Files
AriWeb-Suite/modules/arimail/haraka/run.mjs
T

199 lines
17 KiB
JavaScript

/**
* 내장 Haraka 관리 프로세스(1.0.0, 요구사항 1) — systemd 서비스 <APP_NAME>-haraka 가 실행합니다(deploy/systemd/mail-haraka.service.in).
*
* 왜 따로 두나
* 게이트웨이 프로세스는 systemd 의 NoNewPrivileges 아래에서 돌아 다른 서비스를 켜고 끌 수 없습니다(sudo 불가). 대신 이 관리 프로세스가
* 항상 떠 있으면서 설정 파일(config.json, config.mjs)을 5초마다 보고, 수신 또는 발신 방식이 haraka 이면 Haraka 설정을 만들어 Haraka 를 자식
* 프로세스로 띄우고, 아니면 끕니다. 그래서 운영자는 웹 화면에서 방식만 고르면 되고 서버에서 명령을 칠 일이 없습니다.
*
* 하는 일
* 1) 설정 읽기: CONFIG_FILE(없으면 DB 파일 옆 config.json). 값이 없으면 환경 변수(HARAKA_*)와 기본값을 씁니다.
* 2) Haraka 설정 만들기: <HARAKA_DIR>/config/{smtp.ini, plugins, me, arimail.ini, tls.ini, dkim.ini, dkim.private.key, outbound.ini} 와 plugins/arimail.js.
* 내용이 바뀐 파일만 다시 쓰고, 바뀌었으면 Haraka 를 다시 띄웁니다.
* 3) Haraka 실행: node <HARAKA_DIR>/node_modules/Haraka/bin/haraka -c <HARAKA_DIR>. 죽으면 5초→최대 60초 간격으로 다시 띄웁니다.
* 4) 상태 기록: 데이터 디렉터리의 haraka-status.json 에 실행 여부·pid·대기 포트·최근 로그를 30초마다(변화가 있으면 즉시) 씁니다.
* 게이트웨이(setup.mjs harakaStatus)와 설정 화면이 이 파일로 상태를 보여 줍니다.
*
* 환경 변수(gateway.env 를 그대로 받음): DB_FILE 또는 CONFIG_FILE, HARAKA_DIR(기본 데이터 디렉터리 옆 haraka/), PORT(게이트웨이 HTTP 포트), MAILBOX(메일 도메인),
* PUBLIC_HOST(기본 호스트 이름), POP_TLS_CERT/POP_TLS_KEY(있으면 Haraka STARTTLS 에 같은 인증서), HARAKA_LOGLEVEL(기본 info).
*
* 다중 도메인(1.0.7): 데이터 디렉터리의 도메인 등록부(domains.json)와 각 추가 도메인의 설정 파일(domains/<도메인>/config.json)도 읽습니다.
* - 수신 방식이 haraka 인 모든(켜진) 도메인을 arimail.ini 의 domains= 에 넣어 Haraka 가 그 도메인들의 메일을 받습니다(게이트웨이가 받는 사람 주소로 도메인을 나눔).
* - 발신 방식이 haraka 이고 DKIM 키가 있는 도메인마다 config/dkim/<도메인>/{private,selector} 를 만들어 haraka-plugin-dkim 이 보낸 사람 도메인에 맞는 키로 서명합니다.
* - 포트·호스트 이름·게이트웨이 토큰은 기본 도메인(환경 파일의 도메인) 설정을 씁니다. Haraka 는 서버에 하나입니다.
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, copyFileSync, statSync, readdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const DB_FILE = process.env.DB_FILE || join(process.cwd(), 'data', 'mail-gateway.sqlite');
const CONFIG_FILE = process.env.CONFIG_FILE || join(dirname(DB_FILE), 'config.json');
const DATA_DIR = dirname(CONFIG_FILE);
const HARAKA_DIR = process.env.HARAKA_DIR || join(DATA_DIR, '..', 'haraka');
const STATUS_FILE = join(DATA_DIR, 'haraka-status.json');
const HARAKA_BIN = join(HARAKA_DIR, 'node_modules', 'Haraka', 'bin', 'haraka');
const CHECK_MS = Number(process.env.HARAKA_CHECK_MS || 5000);
const log = (...a) => console.log(new Date().toISOString().slice(11, 19), ...a);
let child = null, startedAt = null, restarts = 0, lastExit = null, backoff = 5000, restartTimer = null, stopping = false, expectedExit = false;
const lastLog = []; const remember = (line) => { for (const l of String(line).split(/\r?\n/)) if (l.trim()) { lastLog.push(l.slice(0, 300)); if (lastLog.length > 15) lastLog.shift(); } };
let lastStatusWrite = 0, lastStatusJson = '';
/** 설정 파일 + 환경 변수 → 이 프로세스가 쓰는 값. 파일을 못 읽으면 null(Haraka 를 띄우지 않음). */
function readSettings() {
let cfg = {};
try { cfg = JSON.parse(readFileSync(CONFIG_FILE, 'utf8')); } catch (e) { if (e.code !== 'ENOENT') log('설정 파일 읽기 실패:', e.message); return null; }
const pick = (key, fallback) => (cfg[key] !== undefined && cfg[key] !== '' && cfg[key] !== null ? cfg[key] : process.env[key] !== undefined && process.env[key] !== '' ? process.env[key] : fallback);
const mailbox = String(process.env.MAILBOX || 'admin@arihakdang.com');
const domain = mailbox.split('@')[1] || 'localhost';
const hostname = String(pick('HARAKA_HOSTNAME', process.env.PUBLIC_HOST || 'mail.' + domain)).trim();
// 1.0.7: 등록부의 추가 도메인(켜진 것만)과 그 설정 파일. 등록부가 없으면 기본 도메인 하나(1.0.5 까지와 같음).
const all = [{ domain, cfg }];
try {
const reg = JSON.parse(readFileSync(join(DATA_DIR, 'domains.json'), 'utf8'));
for (const [d, entry] of Object.entries(reg.domains || {})) {
if (d === reg.primary || d === domain || !entry || entry.enabled === false) continue;
let c = {}; try { c = JSON.parse(readFileSync(join(DATA_DIR, 'domains', d, 'config.json'), 'utf8')); } catch { c = {}; }
all.push({ domain: d, cfg: c });
}
} catch { /* 등록부 없음 = 단일 도메인 */ }
const inboundDomains = all.filter((x) => x.cfg.inbound === 'haraka').map((x) => x.domain);
// 서명 키: 발신 haraka + DKIM 키가 있는 도메인. 기본 도메인은 설정 파일에 없으면 환경 변수(HARAKA_DKIM_*)도 봅니다(예전 방식).
const signers = all.filter((x) => x.cfg.outbound === 'haraka').map((x) => ({ domain: x.domain, selector: String((x.domain === domain ? pick('HARAKA_DKIM_SELECTOR', 'arimail') : x.cfg.HARAKA_DKIM_SELECTOR) || 'arimail'), privateKey: String((x.domain === domain ? pick('HARAKA_DKIM_PRIVATE', '') : x.cfg.HARAKA_DKIM_PRIVATE) || '') })).filter((x) => x.privateKey);
const inbound = inboundDomains.length > 0, outbound = all.some((x) => x.cfg.outbound === 'haraka');
const tlsCert = process.env.POP_TLS_CERT, tlsKey = process.env.POP_TLS_KEY;
const tls = tlsCert && tlsKey && existsSync(tlsCert) && existsSync(tlsKey);
const primarySigner = signers.find((x) => x.domain === domain) || signers[0] || null;
return {
enabled: inbound || outbound, inbound, outbound, domain, domains: inboundDomains, signers, hostname,
port: Number(pick('HARAKA_PORT', 25)), relayPort: Number(pick('HARAKA_RELAY_PORT', 2525)),
token: String(pick('HARAKA_TOKEN', '')), gateway: `http://127.0.0.1:${Number(process.env.PORT || 8790)}`,
// dkimSelector/dkimPrivate: 단일 도메인 폴백([sign] 절). 도메인별 디렉터리(config/dkim/<도메인>)가 먼저 적용됩니다.
dkimSelector: primarySigner ? primarySigner.selector : String(pick('HARAKA_DKIM_SELECTOR', 'arimail')), dkimPrivate: primarySigner ? primarySigner.privateKey : '',
tls: tls ? { cert: tlsCert, key: tlsKey } : null, loglevel: String(process.env.HARAKA_LOGLEVEL || 'info'),
};
}
/** Haraka 설정 파일 내용. 키는 <HARAKA_DIR> 기준 상대 경로. */
function renderFiles(s) {
const files = {};
// 수신 포트는 모든 주소(IPv6 포함 [::0]), 제출 포트는 루프백만. 발신만 쓰면 25 를 열 필요가 없지만 수신·발신을 바꾸는 순간 재시작 없이 동작하도록 둘 다 엽니다.
const listen = [...(s.inbound ? [`[::0]:${s.port}`] : []), `127.0.0.1:${s.relayPort}`].join(',');
files['config/smtp.ini'] = `; AriMail 이 만든 파일 — 직접 고치지 마세요(haraka/run.mjs 가 설정 파일로 다시 만듭니다)\nlisten=${listen}\nnodes=1\ndaemonize=false\nspool_dir=${join(HARAKA_DIR, 'queue')}\n`;
files['config/me'] = s.hostname + '\n';
files['config/plugins'] = ['; AriMail 이 만든 파일', ...(s.tls ? ['tls'] : []), 'arimail', ...(s.outbound && s.signers.length ? ['dkim'] : [])].join('\n') + '\n';
// domains=: 수신 방식이 haraka 인 모든 도메인(1.0.7). 하나도 없으면(발신만) 기본 도메인만 적어 두되 수신 포트는 열지 않습니다.
files['config/arimail.ini'] = `[main]\ngateway=${s.gateway}\ntoken=${s.token}\nrelay_port=${s.relayPort}\ndomains=${(s.domains.length ? s.domains : [s.domain]).join(',')}\ntimeout_ms=30000\n`;
files['config/tls.ini'] = s.tls ? `key=${s.tls.key}\ncert=${s.tls.cert}\n[outbound]\n` : `; TLS 인증서 없음 — POP_TLS_CERT/POP_TLS_KEY 가 읽히면 STARTTLS 를 켭니다\n[outbound]\n`;
files['config/outbound.ini'] = `received_header=AriMail\nenable_tls=true\n`;
files['config/log.ini'] = `level=${s.loglevel}\ntimestamps=false\nformat=default\n`;
// 1.0.7: 단일 도메인 폴백([sign] 의 domain/selector + dkim.private.key)은 쓰지 않습니다 — 키가 없는 다른 도메인의 메일이 기본 도메인 키(d=기본 도메인)로 서명되어
// DMARC 정렬이 조용히 깨지기 때문입니다. 키는 도메인별 디렉터리(config/dkim/<도메인>/)에만 두고, 키 없는 도메인은 서명하지 않습니다.
files['config/dkim.ini'] = `[sign]\nenabled=${s.outbound && s.signers.length ? 'true' : 'false'}\nheaders=From, Sender, Reply-To, Subject, Date, Message-ID, To, Cc, MIME-Version, Content-Type, Content-Transfer-Encoding\n[canon]\nbody=relaxed\n[verify]\nenabled=true\n`;
// 도메인별 서명 키(1.0.7): haraka-plugin-dkim 은 보낸 사람 도메인의 config/dkim/<도메인>/ 디렉터리에서 키를 찾습니다.
for (const x of s.signers) { files[`config/dkim/${x.domain}/selector`] = x.selector + '\n'; files[`config/dkim/${x.domain}/private`] = x.privateKey.endsWith('\n') ? x.privateKey : x.privateKey + '\n'; }
files['plugins/arimail.js'] = readFileSync(join(HERE, 'plugins', 'arimail.js'), 'utf8');
return files;
}
/**
* Haraka 기본 설정 파일 채우기. `haraka -c <dir>` 는 <dir>/config 만 보므로 Haraka 패키지의 기본 설정(connection.ini·outbound.ini·host_list …)이 없으면
* 핵심 코드가 cfg.message 같은 값을 못 읽어 접속 때 예외를 냅니다(`haraka -i` 가 하는 일과 같음). 우리가 만드는 파일은 덮어쓰고, 그 밖의 기본 파일은 없을 때만 복사합니다.
*/
function seedDefaults(ours) {
const src = join(HARAKA_DIR, 'node_modules', 'Haraka', 'config'); let entries = [];
try { entries = readdirSync(src); } catch { return; }
for (const name of entries) {
const rel = 'config/' + name; if (ours[rel]) continue;
const dst = join(HARAKA_DIR, rel);
try { if (!existsSync(dst) && statSync(join(src, name)).isFile()) copyFileSync(join(src, name), dst); } catch (e) { log('기본 설정 복사 실패', name, e.message); }
}
}
/** 바뀐 파일만 씁니다. 하나라도 바뀌면 true. */
function writeFiles(files) {
let changed = false;
mkdirSync(join(HARAKA_DIR, 'config'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'plugins'), { recursive: true }); mkdirSync(join(HARAKA_DIR, 'queue'), { recursive: true });
seedDefaults(files);
for (const [rel, content] of Object.entries(files)) {
const file = join(HARAKA_DIR, rel); let current = null;
mkdirSync(dirname(file), { recursive: true }); // config/dkim/<도메인>/ 같은 하위 디렉터리(1.0.7)
try { current = readFileSync(file, 'utf8'); } catch { current = null; }
if (current === content) continue;
writeFileSync(file, content, { mode: rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644 }); changed = true;
try { chmodSync(file, rel.includes('private') || rel.includes('arimail.ini') ? 0o600 : 0o644); } catch { /* 무시 */ }
}
// 더는 서명하지 않는 도메인의 키 디렉터리는 지웁니다(도메인 삭제·발신 방식 변경). config/dkim 은 이 프로그램이 전부 관리하는 디렉터리입니다.
const keep = new Set(Object.keys(files).map((rel) => rel.match(/^config\/dkim\/([^/]+)\//)?.[1]).filter(Boolean));
// 1.0.5 까지 쓰던 단일 도메인 키 파일은 지웁니다(폴백 서명 방지).
try { if (existsSync(join(HARAKA_DIR, 'config', 'dkim.private.key'))) { rmSync(join(HARAKA_DIR, 'config', 'dkim.private.key')); changed = true; } } catch { /* 무시 */ }
try { for (const name of readdirSync(join(HARAKA_DIR, 'config', 'dkim'))) { const dir = join(HARAKA_DIR, 'config', 'dkim', name); if (!keep.has(name) && statSync(dir).isDirectory()) { rmSync(dir, { recursive: true, force: true }); changed = true; } } } catch { /* 디렉터리 없음 */ }
return changed;
}
function installed() { return existsSync(HARAKA_BIN); }
function harakaVersion() { try { return JSON.parse(readFileSync(join(HARAKA_DIR, 'node_modules', 'Haraka', 'package.json'), 'utf8')).version; } catch { return null; } }
function startHaraka(s) {
if (child || stopping) return;
log(`Haraka 시작: ${HARAKA_BIN} -c ${HARAKA_DIR} (수신 ${s.inbound ? s.port : '꺼짐'} · 제출 127.0.0.1:${s.relayPort})`);
child = spawn(process.execPath, [HARAKA_BIN, '-c', HARAKA_DIR], { cwd: HARAKA_DIR, env: { ...process.env, HARAKA: HARAKA_DIR }, stdio: ['ignore', 'pipe', 'pipe'] });
startedAt = new Date().toISOString();
child.stdout.on('data', (c) => { process.stdout.write(c); remember(c.toString('utf8')); });
child.stderr.on('data', (c) => { process.stderr.write(c); remember(c.toString('utf8')); });
child.on('exit', (code, signal) => {
lastExit = { code: code ?? signal, at: new Date().toISOString() }; const uptime = Date.now() - Date.parse(startedAt);
child = null; startedAt = null; log(`Haraka 종료: ${code ?? signal}`);
if (stopping) return;
// 우리가 끈 것(설정 변경·사용 중지)은 장애가 아니므로 재시작 횟수·대기 시간을 늘리지 않고 다음 점검(tick)에서 필요하면 바로 다시 띄웁니다.
if (expectedExit) { expectedExit = false; backoff = 5000; writeStatus(readSettings(), true); return; }
// 스스로 죽었으면: 오래 잘 돌다 죽었으면 5초 뒤, 금방 죽었으면 간격을 늘려 다시 띄웁니다(설정 오류로 무한 재시작 폭주 방지).
backoff = uptime > 60000 ? 5000 : Math.min(60000, backoff * 2); restarts++;
restartTimer = setTimeout(() => { restartTimer = null; const s2 = readSettings(); if (s2?.enabled && installed()) startHaraka(s2); }, backoff);
writeStatus(readSettings(), true);
});
writeStatus(s, true);
}
function stopHaraka(reason) {
if (restartTimer) { clearTimeout(restartTimer); restartTimer = null; }
if (!child) return;
log(`Haraka 중지(${reason})`);
expectedExit = true; const c = child; c.kill('SIGTERM'); setTimeout(() => { try { c.kill('SIGKILL'); } catch { /* 이미 끝남 */ } }, 8000).unref();
}
/** 상태 파일 쓰기. 내용이 같으면 30초마다 updatedAt 만 갱신(살아 있음 표시). */
function writeStatus(s, force = false) {
const status = {
enabled: !!s?.enabled, inbound: !!s?.inbound, outbound: !!s?.outbound, installed: installed(), version: harakaVersion(),
running: !!child, pid: child?.pid || null, startedAt, restarts, lastExit, lastLog,
listen: s ? [...(s.inbound ? [`0.0.0.0:${s.port}`] : []), `127.0.0.1:${s.relayPort}`] : [], hostname: s?.hostname || null, domain: s?.domain || null,
// 1.0.7: 수신하는 도메인 목록과 DKIM 서명 도메인 목록(설정 화면의 Haraka 상태에 보임)
domains: s?.domains || [], signers: (s?.signers || []).map((x) => x.domain),
dkim: !!(s?.signers?.length), tls: !!s?.tls, configDir: join(HARAKA_DIR, 'config'), updatedAt: new Date().toISOString(),
};
const json = JSON.stringify({ ...status, updatedAt: null });
if (!force && json === lastStatusJson && Date.now() - lastStatusWrite < 30000) return;
try { writeFileSync(STATUS_FILE + '.tmp', JSON.stringify(status, null, 1)); chmodSync(STATUS_FILE + '.tmp', 0o644); copyFileSync(STATUS_FILE + '.tmp', STATUS_FILE); } catch (e) { log('상태 파일 쓰기 실패:', e.message); }
lastStatusJson = json; lastStatusWrite = Date.now();
}
let lastFiles = null;
function tick() {
const s = readSettings();
if (!s || !s.enabled) { if (child) stopHaraka('설정에서 Haraka 를 쓰지 않음'); writeStatus(s); return; }
if (!installed()) { writeStatus(s); return; }
if (!s.token) { log('HARAKA_TOKEN 이 없어 기다립니다(게이트웨이가 처음 뜨면 만듭니다)'); writeStatus(s); return; }
const files = renderFiles(s);
const changed = writeFiles(files);
const key = JSON.stringify(files);
if (child && (changed || (lastFiles && lastFiles !== key))) { log('설정이 바뀌어 Haraka 를 다시 띄웁니다'); stopHaraka('설정 변경'); }
lastFiles = key;
if (!child && !restartTimer) startHaraka(s);
writeStatus(s);
}
log(`AriMail Haraka 관리 시작 — 설정 ${CONFIG_FILE}, Haraka ${HARAKA_DIR}${installed() ? ' (v' + harakaVersion() + ')' : ' (미설치: deploy/haraka-apply.sh 로 설치)'}`);
tick(); const timer = setInterval(tick, CHECK_MS);
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { stopping = true; clearInterval(timer); stopHaraka(sig); writeStatus(readSettings(), true); setTimeout(() => process.exit(0), child ? 2000 : 0); });