Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
# haraka/ — AriMail 내장 Haraka (1.0.0)
Haraka 를 게이트웨이와 함께 띄워 Resend 없이 메일을 받고(MX) 보낼 수 있게 하는 묶음입니다. 자세한 설명은 `docs/SETUP.md` 12절.
| 파일 | 역할 |
|---|---|
| `package.json` | Haraka 3.3.4 + `haraka-plugin-dkim`. `deploy/haraka-apply.sh` 가 서버의 `<APP_DIR>/haraka` 에 npm 으로 설치합니다(저장소에는 node_modules 를 두지 않음). |
| `run.mjs` | 관리 프로세스(systemd `<APP_NAME>-haraka`). 설정 파일(config.json)을 5초마다 보고 수·발신 방식이 haraka 이면 `config/*` 와 `plugins/arimail.js` 를 만들어 Haraka 를 자식으로 띄우고, 아니면 끕니다. 상태는 `data/haraka-status.json`. |
| `plugins/arimail.js` | Haraka 플러그인. 로컬 제출 포트에서 온 접속은 relaying(발신), 그 밖은 수신으로 보고 RCPT 를 게이트웨이에 확인한 뒤 원본을 `POST /api/inbound/haraka` 로 넘깁니다. |
개발 PC 에서 시험하려면: `npm install --prefix <어딘가>/haraka` 로 의존성을 두고 `HARAKA_DIR=<어딘가>/haraka PORT=8790 DB_FILE=<데이터>/mail-gateway.sqlite MAILBOX=admin@example.test node haraka/run.mjs`,
설정 파일의 `HARAKA_PORT`/`HARAKA_RELAY_PORT` 를 1024 이상으로 두면 root 권한 없이 뜹니다.
+9
View File
@@ -0,0 +1,9 @@
{
"name": "arimail-haraka",
"private": true,
"description": "AriMail 내장 Haraka 메일 서버(MTA) — 수신(25) 과 발신(로컬 제출 포트 → 받는 쪽 MX 직접 배달). 설치는 deploy/haraka-apply.sh 가 서버의 <APP_DIR>/haraka 에 npm 으로 합니다.",
"dependencies": {
"Haraka": "3.3.4",
"haraka-plugin-dkim": "1.3.1"
}
}
+111
View File
@@ -0,0 +1,111 @@
'use strict';
/**
* AriMail ↔ Haraka 연결 플러그인(1.0.0, 요구사항 1). haraka/run.mjs 가 Haraka 설치 디렉터리(<APP_DIR>/haraka/plugins/)에 복사하고
* config/plugins 에 'arimail' 로 올립니다. Haraka 플러그인은 CommonJS 라 이 파일만 require 문법을 씁니다.
*
* 하는 일(세 개의 훅)
* connect : 127.0.0.1 에서 "제출 포트(relay_port)" 로 들어온 접속은 게이트웨이가 보내는 메일이므로 connection.relaying = true 로 둡니다.
* relaying 이 켜진 접속은 Haraka 가 받는 쪽 MX 를 찾아 직접 배달하는 "발신" 경로(outbound 큐)로 처리합니다.
* 다른 주소·다른 포트에서 온 접속은 relaying 이 꺼진 "수신" 경로이며, 우리 도메인의 실제 계정으로 오는 메일만 받습니다(오픈 릴레이 방지).
* rcpt : 수신 접속의 받는 사람마다 (1) 우리 도메인인지, (2) 게이트웨이에 그 메일함이 있는지(GET /api/inbound/haraka/rcpt) 확인합니다.
* 없는 사용자는 RCPT 단계에서 바로 거부해(550) 받아 놓고 반송하는 역산란(backscatter)을 만들지 않습니다.
* queue : 수신한 메일 원본을 모아 게이트웨이의 POST /api/inbound/haraka 로 넘깁니다(헤더: 토큰, 트랜잭션 id, 봉투 보낸 사람/받는 사람).
* 게이트웨이가 저장하면 250(OK), 메일함이 가득 차 건너뛰면 452(DENYSOFT — 보낸 쪽이 나중에 다시 시도), 게이트웨이 오류면 451(DENYSOFT).
*
* 설정(config/arimail.ini, run.mjs 가 만듭니다)
* [main] gateway=http://127.0.0.1:8790 token=<HARAKA_TOKEN> relay_port=2525 domains=example.com[,other.com] timeout_ms=30000
*/
const http = require('node:http');
const { URL } = require('node:url'); // Haraka 플러그인 샌드박스에는 전역 URL 이 없습니다
const { Writable } = require('node:stream');
exports.register = function () {
this.load_arimail_ini();
this.register_hook('connect', 'mark_relay');
this.register_hook('rcpt', 'check_rcpt');
this.register_hook('queue', 'deliver');
};
/** arimail.ini 를 읽고, 파일이 바뀌면 다시 읽습니다(Haraka 의 config 감시 기능). */
exports.load_arimail_ini = function () {
this.cfg = this.config.get('arimail.ini', { booleans: [] }, () => this.load_arimail_ini());
const m = this.cfg.main || {};
this.gateway = String(m.gateway || 'http://127.0.0.1:8790').replace(/\/+$/, '');
this.token = String(m.token || '');
this.relayPort = Number(m.relay_port || 2525);
this.domains = String(m.domains || '').split(/[,\s]+/).map((d) => d.trim().toLowerCase()).filter(Boolean);
this.timeoutMs = Number(m.timeout_ms || 30000);
};
const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
// Haraka 3 의 주소 객체(address-rfc2821)는 address 가 속성(문자열)입니다. 예전 판은 메서드였으므로 둘 다 받습니다.
const addrOf = (a) => (a ? String(typeof a.address === 'function' ? a.address() : a.address || '') : '');
/** connect: 로컬 제출 포트로 온 접속은 발신(relaying). */
exports.mark_relay = function (next, connection) {
const port = Number(connection.local && connection.local.port);
if (isLoopback(connection.remote && connection.remote.ip) && port === this.relayPort) {
connection.relaying = true;
connection.loginfo(this, `relay from gateway (local port ${port})`);
}
next();
};
/** 게이트웨이 API 호출(JSON). 실패는 reject. */
exports.gateway_request = function (method, path, headers, body) {
return new Promise((resolve, reject) => {
const url = new URL(this.gateway + path);
const req = http.request({ hostname: url.hostname, port: url.port || 80, path: url.pathname + url.search, method, headers: { 'X-AriMail-Token': this.token, ...headers } }, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => { let data = null; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { data = null; } resolve({ status: res.statusCode, data }); });
});
req.setTimeout(this.timeoutMs, () => req.destroy(new Error('gateway timeout')));
req.on('error', reject);
if (body) req.end(body); else req.end();
});
};
/** rcpt: 수신 접속의 받는 사람 확인. */
exports.check_rcpt = function (next, connection, params) {
if (connection.relaying) return next(); // 발신(게이트웨이 제출)은 어떤 받는 사람이든 허용
const rcpt = params[0];
const addr = addrOf(rcpt).toLowerCase(), domain = String(rcpt.host || addr.split('@')[1] || '').toLowerCase();
if (!this.domains.includes(domain)) return next(DENY, 'Relay access denied');
this.gateway_request('GET', '/api/inbound/haraka/rcpt?addr=' + encodeURIComponent(addr), {})
.then(({ status, data }) => {
if (status === 200 && data && data.local) return next(OK);
if (status === 200 && data && data.local === false) { connection.loginfo(this, `rcpt rejected (no such user): ${addr}`); return next(DENY, 'No such user here'); }
connection.logwarn(this, `gateway rcpt check failed: HTTP ${status}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable');
})
.catch((e) => { connection.logerror(this, `gateway rcpt check error: ${e.message}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); });
};
/** queue: 원본을 모아 게이트웨이로 넘깁니다. */
exports.deliver = function (next, connection) {
const txn = connection.transaction;
if (!txn) return next();
const chunks = [];
const sink = new Writable({ write(chunk, _enc, cb) { chunks.push(chunk); cb(); } });
sink.on('error', (e) => { connection.logerror(this, `message stream error: ${e.message}`); next(DENYSOFT, 'Temporary failure reading message'); });
sink.on('finish', () => {
const eml = Buffer.concat(chunks);
const rcpt = (txn.rcpt_to || []).map((r) => addrOf(r).toLowerCase());
const from = addrOf(txn.mail_from);
const headers = { 'Content-Type': 'message/rfc822', 'Content-Length': String(eml.length), 'X-AriMail-Id': String(txn.uuid || ''), 'X-AriMail-From': from, 'X-AriMail-Rcpt': JSON.stringify(rcpt) };
this.gateway_request('POST', '/api/inbound/haraka', headers, eml)
.then(({ status, data }) => {
if (status >= 200 && status < 300 && data && data.ok) {
if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); }
connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`);
return next(OK, 'Message accepted for delivery');
}
connection.logerror(this, `gateway rejected: HTTP ${status} ${data && data.error ? data.error : ''}`);
next(DENYSOFT, 'Delivery to mailbox temporarily failed');
})
.catch((e) => { connection.logerror(this, `gateway delivery error: ${e.message}`); next(DENYSOFT, 'Delivery to mailbox temporarily failed'); });
});
// haraka-message-stream 의 옵션 뜻(3.1+): dot_stuffed:true = 저장된 SMTP 점 스터핑을 풀어 "로컬 전달용" 원본으로 내보냄(false 는 SMTP 중계용으로 그대로 둠).
// 이름이 직관과 반대이므로 주의. 줄 끝은 CRLF 로 맞춥니다(게이트웨이 해석기 기준).
txn.message_stream.pipe(sink, { line_endings: '\r\n', dot_stuffed: true });
};
+198
View File
@@ -0,0 +1,198 @@
/**
* 내장 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); });