- mail_services(도메인 단위 논리 리소스, 불변 id, 출처 권한, 도메인별 active owner 1 — 부분 유일 인덱스)·mail_imports 표 추가(기존 표 변경 없음) - discovery 문서에 비밀값이 있으면 거부(참조만 저장), 충돌·중복·누락은 전체 거부, 적용은 트랜잭션 + 감사(immutable), 재적용 멱등, 적용 단위 되돌리기 - hal/index.mjs: mail-import · mail-import-rollback · mail-services · server-token(시스템 신원 토큰, 감사) / app.mjs: /ops/api/mail-services* / ui: /ops/mail HAL 관리형 표 + 포털 중계(#portal-mail) - core.heartbeat → syncFromHeartbeat(Agent 의 arimail 모듈 상태를 last_sync·health 로 반영, 행 없으면 무관) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
128 lines
16 KiB
JavaScript
128 lines
16 KiB
JavaScript
/**
|
|
* scripts/mail-discover.mjs — 운영 중인 AriMail 인스턴스의 **읽기 전용** discovery 문서 만들기(HAL `mail-import` 의 입력, hal/mailsvc.mjs).
|
|
*
|
|
* sudo node scripts/mail-discover.mjs --unit arihakdang-mail.service --app-dir /srv/arihakdang-mail [--etc /etc/arihakdang-mail] [--vhost /etc/nginx/sites-available/mail.arihakdang.conf]
|
|
* [--hook /etc/letsencrypt/renewal-hooks/deploy/arihakdang-mail.sh] [--no-net] [--out <json>]
|
|
*
|
|
* 무엇을 읽나(모두 읽기 전용, 원본 파일은 바꾸지 않음): systemd 유닛 상태(systemctl show)·유닛 파일의 EnvironmentFile 경로, 앱 디렉터리의 current 심링크·REVISION(.json)·package.json,
|
|
* AriMail 도메인 등록부(data/domains.json, AriMail 1.0.7+ 형식)·건강 API(/api/health)·haraka-status.json, 게이트웨이 DB 와 도메인별 테넌트 DB 의 **집계**(계정 수·메일 수·DB 크기 — 주소·내용은 읽지 않음),
|
|
* 리스너(ss -ltnp 에서 유닛 PID 소유 포트), nginx vhost 의 server_name/proxy_pass, TLS 인증서 주체·만료(127.0.0.1:IMAPS 로 handshake), DNS(A·MX, --no-net 이면 생략), certbot 훅 경로.
|
|
* 비밀값은 읽지도 쓰지도 않는다: env/config 는 **키 이름과 경로·권한**만(값 없음), 메일 주소는 제외, TLS 개인키 경로만. 결과는 `secretRefs` 에 참조로 남는다.
|
|
* 이 파일은 단독 실행용이라 저장소의 다른 모듈을 import 하지 않는다(원본 호스트에 파일 하나만 복사해 실행).
|
|
*/
|
|
import { readFileSync, existsSync, readdirSync, statSync, realpathSync } from 'node:fs';
|
|
import { join, basename } from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { hostname } from 'node:os';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import { connect as tlsConnect } from 'node:tls';
|
|
import { promises as dns } from 'node:dns';
|
|
|
|
export const FORMAT = 1;
|
|
const args = process.argv.slice(2);
|
|
const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
|
const flag = (n) => args.includes('--' + n);
|
|
const readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
|
|
const run = (cmd, a) => { try { return execFileSync(cmd, a, { encoding: 'utf8', timeout: 10000 }); } catch (_) { return ''; } };
|
|
const mode = (f) => { try { const st = statSync(f); return { mode: (st.mode & 0o777).toString(8), size: st.size }; } catch (_) { return null; } };
|
|
const owner = (f) => run('stat', ['-c', '%U:%G', f]).trim() || null;
|
|
/** 환경 파일의 **키 이름만**(값은 절대 읽지 않는다) */
|
|
const envKeys = (f) => { try { return readFileSync(f, 'utf8').split('\n').map((l) => /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(l)).filter(Boolean).map((m) => m[1]); } catch (_) { return null; } };
|
|
|
|
export function unitShow(unit) {
|
|
const out = run('systemctl', ['show', unit, '-p', 'ActiveState,SubState,UnitFileState,NRestarts,ExecMainPID,ExecMainStartTimestamp,User,Group,WorkingDirectory,FragmentPath,EnvironmentFiles']);
|
|
const kv = Object.fromEntries(out.split('\n').filter((l) => l.includes('=')).map((l) => { const i = l.indexOf('='); return [l.slice(0, i), l.slice(i + 1)]; }));
|
|
if (!Object.keys(kv).length) return null;
|
|
return { active: kv.ActiveState || null, sub: kv.SubState || null, enabled: kv.UnitFileState || null, nRestarts: Number(kv.NRestarts || 0), mainPid: Number(kv.ExecMainPID || 0) || null, startedAt: kv.ExecMainStartTimestamp || null, user: kv.User || null, group: kv.Group || null, workingDirectory: kv.WorkingDirectory || null, fragmentPath: kv.FragmentPath || null, environmentFiles: (kv.EnvironmentFiles || '').split(/\s+/).map((x) => x.replace(/\s*\(ignore_errors=\w+\)/, '')).filter((x) => x.startsWith('/')) };
|
|
}
|
|
/** DB 집계: 표 이름·행 수만. 주소·본문은 읽지 않는다. */
|
|
export function dbCounts(file) {
|
|
if (!existsSync(file)) return null;
|
|
const db = new DatabaseSync(file, { readOnly: true });
|
|
try {
|
|
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").all().map((r) => r.name); const counts = {};
|
|
for (const t of tables) { try { counts[t] = db.prepare(`SELECT count(*) c FROM "${t}"`).get().c; } catch (_) { counts[t] = null; } }
|
|
const integrity = db.prepare('PRAGMA integrity_check').get().integrity_check;
|
|
let activeAccounts = null; try { activeAccounts = db.prepare("SELECT count(*) c FROM mail_users WHERE status='active'").get().c; } catch (_) {}
|
|
const wal = existsSync(file + '-wal') ? statSync(file + '-wal').size : 0;
|
|
return { file, bytes: statSync(file).size + wal, integrity, accounts: counts.mail_users ?? null, activeAccounts, messages: counts.messages ?? null, attachments: counts.attachments ?? null, outboundQueue: counts.outbound_queue ?? null, migrations: counts.schema_migrations ?? null, tables: Object.keys(counts).length };
|
|
} finally { db.close(); }
|
|
}
|
|
export function listeners(pid) {
|
|
const out = run('ss', ['-ltnpH']); const rows = [];
|
|
for (const l of out.split('\n')) { const m = /LISTEN\s+\S+\s+\S+\s+(\S+):(\d+)\s+\S+\s*(.*)$/.exec(l.trim()); if (!m) continue; const p = /pid=(\d+)/.exec(m[3]); rows.push({ bind: m[1].replace(/^\[|\]$/g, ''), port: Number(m[2]), pid: p ? Number(p[1]) : null }); }
|
|
const mine = rows.filter((r) => r.pid && r.pid === pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'unit' })).sort((a, b) => a.port - b.port);
|
|
const mailPorts = [25, 110, 143, 465, 587, 993, 995]; const others = rows.filter((r) => mailPorts.includes(r.port) && r.pid !== pid).map((r) => ({ port: r.port, bind: r.bind, owner: 'other', pid: r.pid }));
|
|
return { unit: mine, otherMailPortOwners: others, port25Open: rows.some((r) => r.port === 25) };
|
|
}
|
|
const dirBytes = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = join(d, e.name); if (e.isDirectory()) walk(p); else if (e.isFile()) { try { n += statSync(p).size; } catch (_) {} } } }; try { walk(dir); } catch (_) {} return n; };
|
|
function tlsInfo(host, port = 993) {
|
|
return new Promise((resolve) => { const t = setTimeout(() => { try { s.destroy(); } catch (_) {} resolve({ host, port, error: 'timeout' }); }, 5000); const s = tlsConnect({ host: '127.0.0.1', port, servername: host, rejectUnauthorized: false }, () => { const c = s.getPeerCertificate(); clearTimeout(t); s.destroy(); resolve({ host, port, subject: c.subject ? `CN=${c.subject.CN}` : null, issuer: c.issuer ? `${c.issuer.O || ''} ${c.issuer.CN || ''}`.trim() : null, validFrom: c.valid_from ? new Date(c.valid_from).toISOString() : null, validTo: c.valid_to ? new Date(c.valid_to).toISOString() : null, altNames: c.subjectaltname || null, protocol: s.getProtocol() }); }); s.on('error', (e) => { clearTimeout(t); resolve({ host, port, error: e.code || e.message }); }); });
|
|
}
|
|
/** 공개 DNS(1.1.1.1·8.8.8.8)로 조회한다 — 호스트의 /etc/hosts(자기 이름 → 127.0.0.1)가 증거를 오염시키지 않게. */
|
|
async function dnsInfo(hosts, domains) {
|
|
const r = new dns.Resolver({ timeout: 4000, tries: 2 }); try { r.setServers(['1.1.1.1', '8.8.8.8']); } catch (_) {}
|
|
const out = { _resolver: 'public:1.1.1.1,8.8.8.8' };
|
|
for (const h of hosts) { out[h] = {}; try { out[h].a = await r.resolve4(h); } catch (e) { out[h].a = []; out[h].error = e.code || e.message; } }
|
|
for (const dn of domains) { out[dn] = out[dn] || {}; try { out[dn].mx = (await r.resolveMx(dn)).sort((a, b) => a.priority - b.priority).map((m) => m.exchange); } catch (e) { out[dn].mx = []; } try { out[dn].txt = (await r.resolveTxt(dn)).map((t) => t.join('')).filter((t) => /^v=spf1/i.test(t)); } catch (e) { out[dn].txt = []; } }
|
|
return out;
|
|
}
|
|
async function httpHealth(url) { try { const r = await fetch(url, { signal: AbortSignal.timeout(5000) }); const j = await r.json().catch(() => null); return { status: r.status, body: j && typeof j === 'object' ? { ok: j.ok, version: j.version, domains: j.domains } : null }; } catch (e) { return { status: 0, error: e.code || e.message }; } }
|
|
|
|
/** discovery 문서 만들기(순수: 파일 시스템·명령 결과만 읽는다). opts.net=false 면 TLS/DNS/HTTP 를 생략. */
|
|
export async function discover({ unit, appDir, etcDir = null, vhost = null, hook = null, net = true, host = hostname() }) {
|
|
const us = unitShow(unit); const haraka = unitShow(unit.replace(/\.service$/, '') + '-haraka.service');
|
|
const current = existsSync(join(appDir, 'current')) ? realpathSync(join(appDir, 'current')) : null;
|
|
const revision = readJson(join(appDir, 'current', 'REVISION.json')) || (existsSync(join(appDir, 'current', 'REVISION')) ? { commit: readFileSync(join(appDir, 'current', 'REVISION'), 'utf8').trim() } : {});
|
|
const pkg = readJson(join(appDir, 'current', 'package.json'), {});
|
|
const nodeBin = us && us.fragmentPath ? (/^ExecStart=(\S+)/m.exec(readFileSync(us.fragmentPath, 'utf8')) || [])[1] || null : null;
|
|
const nodeVersion = nodeBin ? run(nodeBin, ['--version']).trim() || null : null;
|
|
const dataDir = join(appDir, 'data'); const reg = readJson(join(dataDir, 'domains.json'), { domains: {} });
|
|
const regDomains = Array.isArray(reg.domains) ? reg.domains : Object.values(reg.domains || {});
|
|
const domains = []; const byDomain = {}; let accounts = 0, messages = 0, storageBytes = 0;
|
|
const main = dbCounts(join(dataDir, 'mail-gateway.sqlite'));
|
|
for (const x of regDomains) {
|
|
const dn = String(x.domain || '').toLowerCase(); const tenant = join(dataDir, 'domains', dn, 'mail.sqlite'); const isPrimary = !!x.primary || dn === String(reg.primary || '').toLowerCase();
|
|
const c = isPrimary ? main : dbCounts(tenant); const tenantDir = join(dataDir, 'domains', dn);
|
|
const agg = c ? { accounts: c.accounts, activeAccounts: c.activeAccounts, mailboxes: c.accounts, messages: c.messages, attachments: c.attachments, outboundQueue: c.outboundQueue, dbBytes: c.bytes, dbIntegrity: c.integrity, dbMigrations: c.migrations, storageBytes: isPrimary ? dirBytes(dataDir) - dirBytes(join(dataDir, 'domains')) - dirBytes(join(dataDir, 'backups')) : dirBytes(tenantDir) } : null;
|
|
if (agg) { accounts += agg.accounts || 0; messages += agg.messages || 0; storageBytes += agg.storageBytes || 0; byDomain[dn] = agg; }
|
|
domains.push({ domain: dn, enabled: x.enabled !== false, primary: isPrimary, hosts: (x.hosts || []).map(String), authMode: x.auth && x.auth.mode || null, operating: x.operating || 'active', ariweb: x.ariweb ? { kind: x.ariweb.kind || null, siteId: x.ariweb.siteId || null, groupId: x.ariweb.groupId || null, mailServiceId: x.ariweb.mailServiceId || null } : null, note: x.note || null, tenantDb: isPrimary ? join(dataDir, 'mail-gateway.sqlite') : (existsSync(tenant) ? tenant : null), createdAt: x.createdAt || null, updatedAt: x.updatedAt || null });
|
|
}
|
|
const hs = readJson(join(dataDir, 'haraka-status.json'), null);
|
|
const cfg = readJson(join(dataDir, 'config.json'), null); const configKeys = cfg ? Object.keys(cfg).filter((k) => k !== '_meta') : null;
|
|
const providerKind = hs && hs.enabled ? 'haraka' : cfg && (cfg.outbound || cfg.inbound) ? String((cfg.outbound && cfg.outbound.provider) || (cfg.inbound && cfg.inbound.provider) || (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown')) : (configKeys && configKeys.some((k) => /^RESEND/.test(k)) ? 'resend' : 'unknown');
|
|
const secretRefs = [];
|
|
for (const f of (us && us.environmentFiles) || []) secretRefs.push({ name: basename(f), ref: `file:${f}`, ...(mode(f) ? { mode: mode(f).mode } : {}), owner: owner(f), keys: envKeys(f) });
|
|
if (existsSync(join(dataDir, 'config.json'))) secretRefs.push({ name: 'config.json', ref: `file:${join(dataDir, 'config.json')}`, mode: mode(join(dataDir, 'config.json')).mode, owner: owner(join(dataDir, 'config.json')), keys: configKeys });
|
|
if (etcDir && existsSync(join(etcDir, 'tls'))) secretRefs.push({ name: 'tls', ref: `dir:${join(etcDir, 'tls')}`, files: readdirSync(join(etcDir, 'tls')) });
|
|
if (existsSync(join(dataDir, 'setup-token'))) secretRefs.push({ name: 'setup-token', ref: `file:${join(dataDir, 'setup-token')}`, mode: mode(join(dataDir, 'setup-token')).mode });
|
|
const vh = vhost && existsSync(vhost) ? readFileSync(vhost, 'utf8') : '';
|
|
const evidence = { nginx: vhost ? { vhostFile: vhost, exists: !!vh, serverNames: [...new Set([...vh.matchAll(/server_name\s+([^;]+);/g)].flatMap((m) => m[1].trim().split(/\s+/)))], proxyPass: [...new Set([...vh.matchAll(/proxy_pass\s+([^;]+);/g)].map((m) => m[1].trim()))], httpRedirect: /return\s+301\s+https/.test(vh), sslCertificate: (/ssl_certificate\s+([^;]+);/.exec(vh) || [])[1] || null } : null, certbot: hook ? { hookFile: hook, exists: existsSync(hook), restartsUnit: existsSync(hook) ? /systemctl\s+restart/.test(readFileSync(hook, 'utf8')) : null } : null, haraka: hs ? { enabled: !!hs.enabled, running: !!hs.running, inbound: !!hs.inbound, outbound: !!hs.outbound, listen: hs.listen || [], version: hs.version || null, dkim: !!hs.dkim, updatedAt: hs.updatedAt || null } : null };
|
|
const hostsAll = [...new Set(domains.flatMap((x) => x.hosts.map((h) => h.split('/')[0])))];
|
|
let health = null;
|
|
if (net) {
|
|
health = await httpHealth('http://127.0.0.1:8790/api/health');
|
|
evidence.tls = hostsAll.length ? await tlsInfo(hostsAll[0]) : null;
|
|
evidence.dns = await dnsInfo(hostsAll, domains.map((x) => x.domain));
|
|
evidence.http = { localHealth: health, public: {} };
|
|
for (const h of hostsAll.slice(0, 4)) { try { const r = await fetch(`https://${h}/api/health`, { signal: AbortSignal.timeout(8000) }); const j = await r.json().catch(() => null); evidence.http.public[h] = { status: r.status, ok: !!(j && j.ok), version: j && j.version }; const r2 = await fetch(`http://${h}/`, { redirect: 'manual', signal: AbortSignal.timeout(8000) }); evidence.http.public[h].httpRedirect = { status: r2.status, location: r2.headers.get('location') }; } catch (e) { evidence.http.public[h] = { error: e.code || e.message }; } }
|
|
}
|
|
const doc = {
|
|
format: FORMAT, collectedAt: new Date().toISOString(), collector: 'scripts/mail-discover.mjs',
|
|
source: { hostname: host, unit, unitState: us, harakaUnit: haraka, appDir, currentTarget: current, user: (us && us.user) || (owner(dataDir) || '').split(':')[0] || null, nodeBin, nodeVersion, revision, packageName: pkg.name || null, packageVersion: pkg.version || null, healthUrl: 'http://127.0.0.1:8790/api/health', health: health && health.body ? health.body : { ok: null, version: pkg.version || null, domains: domains.length, note: net ? 'health 응답 없음' : 'no-net' }, dataDir, dataBytes: dirBytes(dataDir), releases: existsSync(join(appDir, 'releases')) ? readdirSync(join(appDir, 'releases')).sort() : [], previous: readJson(join(appDir, '.previous.json'), null) },
|
|
domains, listeners: us && us.mainPid ? listeners(us.mainPid).unit : [], listenerDetail: us && us.mainPid ? listeners(us.mainPid) : null,
|
|
aggregates: { accounts, mailboxes: accounts, messages, storageBytes, domains: domains.length, enabledDomains: domains.filter((x) => x.enabled).length, byDomain },
|
|
provider: { kind: providerKind, haraka: evidence.haraka, configPath: existsSync(join(dataDir, 'config.json')) ? join(dataDir, 'config.json') : null, configKeys },
|
|
secretRefs, evidence,
|
|
};
|
|
return doc;
|
|
}
|
|
|
|
if (process.argv[1] && /mail-discover\.mjs$/.test(process.argv[1]) && !process.env.ARIWEB_DISCOVER_NO_MAIN) {
|
|
const unit = opt('unit'); const appDir = opt('app-dir'); if (!unit || !appDir) { console.error('--unit <유닛> --app-dir <앱 디렉터리> 가 필요합니다'); process.exit(1); }
|
|
const doc = await discover({ unit: unit.endsWith('.service') ? unit : unit + '.service', appDir, etcDir: opt('etc'), vhost: opt('vhost'), hook: opt('hook'), net: !flag('no-net') });
|
|
const text = JSON.stringify(doc, null, 1);
|
|
if (/-----BEGIN|password=|secret=/i.test(text)) { console.error('비밀 형태의 내용이 포함되어 출력하지 않습니다'); process.exit(2); }
|
|
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); console.error(`discovery → ${opt('out')} (${doc.domains.length} 도메인, 계정 ${doc.aggregates.accounts}, 메일 ${doc.aggregates.messages})`); } else console.log(text);
|
|
}
|