diff --git a/VERSION_HISTORY.md b/VERSION_HISTORY.md index 37d69fa..92cd0a7 100644 --- a/VERSION_HISTORY.md +++ b/VERSION_HISTORY.md @@ -124,6 +124,12 @@ - **`scripts/webmaker-inventory.mjs`**(+ `test/webmaker-inventory.test.mjs` 5건): 운영 Webmaker 데이터(JSON)·포털 DB·HAL DB 를 읽기 전용으로 안정 식별자(프로젝트 32-hex id, 포털 `s_/g_/u_`, HAL `srv_`)로 대조해 연결 등록부 일치·충돌·중복·누락, SSO 회원(`aw--`) 신원, 도메인, Gitea `sites//main` ref, HAL 모듈 등록·버전·건강을 판정한다. 비밀로 보이는 키는 출력 전에 거부(`assertNoSecrets`). 충돌·누락이 있으면 종료 코드 2. - 2026-10-27 hostinger 실데이터 결과: 프로젝트 10(포털 연결 일치 3·Webmaker 고유 7), 회원 12(SSO 3 일치·고유 9), 도메인 2, Gitea refs 13, HAL `module_targets.webmaker` 0.1.1 health ok — 충돌 0·누락 0, **적용 변경 없음**(교체도 불필요). 사전 스냅샷·복원 검증·사후 readback 은 `docs/verification/webmaker-hal-link-2026-10-27.md`, 절차는 RUNBOOK §8. +### 기존 메일 서비스(AriMail 인스턴스) ↔ HAL 관리형 MailService/Node 가져오기(2026-10-27, mail.arihakdang.com · webmail.ibit.kr → bear `hal21.ariweb.net`) +- **`hal/mailsvc.mjs`**(+ 마이그레이션 `hal_0003_mail_services`, `test/hal-mailimport.test.mjs` 7건): HAL 관리형 메일 서비스 등록부(`mail_services`, 도메인 단위 논리 리소스, 불변 id `mls_…`, 출처 권한 `arimail::`, 도메인별 active owner 정확히 하나 — 부분 유일 인덱스)와 가져오기 단위(`mail_imports`). 읽기 전용 discovery 문서만 입력으로 받고 **비밀값이 어디에든 있으면 거부**(비밀은 `secretRefs` 의 file:/dir:/vault:/env: 참조만 저장). dry-run 이 기본, 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락이 하나라도 있으면 아무것도 적용하지 않음, 적용은 한 트랜잭션 + 감사(immutable), 같은 출처·서버의 재적용은 update(멱등), 적용 단위 되돌리기(생성 행 삭제·갱신 행 복원). 소유 조직은 증거로 확인되기 전까지 `unassigned_legacy_managed`. Agent heartbeat 의 arimail 모듈 상태가 그 서버의 행에 `last_sync`·`health` 로 비친다(`core.heartbeat`). Data Plane(AriMail 송수신·계정·메일·DNS/MX·TLS·provider)은 바꾸지 않는다 — HAL 이 멈춰도 메일은 그대로다. +- **`scripts/mail-discover.mjs`**(+ `test/mail-discover.test.mjs` 2건): 원본 호스트에서 단독 실행하는 읽기 전용 discovery — 유닛 상태(`systemctl show`)·`current`/REVISION·package.json·AriMail 도메인 등록부(`data/domains.json`)·건강 API·haraka-status·게이트웨이/테넌트 DB **집계**(계정·메일 수·크기·무결성, 주소·내용 미열람)·유닛 PID 소유 리스너·nginx vhost·TLS 주체/만료·공개 DNS(1.1.1.1/8.8.8.8, 호스트 /etc/hosts 오염 방지)·certbot 훅. env/config 는 키 이름과 경로·권한만. +- **HAL CLI**(`hal/index.mjs`): `mail-import --discovery (--server | --create-server --name …) [--apply]`, `mail-import-rollback --import `, `mail-services`(readback JSON), `server-token --server `(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:`). **운영자 API**: `GET /ops/api/mail-services`(+readback: 총수·active·도메인 중복·서버별·비밀 누출 검사), `GET /ops/api/mail-services/:id`(감사 포함), `GET /ops/api/mail-services/imports`, `POST /ops/api/mail-services/import {discovery, serverId, apply}`(적용은 재인증), `POST /ops/api/mail-services/imports/:id/rollback`(재인증). **운영자 화면** `/ops/mail`: HAL 관리형 메일 서비스 표(도메인·공개 주소·서버/인스턴스·리비전·계정/메일함/메일·건강/동기화·TLS/DNS·비밀 참조 수)를 먼저 그리고 포털 메일 서비스는 그 아래 `#portal-mail` 에 중계로 채운다(`ops.js`). +- 실제 적용 결과·백업·readback 은 `docs/verification/mail-hal-link-2026-10-27.md`, 절차·되돌리기는 `docs/RUNBOOK-0.1.1.md` §9. + ### 도입 소스 - 가져오기: `node scripts/import-sources.mjs` 가 고정 커밋(Webmaker `3b10859884ef2fd0f2af11934ab6c16370fd64b8`, AriMail `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`)을 검사하고 실행에 필요 없는 문서·보고서·샘플을 제외한 전체를 `modules/webmaker`(176 파일)·`modules/arimail`(88 파일)로 복사, 파일별 원본 경로·대상 경로·수정 여부·원본 해시·라이선스 검토를 `IMPORTED_SOURCES.md`/`.json` 에 기록한다. - 라이선스 검토: 두 저장소 모두 `private: true`, 별도 LICENSE 없음 — OZ-Group 내부 전용 소스로 같은 조직의 Ariweb Suite 에서 재사용 가능, 외부 배포 불가. AriMail `haraka/` 는 npm Haraka(MIT) 를 설치 시점에 내려받는 래퍼만 포함. diff --git a/docs/RUNBOOK-0.1.1.md b/docs/RUNBOOK-0.1.1.md index 7cd2339..ef192cc 100644 --- a/docs/RUNBOOK-0.1.1.md +++ b/docs/RUNBOOK-0.1.1.md @@ -98,3 +98,26 @@ node --no-warnings scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker ``` 판정 뜻: `linked_consistent` 양쪽 등록부 일치 · `unlinked_native` Webmaker 고유 프로젝트(변경 대상 아님) · `link_*`/`duplicate`/`missing_project`/`gitea_ref`/`hal_*` 는 충돌·누락이라 적용 금지. 2026-10-27 결과: 프로젝트 10(일치 3·고유 7), 회원 12(SSO 3 일치·고유 9), 충돌 0·누락 0 → **적용 변경 없음**. 되돌리기는 스냅샷 디렉터리의 `ROLLBACK.md`. 남은 사람 결정: hal21(bear) 운영자 자격 증명으로 서버 등록·Agent 재등록 여부(제어 평면 이전은 §34 "하나의 HAL" 절차), 고유 회원 9명의 포털 계정 전환(사용자 자격 증명·동의 필요). + +## 9. 기존 메일 서비스(AriMail) ↔ HAL 관리형 MailService/Node 가져오기 (2026-10-27) +사실(읽기 전용 조사): `https://mail.arihakdang.com/` = hostinger(187.53.133.173, 유닛 `arihakdang-mail`, `/srv/arihakdang-mail`, 실행 계정 arihakdang, Ariweb Suite arimail 모듈 0.1.1 = AriMail 1.0.11, 도메인 6개 멀티테넌트) · `https://webmail.ibit.kr/` = 115.68.194.192(`ssh jitsi`, 유닛 `arimail`, `/srv/arimail`, 실행 계정 arimail, 원본 AriMail 1.0.11 `a07f69f`, 도메인 1개). **서로 다른 호스트·다른 인스턴스**다. 두 호스트 모두 Haraka 비활성(25 포트 없음), 수신 MX 는 Resend(inbound-smtp.ap-northeast-1.amazonaws.com), 제출 587/465·IMAP 143/993·POP 110/995 는 AriMail 프로세스가 직접 연다. hostinger 에는 자체 HAL(`hal.ariweb.net`)과 거기에 등록된 Node Agent 가 이미 있다(bear `hal21` 과 별개 제어 평면). + +원칙: 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 그대로. HAL 에는 **등록·감시·통제용 등록부**(`mail_services` 도메인별 논리 리소스 + `servers` 노드)만 만든다. AriMail 재시작·DNS 변경·계정/DB 변형·리스너 교체 금지. + +```bash +# 1) 사전 사설 스냅샷(각 원본 호스트, root 0700/0600, git 밖) — /backups/pre-hal-onboard-/ : archive.tgz(데이터·설정·유닛·env·nginx·훅, DB 제외) · db.tgz(VACUUM INTO 사본 + integrity_check) · code.tgz(current 릴리스 트리) · meta.tgz · SHA256SUMS · RESTORE.md +# bear HAL 도 같은 모양: /srv/ariweb/backups/hal-pre-mail-onboard-/ (hal.sqlite VACUUM 사본·vault·config·유닛·nginx·current 트리) +# 2) 읽기 전용 discovery(원본 호스트, 파일 하나만 복사해 sudo 로 실행 — 데이터 디렉터리가 0700 이라서). 출력에 비밀값·메일 주소 없음 +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 --out discovery.json +# 3) bear(HAL 데이터 소유자 master): 서버(노드) 만들기 + dry-run → 거부 0 이면 --apply. 같은 문서를 다시 적용하면 update(멱등) +cd /srv/ariweb/hal/current && export ARIWEB_HAL_DATA=/srv/ariweb/hal/data +node hal/index.mjs mail-import --discovery discovery.json --create-server --name "srv1953374 (mail.arihakdang.com)" --location hostinger --by hera # dry-run, 서버 id 출력 +node hal/index.mjs mail-import --discovery discovery.json --server --apply --by hera +node hal/index.mjs mail-services # readback: counts.active = 도메인 수, duplicates [], secretLeaks 0 +# 4) (선택) Node Agent — Agent 가 없는 호스트만. 유닛 ariweb-agent(별도 프로세스, HAL 로의 outbound HMAC 만, 메일 포트·유닛 무관) +node hal/index.mjs server-token --server --by hera # 1회 표시 토큰 → 원본 호스트에서 install.sh (--role mail) +# 운영자 콘솔에서 서버 '승인' 은 사람 결정(첫 Agent 승인은 부트스트랩을 닫고 수동 설치를 잠근다 §32.2) +``` +되돌리기: `node hal/index.mjs mail-import-rollback --import `(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(3단계 확인). Agent 는 `systemctl disable --now ariweb-agent` + `/srv/ariweb/agent` 제거(메일 서비스 영향 없음). 원본 메일 데이터 복원은 각 백업의 RESTORE.md(운영자 승인 뒤에만 — 메일 중단 동반). +hostinger 주의: `arihakdang-mail` 은 hostinger HAL(`hal.ariweb.net`)의 `module_targets.arimail` 로도 관리된다. hal21 에 같은 호스트의 두 번째 Agent 를 설치하면 두 제어 평면이 같은 유닛에 배포할 수 있으므로(§34 "하나의 HAL") **설치하지 않고** 서버 행을 pending(Agent 미연결)으로 두었다 — 제어 평면 이전은 사람 결정. diff --git a/hal/app.mjs b/hal/app.mjs index 7465212..dcabd06 100644 --- a/hal/app.mjs +++ b/hal/app.mjs @@ -24,6 +24,7 @@ import * as core from './core.mjs'; import * as ops from './ops.mjs'; import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; +import * as mailsvc from './mailsvc.mjs'; import { MODULES, DEPLOY_ORDER } from './modules/index.mjs'; import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34) @@ -128,6 +129,12 @@ export function buildApp(d) { app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) }))); app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; })); app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(409, 'server_in_use', `이 서버에 ${hosted}개 서비스가 배치되어 있습니다 — 먼저 이전하세요`); if (s.agent_id) core.revokeAgent(d, s.agent_id, ctx.operator.id); d.store.run('DELETE FROM servers WHERE id=?', s.id); d.store.run('DELETE FROM module_targets WHERE server_id=?', s.id); listAudit; return { ok: true }; })); + // HAL 관리형 메일 서비스(legacy 가져오기, hal/mailsvc.mjs). 응답에 비밀 없음(참조만 저장). + app.get('/ops/api/mail-services', () => { const rb = mailsvc.readback(d); return { ok: true, services: rb.services, readback: { counts: rb.counts, duplicates: rb.duplicates, perServer: rb.perServer, secretLeaks: rb.secretLeaks, generatedAt: rb.generatedAt } }; }); + app.get('/ops/api/mail-services/imports', () => ({ ok: true, imports: mailsvc.listImports(d) })); + app.post('/ops/api/mail-services/import', wrap(async (ctx) => { const b = await ctx.body(); if (b.apply) requireRecent(ctx); const v = mailsvc.validateDiscovery(b.discovery); if (!v.ok) throw new HttpError(400, 'bad_discovery', 'discovery 문서 검증 실패', { errors: v.errors }); return { ok: true, result: mailsvc.applyImport(d, b.discovery, { serverId: b.serverId, by: ctx.operator.id, dryRun: !b.apply }) }; })); + app.post('/ops/api/mail-services/imports/:id/rollback', wrap((ctx) => { requireRecent(ctx); return { ok: true, result: mailsvc.rollbackImport(d, ctx.params.id, ctx.operator.id) }; })); + app.get('/ops/api/mail-services/:id', wrap((ctx) => { const s = mailsvc.getMailService(d, ctx.params.id); if (!s) throw new HttpError(404, 'mail_service_not_found', '메일 서비스가 없습니다'); return { ok: true, service: s, audit: listAudit(d.store, { subjectType: 'mail_service', subjectId: s.id, limit: 50 }) }; })); // 릴리스·모듈 배포(§32) app.get('/ops/api/releases', () => ({ ok: true, releases: core.listReleases(d), running: core.selfHealth(d).bootstrap, modules: Object.keys(MODULES), deployOrder: DEPLOY_ORDER, targets: d.store.all('SELECT * FROM module_targets ORDER BY module, server_id') })); app.post('/ops/api/releases/:version/approve', wrap((ctx) => { requireRecent(ctx); return { ok: true, release: core.approveRelease(d, ctx.params.version, ctx.operator.id) }; })); diff --git a/hal/core.mjs b/hal/core.mjs index 6bbbf96..68321b4 100644 --- a/hal/core.mjs +++ b/hal/core.mjs @@ -12,6 +12,7 @@ import { serviceFetch } from '../lib/serviceauth.mjs'; import { JobError } from '../lib/jobs.mjs'; import { existsSync, statSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; +import { syncFromHeartbeat } from './mailsvc.mjs'; export const EXTERNAL_KINDS = new Set(['site.deploy', 'site.remove', 'site.check', 'site.rollback']); // 외부 Agent 가 받을 수 있는 작업(§10.2) export const SERVER_ROLES = ['web', 'mail', 'backup', 'control']; @@ -148,6 +149,8 @@ export function heartbeat(d, agent, body) { d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at', name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now); } + // [legacy 메일 서비스] Agent 가 보고한 arimail 모듈 상태를 그 서버의 HAL 관리형 메일 서비스 행에 비춘다(행이 없으면 무관) + try { syncFromHeartbeat(d, agent.server_id, body.modules || {}); } catch (e) { d.log && d.log('[hal] mail_services sync 실패', e.message); } // 시계 차이·디스크 고갈 경고(§28.1) if (skew != null && Math.abs(skew) > 60000) notify(d.store, { category: 'ops', severity: 'warning', incidentKey: `server:${agent.server_id}:clock`, title: `서버 시계 차이 ${Math.round(skew / 1000)}초`, link: `/ops/servers/${agent.server_id}` }); else { try { const { resolveIncident } = d.notifyLib; resolveIncident(d.store, `server:${agent.server_id}:clock`); } catch (_) {} } diff --git a/hal/index.mjs b/hal/index.mjs index 7756d81..d2afd37 100644 --- a/hal/index.mjs +++ b/hal/index.mjs @@ -7,6 +7,11 @@ * node hal/index.mjs register-release --manifest /manifest.json> [--approve] 릴리스 등록(부트스트랩·CI) * node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회) * node hal/index.mjs bootstrap-release --version --commit --sha256 부트스트랩 때 선택한 릴리스 정보를 기록 + * node hal/index.mjs server-token --server [--by ] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록) + * node hal/index.mjs mail-import --discovery (--server | --create-server --name [--location ]) [--apply] [--by ] + * 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs) + * node hal/index.mjs mail-import-rollback --import [--by ] 적용된 가져오기 되돌리기 + * node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음) */ import { loadConfig } from './config.mjs'; import { createHalDeps, VERSION } from './deps.mjs'; @@ -15,6 +20,7 @@ import * as core from './core.mjs'; import * as ops from './ops.mjs'; import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; +import * as mailsvc from './mailsvc.mjs'; import { purgeSessions } from '../lib/session.mjs'; import { randomToken } from '../lib/crypto.mjs'; import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs'; @@ -42,6 +48,23 @@ if (cmd === 'setup-token') { console.log(`포털 보관소에 넣을 값(1회 표시, 공유 금지):\n service:hal = ${shared}\n (포털 서버에서: ARIWEB_PORTAL_DATA=<포털 데이터> node -e "import('./lib/vault.mjs').then(m=>m.openVault(process.env.ARIWEB_PORTAL_DATA).set('service:hal', process.argv[1]))" <값>)`); } else if (cmd === 'bootstrap-release') { core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다'); +} else if (cmd === 'server-token') { + // 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:). + const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server 가 필요합니다(없는 서버)'); process.exit(1); } + const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` }); + console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`); +} else if (cmd === 'mail-import') { + const file = opt('discovery'); if (!file) { console.error('--discovery 이 필요합니다'); process.exit(1); } + const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); } + let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`; + if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); } + if (!serverId) { console.error('--server 또는 --create-server 가 필요합니다'); process.exit(1); } + try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); } + catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); } +} else if (cmd === 'mail-import-rollback') { + try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); } +} else if (cmd === 'mail-services') { + console.log(JSON.stringify(mailsvc.readback(d), null, 1)); } else if (cmd === 'serve') { // "하나의 HAL" 임대(§34). 다른 인스턴스가 살아 있으면 기동 거부(--takeover 는 재해복구 절차에서만). try { core.claimLive(d, { instanceId: d.instanceId, version: VERSION, takeover: !!opt('takeover') }); } catch (e) { console.error(`[hal] 기동 거부: ${e.message}`); process.exit(2); } diff --git a/hal/mailsvc.mjs b/hal/mailsvc.mjs new file mode 100644 index 0000000..11f73f7 --- /dev/null +++ b/hal/mailsvc.mjs @@ -0,0 +1,197 @@ +/** + * hal/mailsvc.mjs — HAL 관리형 메일 서비스 등록부와 기존(legacy) AriMail 인스턴스 가져오기(importer). + * + * 왜 있나: 이미 운영 중인 AriMail(mail.arihakdang.com · webmail.ibit.kr 같은)을 교체·중단·재구성하지 않고, HAL 이 **등록·감시·통제**할 수 있는 + * 논리 리소스(MailService = 도메인 단위)와 노드(servers 행)로 가져온다. Data Plane(AriMail 의 송수신·계정·메일·DNS/MX·TLS·provider)은 그대로이며 + * HAL 이 멈춰도 메일 서비스는 계속 동작한다(HAL 은 제어 평면, 이 표는 등록부일 뿐이다). + * + * 원칙 + * - 읽기 전용 discovery: 원본 호스트에서 `scripts/mail-discover.mjs` 가 만든 문서(JSON)만 입력으로 받는다. 비밀값(토큰·키·비밀번호·해시)은 문서에 있으면 거부하고, + * 비밀은 `secretRefs`(file:/dir:/vault:/env: 참조)로만 둔다. HAL DB 에는 참조만 저장한다. + * - 불변 id(`mls_…`), 출처 권한(authority = `arimail::`), 도메인별 active owner 정확히 하나(부분 유일 인덱스). + * - dry-run 이 기본. 계획(plan)은 충돌(collision: 다른 서버/인스턴스가 이미 active)·중복·누락을 거부하며, 거부가 하나라도 있으면 아무것도 적용하지 않는다(전체 거부). + * - 적용은 한 트랜잭션, 모든 변화는 감사 기록(immutable), 적용 단위(mail_imports)로 되돌리기(생성 행 삭제·갱신 행 복원) 가능. + * - 같은 authority·서버의 재적용은 update(집계·증거 갱신, id 유지) — 멱등. + * - 소유 조직이 증거로 확인되지 않으면 임의 매핑하지 않고 `unassigned_legacy_managed` 로 둔다(AriMail 등록부 `ariweb.groupId` 가 있어도 포털 그룹과 대조하기 전에는 추정하지 않는다). + * - Agent heartbeat 가 arimail 모듈 상태를 보고하면 last_sync·health 가 갱신된다(core.heartbeat → syncFromHeartbeat). Agent 가 없는 서버의 행은 import 시각만 가진다. + */ +import { newId } from '../lib/crypto.mjs'; +import { nowIso, toJson, parseJson } from '../lib/db.mjs'; +import { audit } from '../lib/audit.mjs'; +import { looksSecret } from '../lib/redact.mjs'; +import { JobError } from '../lib/jobs.mjs'; +import * as core from './core.mjs'; + +export const DISCOVERY_FORMAT = 1; +export const MAIL_SERVER_ROLES = ['mail', 'control']; +const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/; +const SECRET_KEY_RE = /password|passwd|secret|token|api[-_]?key|private[-_]?key|privkey|credential|cookie|authorization|recovery|otp|totp|^(pass|key)$/i; +const REF_KEY_RE = /(ref|refs|path|file|files|dir|keys|names|at|mode|owner|hint|exists|count|url)$/i; +const REF_RE = /^(file|dir|vault|env):\S+$/; +/** git 커밋·artifact 해시처럼 "비밀처럼 보이지만 공개 식별자" 인 값: 키 이름이 commit/sha256 류이고 값이 40/64 hex 일 때만 허용 */ +const isPublicDigest = (k, v) => /^(commit|main_?commit|sha256|artifact_?sha256|digest)$/i.test(String(k)) && /^([a-f0-9]{40}|[a-f0-9]{64})$/i.test(String(v)); + +export const MIGRATIONS = [{ id: 'hal_0003_mail_services', sql: ` +-- HAL 관리형 메일 서비스(도메인 단위 논리 리소스). 비밀은 secret_refs_json 의 참조만. 도메인별 active 는 정확히 하나. +CREATE TABLE IF NOT EXISTS mail_services ( + id TEXT PRIMARY KEY, domain TEXT NOT NULL, server_id TEXT NOT NULL, authority TEXT NOT NULL, kind TEXT NOT NULL DEFAULT 'legacy_managed', + ownership TEXT NOT NULL DEFAULT 'unassigned_legacy_managed', owner_group_id TEXT, active INTEGER NOT NULL DEFAULT 1, status TEXT NOT NULL DEFAULT 'managed', + public_host TEXT, web_url TEXT, instance_json TEXT NOT NULL, domain_json TEXT, evidence_json TEXT, aggregates_json TEXT, secret_refs_json TEXT, health TEXT, + import_id TEXT, imported_at TEXT NOT NULL, imported_by TEXT, last_sync_at TEXT, last_sync_source TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS mail_services_active_domain ON mail_services(domain) WHERE active=1; +CREATE INDEX IF NOT EXISTS mail_services_server ON mail_services(server_id); +-- 가져오기 단위(dry-run 도 기록). status: planned(dry-run) | rejected | applied | rolled_back +CREATE TABLE IF NOT EXISTS mail_imports ( + id TEXT PRIMARY KEY, mode TEXT NOT NULL, status TEXT NOT NULL, actor TEXT, server_id TEXT, authority TEXT NOT NULL, plan_json TEXT NOT NULL, + created_ids_json TEXT, updated_json TEXT, created_at TEXT NOT NULL, applied_at TEXT, rolled_back_at TEXT, rolled_back_by TEXT +);` }]; + +// ------------------------------------------------------------------ 검증 +/** discovery 문서 검증. 비밀값이 어디에든 있으면 거부한다. */ +export function validateDiscovery(doc) { + const errors = []; + if (!doc || typeof doc !== 'object') return { ok: false, errors: ['discovery 문서가 객체가 아닙니다'] }; + if (doc.format !== DISCOVERY_FORMAT) errors.push(`format 은 ${DISCOVERY_FORMAT} 이어야 합니다`); + if (!doc.collectedAt || isNaN(Date.parse(doc.collectedAt))) errors.push('collectedAt(ISO 시각)이 필요합니다'); + const s = doc.source; + if (!s || typeof s !== 'object') errors.push('source 가 필요합니다'); + else { for (const k of ['hostname', 'unit', 'appDir', 'user']) if (!s[k] || typeof s[k] !== 'string') errors.push(`source.${k} 가 필요합니다`); if (!s.revision || typeof s.revision !== 'object') errors.push('source.revision 이 필요합니다'); if (!s.health || typeof s.health !== 'object') errors.push('source.health 가 필요합니다'); } + if (!Array.isArray(doc.domains) || !doc.domains.length) errors.push('domains 배열이 필요합니다'); + else { + const seen = new Set(); + doc.domains.forEach((x, i) => { + if (!x || typeof x !== 'object') { errors.push(`domains[${i}] 가 객체가 아닙니다`); return; } + const dn = String(x.domain || '').toLowerCase(); if (!DOMAIN_RE.test(dn)) errors.push(`domains[${i}] 도메인 형식 오류: ${x.domain}`); + if (seen.has(dn)) errors.push(`도메인 중복: ${dn}`); seen.add(dn); + if (!Array.isArray(x.hosts)) errors.push(`domains[${i}].hosts 배열이 필요합니다`); + }); + } + if (!Array.isArray(doc.listeners)) errors.push('listeners 배열이 필요합니다'); + if (!doc.aggregates || typeof doc.aggregates !== 'object') errors.push('aggregates 가 필요합니다'); + if (doc.secretRefs != null) { + if (!Array.isArray(doc.secretRefs)) errors.push('secretRefs 는 배열이어야 합니다'); + else doc.secretRefs.forEach((r, i) => { if (!r || typeof r !== 'object') { errors.push(`secretRefs[${i}] 형식 오류`); return; } if ('value' in r || 'secret' in r) errors.push(`secretRefs[${i}] 에 비밀값을 넣을 수 없습니다 — 참조만`); if (!REF_RE.test(String(r.ref || ''))) errors.push(`secretRefs[${i}].ref 는 file:/dir:/vault:/env: 형식이어야 합니다`); }); + } + // 비밀값 탐지(이름·형태). 참조 키(…Ref/Path/File/Dir/Keys)는 허용. + const walk = (v, path, depth) => { + if (depth > 8 || v == null) return; + if (typeof v === 'string') { if (looksSecret(v)) errors.push(`비밀 형태의 값: ${path}`); return; } + if (Array.isArray(v)) { v.forEach((x, i) => walk(x, `${path}[${i}]`, depth + 1)); return; } + if (typeof v === 'object') for (const [k, val] of Object.entries(v)) { if (isPublicDigest(k, val)) continue; if (SECRET_KEY_RE.test(k) && !REF_KEY_RE.test(k) && typeof val === 'string' && val !== '') errors.push(`비밀 이름의 키에 값이 있습니다: ${path}.${k}`); walk(val, `${path}.${k}`, depth + 1); } + }; + walk(doc, 'doc', 0); + return { ok: errors.length === 0, errors }; +} +export const authorityOf = (doc) => `arimail:${doc.source.hostname}:${doc.source.unit}`; + +// ------------------------------------------------------------------ 조회 +function rowToPublic(d, r) { + if (!r) return null; + const srv = d.store.get('SELECT name, agent_id, status, last_seen_at FROM servers WHERE id=?', r.server_id); + return { id: r.id, domain: r.domain, serverId: r.server_id, serverName: srv?.name || null, agentId: srv?.agent_id || null, serverStatus: srv?.status || null, authority: r.authority, kind: r.kind, ownership: r.ownership, ownerGroupId: r.owner_group_id, active: !!r.active, status: r.status, + publicHost: r.public_host, webUrl: r.web_url, instance: parseJson(r.instance_json, {}), domainInfo: parseJson(r.domain_json, {}), evidence: parseJson(r.evidence_json, {}), aggregates: parseJson(r.aggregates_json, {}), secretRefs: parseJson(r.secret_refs_json, []), health: r.health, + importId: r.import_id, importedAt: r.imported_at, importedBy: r.imported_by, lastSyncAt: r.last_sync_at, lastSyncSource: r.last_sync_source, createdAt: r.created_at, updatedAt: r.updated_at }; +} +export function listMailServices(d, { serverId = null } = {}) { return d.store.all(`SELECT * FROM mail_services ${serverId ? 'WHERE server_id=?' : ''} ORDER BY domain, created_at`, ...(serverId ? [serverId] : [])).map((r) => rowToPublic(d, r)); } +export function getMailService(d, id) { return rowToPublic(d, d.store.get('SELECT * FROM mail_services WHERE id=?', id)); } +export function listImports(d, { limit = 50 } = {}) { return d.store.all('SELECT id, mode, status, actor, server_id, authority, created_at, applied_at, rolled_back_at, rolled_back_by, plan_json, created_ids_json FROM mail_imports ORDER BY created_at DESC LIMIT ?', limit).map((r) => ({ id: r.id, mode: r.mode, status: r.status, actor: r.actor, serverId: r.server_id, authority: r.authority, createdAt: r.created_at, appliedAt: r.applied_at, rolledBackAt: r.rolled_back_at, rolledBackBy: r.rolled_back_by, summary: parseJson(r.plan_json, {}).summary || null, createdIds: parseJson(r.created_ids_json, []) })); } +/** 적용 뒤 정확성 readback: 총수·active·도메인별 중복·서버별·비밀 누출(형태 검사). */ +export function readback(d) { + const rows = d.store.all('SELECT * FROM mail_services ORDER BY domain'); const services = rows.map((r) => rowToPublic(d, r)); + const byDomain = {}; for (const r of rows) if (r.active) byDomain[r.domain] = (byDomain[r.domain] || 0) + 1; + const duplicates = Object.entries(byDomain).filter(([, n]) => n > 1).map(([dn]) => dn); + const perServer = {}; for (const r of rows) perServer[r.server_id] = (perServer[r.server_id] || 0) + 1; + let secretLeaks = 0; const scan = (v, depth = 0) => { if (depth > 8 || v == null) return; if (typeof v === 'string') { if (looksSecret(v)) secretLeaks++; return; } if (Array.isArray(v)) v.forEach((x) => scan(x, depth + 1)); else if (typeof v === 'object') Object.entries(v).forEach(([k, x]) => { if (!isPublicDigest(k, x)) scan(x, depth + 1); }); }; + for (const r of rows) scan([parseJson(r.instance_json, null), parseJson(r.evidence_json, null), parseJson(r.aggregates_json, null), parseJson(r.secret_refs_json, null), parseJson(r.domain_json, null)]); + return { counts: { total: rows.length, active: rows.filter((r) => r.active).length, domains: Object.keys(byDomain).length, byDomain }, duplicates, perServer, secretLeaks, services, generatedAt: nowIso() }; +} + +// ------------------------------------------------------------------ 계획(dry-run) +/** 계획 세우기. 거부(reject)가 하나라도 있으면 plan.ok=false. 서버 없음·역할·authority 불일치는 예외로 던진다(문서 전체가 대상이 아님). */ +export function planImport(d, doc, { serverId }) { + const v = validateDiscovery(doc); if (!v.ok) throw new JobError('bad_discovery', `discovery 문서 검증 실패: ${v.errors.join(' · ')}`, { errors: v.errors }); + const srv = core.getServer(d, serverId); if (!srv) throw new JobError('server_not_found', '서버가 없습니다'); + if (!MAIL_SERVER_ROLES.includes(srv.role)) throw new JobError('bad_role', `메일 서비스를 둘 서버 역할은 ${MAIL_SERVER_ROLES.join('/')} 여야 합니다(현재 ${srv.role})`); + const facts = srv.facts || {}; if (facts.hostname && facts.hostname !== doc.source.hostname) throw new JobError('authority_mismatch', `서버 facts 의 hostname(${facts.hostname})과 discovery 의 hostname(${doc.source.hostname})이 다릅니다`); + if (facts.units && facts.units.arimail && facts.units.arimail !== doc.source.unit.replace(/\.service$/, '')) throw new JobError('authority_mismatch', `서버 facts 의 arimail 유닛(${facts.units.arimail})과 discovery 의 유닛(${doc.source.unit})이 다릅니다`); + const authority = authorityOf(doc); const items = []; + for (const x of doc.domains) { + const domain = String(x.domain).toLowerCase(); + if (x.enabled === false) { items.push({ domain, action: 'skip', reason: 'disabled_in_source' }); continue; } + const ex = d.store.get('SELECT * FROM mail_services WHERE domain=? AND active=1', domain); + if (!ex) items.push({ domain, action: 'create' }); + else if (ex.authority === authority && ex.server_id === serverId) items.push({ domain, action: 'update', existingId: ex.id }); + else items.push({ domain, action: 'reject', reason: `collision: 도메인 ${domain} 의 active owner 는 이미 ${ex.id}(${ex.authority}, 서버 ${ex.server_id}) 입니다`, existingId: ex.id }); + } + const present = new Set(doc.domains.map((x) => String(x.domain).toLowerCase())); + const missingInSource = d.store.all('SELECT domain FROM mail_services WHERE authority=? AND server_id=? AND active=1', authority, serverId).map((r) => r.domain).filter((dn) => !present.has(dn)); + const summary = { create: 0, update: 0, reject: 0, skip: 0, missingInSource: missingInSource.length }; for (const i of items) summary[i.action]++; + return { ok: summary.reject === 0, authority, serverId, serverName: srv.name, items, missingInSource, summary, collectedAt: doc.collectedAt }; +} + +const domainView = (x) => ({ domain: String(x.domain).toLowerCase(), enabled: x.enabled !== false, primary: !!x.primary, hosts: (x.hosts || []).map(String), authMode: x.authMode || null, operating: x.operating || null, ariweb: x.ariweb || null, note: x.note || null, tenantDb: x.tenantDb || null, createdAt: x.createdAt || null, updatedAt: x.updatedAt || null }); +function buildRow(doc, x, serverId, authority) { + const dv = domainView(x); const host = dv.hosts[0] ? String(dv.hosts[0]).split('/')[0] : null; const agg = (doc.aggregates.byDomain || {})[dv.domain] || null; + const s = doc.source; + const instance = { hostname: s.hostname, unit: s.unit, appDir: s.appDir, currentTarget: s.currentTarget || null, user: s.user, nodeBin: s.nodeBin || null, nodeVersion: s.nodeVersion || null, revision: s.revision || {}, packageVersion: s.packageVersion || null, healthUrl: s.healthUrl || null, health: s.health || null, unitState: s.unitState || null, harakaUnit: s.harakaUnit || null, dataDir: s.dataDir || null, listeners: doc.listeners, provider: doc.provider || null, collector: doc.collector || null, collectedAt: doc.collectedAt }; + return { domain: dv.domain, server_id: serverId, authority, public_host: host, web_url: host ? `https://${host}/` : null, instance_json: toJson(instance), domain_json: toJson(dv), evidence_json: toJson(doc.evidence || {}), aggregates_json: toJson(agg), secret_refs_json: toJson(doc.secretRefs || []), health: s.health && s.health.ok === false ? 'down' : s.unitState && s.unitState.active && s.unitState.active !== 'active' ? 'down' : 'ok' }; +} + +// ------------------------------------------------------------------ 적용 +/** + * dry-run(기본) 또는 적용. 결과 { importId, mode: 'dry_run'|'rejected'|'apply', plan, created[], updated[] }. + * 거부가 있으면 아무것도 적용하지 않고 mode='rejected' 로 기록만 남긴다. + */ +export function applyImport(d, doc, { serverId, by = null, dryRun = true }) { + const plan = planImport(d, doc, { serverId }); const authority = plan.authority; const importId = newId('mimp_'); const now = nowIso(); + const actorType = by ? 'operator' : 'system'; + if (dryRun || !plan.ok) { + const mode = plan.ok ? 'dry_run' : 'rejected'; + d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_at) VALUES(?,?,?,?,?,?,?,?)', importId, mode, plan.ok ? 'planned' : 'rejected', by, serverId, authority, toJson(plan), now); + audit(d.store, { actorType, actorId: by, action: plan.ok ? 'mail_service.import_dry_run' : 'mail_service.import_rejected', subjectType: 'server', subjectId: serverId, detail: { importId, authority, summary: plan.summary, rejected: plan.items.filter((i) => i.action === 'reject').map((i) => i.domain) } }); + return { importId, mode, plan, created: [], updated: [] }; + } + return d.store.tx(() => { + const created = []; const updated = []; + for (const item of plan.items) { + if (item.action === 'skip') continue; + const x = doc.domains.find((y) => String(y.domain).toLowerCase() === item.domain); const row = buildRow(doc, x, serverId, authority); + if (item.action === 'create') { + const id = newId('mls_'); + d.store.run('INSERT INTO mail_services(id, domain, server_id, authority, kind, ownership, owner_group_id, active, status, public_host, web_url, instance_json, domain_json, evidence_json, aggregates_json, secret_refs_json, health, import_id, imported_at, imported_by, last_sync_at, last_sync_source, created_at, updated_at) VALUES(?,?,?,?,\'legacy_managed\',\'unassigned_legacy_managed\',NULL,1,\'managed\',?,?,?,?,?,?,?,?,?,?,?,?,\'import\',?,?)', + id, row.domain, row.server_id, row.authority, row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, importId, now, by, now, now, now); + created.push(id); item.createdId = id; + audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: id, immutable: true, detail: { importId, domain: row.domain, serverId, authority, publicHost: row.public_host, aggregates: parseJson(row.aggregates_json, null) } }); + } else if (item.action === 'update') { + const before = d.store.get('SELECT * FROM mail_services WHERE id=?', item.existingId); + d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=\'import\', updated_at=? WHERE id=?', row.public_host, row.web_url, row.instance_json, row.domain_json, row.evidence_json, row.aggregates_json, row.secret_refs_json, row.health, now, now, item.existingId); + updated.push({ id: item.existingId, before }); + audit(d.store, { actorType, actorId: by, action: 'mail_service.import', subjectType: 'mail_service', subjectId: item.existingId, immutable: true, detail: { importId, domain: row.domain, serverId, authority, update: true } }); + } + } + d.store.run('INSERT INTO mail_imports(id, mode, status, actor, server_id, authority, plan_json, created_ids_json, updated_json, created_at, applied_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)', importId, 'apply', 'applied', by, serverId, authority, toJson(plan), toJson(created), toJson(updated), now, now); + return { importId, mode: 'apply', plan, created, updated: updated.map((u) => u.id) }; + }); +} +/** 되돌리기: 그 적용이 만든 행 삭제, 갱신한 행은 이전 값으로 복원. */ +export function rollbackImport(d, importId, by = null) { + const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', importId); if (!imp) throw new JobError('import_not_found', '가져오기 기록이 없습니다'); + if (imp.status === 'rolled_back') throw new JobError('already_rolled_back', '이미 되돌린 가져오기입니다'); + if (imp.status !== 'applied') throw new JobError('not_applied', `적용된 가져오기만 되돌릴 수 있습니다(상태 ${imp.status})`); + return d.store.tx(() => { + let deleted = 0, restored = 0; + for (const id of parseJson(imp.created_ids_json, [])) deleted += d.store.run('DELETE FROM mail_services WHERE id=? AND import_id=?', id, importId).changes; + for (const u of parseJson(imp.updated_json, [])) { const b = u.before; if (!b) continue; restored += d.store.run('UPDATE mail_services SET public_host=?, web_url=?, instance_json=?, domain_json=?, evidence_json=?, aggregates_json=?, secret_refs_json=?, health=?, last_sync_at=?, last_sync_source=?, updated_at=? WHERE id=?', b.public_host, b.web_url, b.instance_json, b.domain_json, b.evidence_json, b.aggregates_json, b.secret_refs_json, b.health, b.last_sync_at, b.last_sync_source, nowIso(), u.id).changes; } + d.store.run('UPDATE mail_imports SET status=\'rolled_back\', rolled_back_at=?, rolled_back_by=? WHERE id=?', nowIso(), by, importId); + audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'mail_service.import_rollback', subjectType: 'server', subjectId: imp.server_id, immutable: true, detail: { importId, deleted, restored } }); + return { importId, deleted, restored }; + }); +} +/** heartbeat 연동(core.heartbeat 가 부른다): 그 서버의 메일 서비스 행에 Agent 관측 상태를 비춘다. 행이 없으면 아무것도 하지 않는다. */ +export function syncFromHeartbeat(d, serverId, modules = {}) { + const m = modules && modules.arimail; if (!m) return 0; + const rows = d.store.all('SELECT id, instance_json FROM mail_services WHERE server_id=?', serverId); if (!rows.length) return 0; const now = nowIso(); + for (const r of rows) { const inst = parseJson(r.instance_json, {}); inst.observedVersion = m.version || null; inst.observedActive = !!m.active; inst.observedAt = now; d.store.run('UPDATE mail_services SET health=?, last_sync_at=?, last_sync_source=\'agent\', instance_json=?, updated_at=? WHERE id=?', m.health || 'unknown', now, toJson(inst), now, r.id); } + return rows.length; +} diff --git a/hal/schema.mjs b/hal/schema.mjs index 4744901..4d4aee1 100644 --- a/hal/schema.mjs +++ b/hal/schema.mjs @@ -6,6 +6,7 @@ import * as session from '../lib/session.mjs'; import * as jobs from '../lib/jobs.mjs'; import * as notify from '../lib/notify.mjs'; import * as audit from '../lib/audit.mjs'; +import { MIGRATIONS as MAIL_MIGRATIONS } from './mailsvc.mjs'; export const MIGRATIONS = [...session.MIGRATIONS, ...jobs.MIGRATIONS, ...notify.MIGRATIONS, ...audit.MIGRATIONS, { id: 'hal_0001', sql: ` -- 관리 서버(§28). agent_id 는 그 서버에서 돌아가는 Node Agent. target/actual 모듈 버전은 module_targets 가 가진다. @@ -85,4 +86,6 @@ CREATE TABLE IF NOT EXISTS portal_events_outbox (id TEXT PRIMARY KEY, payload_js ` }, // [통합 보완] 같은 호스트 재시작 판정을 위한 임대 소유자 호스트·PID { id: 'hal_0002_live_host', sql: `ALTER TABLE bootstrap_state ADD COLUMN live_host TEXT; ALTER TABLE bootstrap_state ADD COLUMN live_pid TEXT;` }, + // [legacy 메일 서비스 가져오기] HAL 관리형 메일 서비스 등록부·가져오기 단위(hal/mailsvc.mjs). 표 추가만(기존 표 변경 없음). + ...MAIL_MIGRATIONS, ]; diff --git a/hal/ui.mjs b/hal/ui.mjs index a6efe80..7dfecc2 100644 --- a/hal/ui.mjs +++ b/hal/ui.mjs @@ -22,6 +22,7 @@ import * as core from './core.mjs'; import * as ops from './ops.mjs'; import * as emergency from './emergency.mjs'; import * as dr from './dr.mjs'; +import * as mailsvc from './mailsvc.mjs'; import { MODULES, DEPLOY_ORDER } from './modules/index.mjs'; import * as R from './ui/render.mjs'; @@ -64,7 +65,9 @@ export function registerUi(app, d) { relayPage('/ops/customers', 'customers', '고객과 사용자 그룹', 'customers'); relayPage('/ops/customers/:groupId', 'customers', '고객 상세', 'customer'); relayPage('/ops/orders', 'orders', '주문과 결제 승인', 'orders'); relayPage('/ops/products', 'products', '상품과 정책', 'products'); relayPage('/ops/sites', 'sites', '사이트와 배포', 'sites'); relayPage('/ops/sites/:id', 'sites', '사이트 상세', 'site'); - relayPage('/ops/mail', 'mail', '메일 서비스', 'mail'); relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains'); + // 메일 서비스: HAL 관리형(legacy 가져오기, HAL 자체 데이터)을 먼저 서버에서 그리고, 포털 메일 서비스는 브라우저가 중계로 채운다(ops.js 는 #portal-mail 만 바꾼다). + app.get('/ops/mail', (ctx) => shell(ctx, { title: '메일 서비스', nav: 'mail', page: 'mail', body: `

HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)

${mailServicesView(mailsvc.readback(d))}

포털 메일 서비스

${R.statePanel('loading', '포털에서 불러오는 중')}
` })); + relayPage('/ops/domains', 'domains', '도메인과 인증서 · Source', 'domains'); relayPage('/ops/backup', 'backup', '백업과 복원', 'backup'); relayPage('/ops/relocation', 'relocation', '서버 이전 · 소유권 이전', 'relocation'); // ---------------- 서버(§28) app.get('/ops/servers', (ctx) => shell(ctx, { title: '서버와 용량', nav: 'servers', page: 'servers', body: `
${R.serversTable(core.listServers(d))}

등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).

` })); @@ -109,3 +112,19 @@ export function overviewLocal(d) { } /** VERSION_HISTORY.md 의 `## <버전>` 절을 버전별 문자열로 */ export function changelog() { const f = join(ROOT, 'VERSION_HISTORY.md'); if (!existsSync(f)) return {}; const out = {}; let cur = null; for (const line of readFileSync(f, 'utf8').split('\n')) { const m = line.match(/^## ([0-9][^\s(]*)/); if (m) { cur = m[1]; out[cur] = ''; continue; } if (cur) out[cur] += line + '\n'; } for (const k of Object.keys(out)) out[k] = out[k].trim().slice(0, 4000); return out; } + +/** HAL 관리형 메일 서비스 표(비밀 없음). 도메인별 active owner 는 하나여야 하며 중복·비밀 누출은 readback 이 드러낸다. */ +function mailServicesView(rb) { + const warn = []; if (rb.duplicates.length) warn.push(`
⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}
`); if (rb.secretLeaks) warn.push(`
⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인
`); + const t = R.table([ + { l: '도메인', f: (s) => `${esc(s.domain)}
${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}` }, + { l: '공개 주소', f: (s) => s.webUrl ? `${esc(s.publicHost)}` : '—' }, + { l: '서버·인스턴스', f: (s) => `${esc(s.serverName || s.serverId)}
${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}` }, + { l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} (AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})${s.instance.observedVersion ? `
Agent 관측 ${esc(s.instance.observedVersion)}` : ''}` }, + { l: '계정·메일함·메일', f: (s) => s.aggregates ? `${esc(String(s.aggregates.accounts ?? '—'))} · ${esc(String(s.aggregates.mailboxes ?? '—'))} · ${esc(String(s.aggregates.messages ?? '—'))}` : '—' }, + { l: '건강·동기화', f: (s) => `${R.healthBadge(s.health || 'unknown')}
${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}` }, + { l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}
${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}` }, + { l: '비밀', f: (s) => `참조 ${(s.secretRefs || []).length}건(값 없음)` }, + ], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' }); + return warn.join('') + t + `

총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).

`; +} diff --git a/hal/ui/public/ops.js b/hal/ui/public/ops.js index 5d542b9..f100327 100644 --- a/hal/ui/public/ops.js +++ b/hal/ui/public/ops.js @@ -54,7 +54,7 @@ const PAGES = { products: async () => { try { body().innerHTML = R.productsView(await relay('/commerce/products')); } catch (e) { body().innerHTML = R.relayError(e); } }, sites: async () => { try { body().innerHTML = R.sitesView(await relay('/sites')); } catch (e) { body().innerHTML = R.relayError(e); } }, site: async (id) => { try { body().innerHTML = R.siteDetailView(await relay('/sites/' + id)); } catch (e) { body().innerHTML = R.relayError(e); } }, - mail: async () => { try { body().innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { body().innerHTML = R.relayError(e); } }, + mail: async () => { const el = document.getElementById('portal-mail') || body(); try { el.innerHTML = R.mailView(await relay('/mail/overview')); } catch (e) { el.innerHTML = R.relayError(e); } }, domains: async () => { const out = []; let src = null; try { const dom = await relay('/domains'); try { src = await relay('/source'); } catch (e) { out.push(R.statePanel('partial', 'Source 목록을 받지 못했습니다')); } body().innerHTML = out.join('') + R.domainsView(dom, src); } catch (e) { body().innerHTML = R.relayError(e); } }, backup: async () => { try { const [r, o, b] = await Promise.all([relay('/backup/restores'), relay('/backup/options'), relay('/backup/backups')]); body().innerHTML = R.backupView({ restores: r.restores, options: o.options, backups: b.backups }); } catch (e) { body().innerHTML = R.relayError(e); } }, relocation: async () => { let tr = null; try { const rl = await relay('/relocation'); try { tr = await relay('/transfer'); } catch (_) { tr = null; } body().innerHTML = (tr ? '' : R.statePanel('partial', '소유권 이전 목록을 받지 못했습니다')) + R.relocationView(rl, tr); } catch (e) { body().innerHTML = R.relayError(e); } }, diff --git a/scripts/mail-discover.mjs b/scripts/mail-discover.mjs new file mode 100644 index 0000000..892e581 --- /dev/null +++ b/scripts/mail-discover.mjs @@ -0,0 +1,127 @@ +/** + * 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 ] + * + * 무엇을 읽나(모두 읽기 전용, 원본 파일은 바꾸지 않음): 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); +} diff --git a/test/hal-mailimport.test.mjs b/test/hal-mailimport.test.mjs new file mode 100644 index 0000000..5d95af1 --- /dev/null +++ b/test/hal-mailimport.test.mjs @@ -0,0 +1,147 @@ +/** test/hal-mailimport.test.mjs — HAL 기존(legacy) 메일 서비스 가져오기(hal/mailsvc.mjs) 회귀 테스트. + * 읽기 전용 discovery 문서 → 검증(비밀값 차단) → dry-run 계획 → 트랜잭션 적용 → 감사 → readback → 되돌리기. 도메인별 active owner 는 정확히 하나. */ +import { test, eq, ok, rejects, tmpDir, done } from '../lib/test.mjs'; +import { invoke } from '../lib/http.mjs'; +import { signHeaders } from '../lib/serviceauth.mjs'; +import { createHalDeps } from '../hal/deps.mjs'; +import { buildApp } from '../hal/app.mjs'; +import * as core from '../hal/core.mjs'; +import * as ms from '../hal/mailsvc.mjs'; +import { listAudit } from '../lib/audit.mjs'; +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + +function makeHal() { + const dataDir = join(tmpDir(), 'hal'); mkdirSync(dataDir, { recursive: true }); + const config = { dataDir, file: join(dataDir, 'config.json'), port: 0, host: '127.0.0.1', publicUrl: 'https://hal.example.test', portalUrl: 'http://127.0.0.1:1', opsAllowCidrs: ['127.0.0.1/32'], metricsRetentionDays: 90, offlineAfterMs: 90000, transferTtlMs: 3600000, emergencyTtlMinutes: 30, values: {}, set(p) { Object.assign(this, p); } }; + const d = createHalDeps({ config, dbFile: ':memory:' }); d.jobsRunning = true; return { d, app: buildApp(d) }; +} +/** 실제 환경(mail.arihakdang.com)과 같은 모양의 discovery 문서. 비밀값 없음, 참조만. */ +function discovery(over = {}) { + return { + format: 1, collectedAt: '2026-10-27T05:00:00.000Z', collector: 'scripts/mail-discover.mjs', + source: { hostname: 'srv1953374', unit: 'arihakdang-mail.service', appDir: '/srv/arihakdang-mail', currentTarget: '/srv/arihakdang-mail/releases/0.1.1', user: 'arihakdang', nodeBin: '/opt/arihakdang-node/bin/node', + unitState: { active: 'active', enabled: 'enabled', nRestarts: 0, mainPid: 1046648, fragmentPath: '/etc/systemd/system/arihakdang-mail.service', environmentFiles: ['/etc/arihakdang-mail/gateway.env'] }, + revision: { module: 'arimail', version: '0.1.1', upstreamVersion: '1.0.11', installedAt: '2026-10-26T14:09:29.902Z' }, packageVersion: '1.0.11', healthUrl: 'http://127.0.0.1:8790/api/health', health: { ok: true, version: '1.0.11', domains: 2 }, dataDir: '/srv/arihakdang-mail/data' }, + domains: [ + { domain: 'arihakdang.com', enabled: true, primary: true, hosts: ['mail.arihakdang.com'], authMode: 'www', operating: 'active', ariweb: null }, + { domain: 'ariweb.net', enabled: true, primary: false, hosts: ['webmail.ariweb.net'], authMode: 'webmaker', operating: 'active', ariweb: null }, + ], + listeners: [{ port: 8790, bind: '127.0.0.1', owner: 'unit' }, { port: 587, bind: '0.0.0.0', owner: 'unit' }, { port: 993, bind: '0.0.0.0', owner: 'unit' }], + aggregates: { accounts: 4, mailboxes: 4, messages: 195, storageBytes: 545259520, byDomain: { 'arihakdang.com': { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }, 'ariweb.net': { accounts: 2, mailboxes: 2, messages: 6, dbBytes: 372736 } } }, + provider: { kind: 'resend', haraka: { enabled: false, running: false }, configPath: '/srv/arihakdang-mail/data/config.json', configKeys: ['RESEND_API_KEY', 'inbound', 'outbound'] }, + secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/arihakdang-mail/gateway.env', mode: '600', owner: 'arihakdang' }, { name: 'config.json', ref: 'file:/srv/arihakdang-mail/data/config.json', mode: '600' }, { name: 'tls', ref: 'dir:/etc/arihakdang-mail/tls' }], + evidence: { nginx: { vhostFile: '/etc/nginx/sites-available/mail.arihakdang.conf', serverNames: ['mail.arihakdang.com'], proxyPass: 'http://127.0.0.1:8790' }, tls: { host: 'mail.arihakdang.com', subject: 'CN=mail.arihakdang.com', validTo: '2027-01-12T08:26:09.000Z' }, dns: { 'mail.arihakdang.com': { a: ['187.53.133.173'] }, 'arihakdang.com': { mx: ['inbound-smtp.ap-northeast-1.amazonaws.com'] } } }, + ...over, + }; +} +const sig = (keyId, secret, method, path, body) => Object.fromEntries(Object.entries(signHeaders(keyId, secret, method, path, body === undefined ? undefined : JSON.stringify(body))).map(([k, v]) => [k.toLowerCase(), v])); + +await test('discovery 검증: 형식·필수 항목·도메인 형식·중복 도메인·비밀값 포함 거부, 참조(secretRefs)만 허용', () => { + eq(ms.validateDiscovery(discovery()).ok, true); + ok(ms.validateDiscovery({ format: 2 }).errors.some((e) => /format/.test(e)), '형식 버전'); + ok(ms.validateDiscovery(discovery({ source: null })).errors.some((e) => /source/.test(e)), 'source 필수'); + ok(ms.validateDiscovery(discovery({ domains: [{ domain: 'Bad Domain', enabled: true, hosts: [] }] })).errors.some((e) => /도메인 형식/.test(e)), '도메인 형식'); + ok(ms.validateDiscovery(discovery({ domains: [...discovery().domains, discovery().domains[0]] })).errors.some((e) => /중복/.test(e)), '문서 안 중복 도메인'); + ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'gateway.env', ref: 'file:/etc/x', value: 'abc' }] })).errors.some((e) => /비밀/.test(e)), 'secretRefs 에 값 금지'); + ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', apiKey: 're_abcdefghijklmnopqrstuvwxyz0123456789' } })).errors.some((e) => /비밀/.test(e)), '비밀 형태 값 거부'); + ok(ms.validateDiscovery(discovery({ provider: { kind: 'resend', password: 'hunter2hunter2' } })).errors.some((e) => /비밀/.test(e)), '비밀 이름 키의 값 거부'); + ok(ms.validateDiscovery(discovery({ secretRefs: [{ name: 'x', ref: 'plain-value' }] })).errors.some((e) => /ref/.test(e)), 'ref 는 file:/dir:/vault:/env: 형식'); +}); + +await test('dry-run 계획: 서버 없음·역할·호스트 불일치 거부, 도메인별 create, 비활성 도메인 skip, DB 변경 없음, 계획은 mail_imports 에 기록', async () => { + const { d } = makeHal(); const doc = discovery(); + await rejects(() => ms.applyImport(d, doc, { serverId: 'srv_none', by: 'test', dryRun: true }), 'server_not_found'); + const web = core.createServer(d, { name: 'web', role: 'web' }); + await rejects(() => ms.applyImport(d, doc, { serverId: web.id, by: 'test', dryRun: true }), 'bad_role'); + const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail', location: 'hostinger' }); + d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'other-host' }), srv.id); + await rejects(() => ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: true }), 'authority_mismatch'); + d.store.run('UPDATE servers SET facts_json=NULL WHERE id=?', srv.id); + const r = ms.applyImport(d, { ...doc, domains: [...doc.domains, { domain: 'off.example', enabled: false, hosts: ['mail.off.example'] }] }, { serverId: srv.id, by: 'test', dryRun: true }); + eq(r.mode, 'dry_run'); eq(r.plan.ok, true); eq(r.plan.summary, { create: 2, update: 0, reject: 0, skip: 1, missingInSource: 0 }); + eq(r.plan.items.find((i) => i.domain === 'off.example').action, 'skip'); + eq(ms.listMailServices(d).length, 0, 'dry-run 은 mail_services 를 바꾸지 않음'); + const imp = d.store.get('SELECT * FROM mail_imports WHERE id=?', r.importId); eq(imp.mode, 'dry_run'); eq(imp.status, 'planned'); ok(!imp.applied_at); + ok(listAudit(d.store, { action: 'mail_service.import_dry_run' }).length === 1, '감사 기록'); +}); + +await test('적용: 트랜잭션으로 생성, 불변 id, unassigned legacy managed, 도메인별 active owner 1, 비밀 참조만, 감사(immutable), 재적용은 update 멱등', async () => { + const { d } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374 (mail)', role: 'mail' }); const doc = discovery(); + const r = ms.applyImport(d, doc, { serverId: srv.id, by: 'test', dryRun: false }); + eq(r.mode, 'apply'); eq(r.created.length, 2); eq(r.updated.length, 0); + const list = ms.listMailServices(d); eq(list.length, 2); + const a = list.find((x) => x.domain === 'arihakdang.com'); ok(/^mls_/.test(a.id)); eq(a.kind, 'legacy_managed'); eq(a.ownership, 'unassigned_legacy_managed'); eq(a.ownerGroupId, null); eq(a.active, true); eq(a.serverId, srv.id); + eq(a.authority, 'arimail:srv1953374:arihakdang-mail.service'); eq(a.instance.revision.version, '0.1.1'); eq(a.instance.revision.upstreamVersion, '1.0.11'); eq(a.publicHost, 'mail.arihakdang.com'); eq(a.webUrl, 'https://mail.arihakdang.com/'); + eq(a.aggregates, { accounts: 2, mailboxes: 2, messages: 189, dbBytes: 22863872 }); eq(a.secretRefs.map((s) => s.ref), ['file:/etc/arihakdang-mail/gateway.env', 'file:/srv/arihakdang-mail/data/config.json', 'dir:/etc/arihakdang-mail/tls']); + ok(a.evidence.tls.subject === 'CN=mail.arihakdang.com' && a.evidence.dns['arihakdang.com'].mx[0].includes('amazonaws'), '증거 보존'); + eq(a.lastSyncSource, 'import'); ok(a.lastSyncAt); + const au = listAudit(d.store, { action: 'mail_service.import' }); eq(au.length, 2); ok(au.every((x) => x.immutable === 1 && x.actor_type === 'operator' && x.actor_id === 'test')); + ok(!JSON.stringify(d.store.all('SELECT * FROM mail_services')).includes('re_'), 'DB 에 비밀 없음'); + // 재적용: 같은 authority·서버 → update(id 유지), 집계만 갱신 + const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 190; + const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 0); eq(r2.updated.length, 2); + const a2 = ms.getMailService(d, a.id); eq(a2.id, a.id); eq(a2.aggregates.messages, 190); eq(ms.listMailServices(d).length, 2, '중복 생성 없음'); + const rb = ms.readback(d); eq(rb.counts.total, 2); eq(rb.counts.active, 2); eq(rb.duplicates, []); eq(rb.secretLeaks, 0); eq(rb.perServer[srv.id], 2); +}); + +await test('충돌·누락: 다른 서버/인스턴스가 이미 active 인 도메인은 collision 으로 전체 거부(부분 적용 없음), 원본에서 사라진 도메인은 missing_in_source 로 보고(삭제하지 않음)', async () => { + const { d } = makeHal(); const s1 = core.createServer(d, { name: 'h1', role: 'mail' }); const s2 = core.createServer(d, { name: 'h2', role: 'mail' }); + ms.applyImport(d, discovery(), { serverId: s1.id, by: 'test', dryRun: false }); + const other = discovery({ source: { ...discovery().source, hostname: 'jitsi', unit: 'arimail.service', appDir: '/srv/arimail' }, domains: [{ domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'] }, { domain: 'ariweb.net', enabled: true, hosts: ['webmail.ariweb.net'] }] }); + const r = ms.applyImport(d, other, { serverId: s2.id, by: 'test', dryRun: false }); + eq(r.mode, 'rejected'); eq(r.plan.ok, false); eq(r.plan.items.find((i) => i.domain === 'ariweb.net').action, 'reject'); ok(/collision/.test(r.plan.items.find((i) => i.domain === 'ariweb.net').reason)); + eq(ms.listMailServices(d).filter((x) => x.serverId === s2.id).length, 0, '부분 적용 없음(ibit.kr 도 만들지 않음)'); + eq(d.store.get('SELECT status FROM mail_imports WHERE id=?', r.importId).status, 'rejected'); + ok(listAudit(d.store, { action: 'mail_service.import_rejected' }).length === 1); + // 충돌 도메인을 뺀 문서는 적용된다 + const okDoc = { ...other, domains: other.domains.slice(0, 1) }; const r2 = ms.applyImport(d, okDoc, { serverId: s2.id, by: 'test', dryRun: false }); eq(r2.created.length, 1); + eq(ms.readback(d).duplicates, []); eq(ms.readback(d).counts.active, 3); + // 원본에서 ariweb.net 이 사라지면: 보고만, 삭제 없음 + const shrunk = discovery({ domains: [discovery().domains[0]] }); const r3 = ms.applyImport(d, shrunk, { serverId: s1.id, by: 'test', dryRun: true }); + eq(r3.plan.summary.missingInSource, 1); eq(r3.plan.missingInSource, ['ariweb.net']); eq(ms.listMailServices(d).length, 3); +}); + +await test('되돌리기: 적용으로 만든 행 삭제·갱신한 행 복원, 두 번 되돌리기 거부, dry-run 되돌리기 거부', async () => { + const { d } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' }); + const r1 = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false }); + const before = ms.getMailService(d, r1.created[0]).aggregates.messages; + const doc2 = discovery(); doc2.aggregates.byDomain['arihakdang.com'].messages = 999; doc2.domains.push({ domain: 'new.example', enabled: true, hosts: ['mail.new.example'] }); + const r2 = ms.applyImport(d, doc2, { serverId: srv.id, by: 'test', dryRun: false }); eq(r2.created.length, 1); eq(r2.updated.length, 2); + const rb = ms.rollbackImport(d, r2.importId, 'test'); eq(rb.deleted, 1); eq(rb.restored, 2); + eq(ms.listMailServices(d).length, 2); eq(ms.getMailService(d, r1.created[0]).aggregates.messages, before, '갱신 전 값 복원'); + await rejects(() => ms.rollbackImport(d, r2.importId, 'test'), 'already_rolled_back'); + const dr = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: true }); await rejects(() => ms.rollbackImport(d, dr.importId, 'test'), 'not_applied'); + ok(listAudit(d.store, { action: 'mail_service.import_rollback' }).length === 1); +}); + +await test('heartbeat 연동: 서버의 Agent 가 arimail 모듈 상태를 보고하면 last_sync·health 갱신(Data Plane 과 독립: HAL 행이 없어도 heartbeat 정상)', async () => { + const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'srv1953374', role: 'mail' }); + const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: srv.id, label: 'm' }); const reg = await invoke(app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'srv1953374' }, version: '0.1.1' } }); ok(reg.json.ok); + const hb = (modules) => invoke(app, 'POST', '/agent/v1/heartbeat', { body: { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }, headers: sig(`agent:${reg.json.agentId}`, reg.json.secret, 'POST', '/agent/v1/heartbeat', { version: '0.1.1', metrics: {}, modules, clockSkewMs: 0 }) }); + eq((await hb({ arimail: { version: '0.1.1', active: true, health: 'ok' } })).json.ok, true, 'mail_services 행 없이도 정상'); + const r = ms.applyImport(d, discovery(), { serverId: srv.id, by: 'test', dryRun: false }); const beforeSync = ms.getMailService(d, r.created[0]).lastSyncAt; + await new Promise((x) => setTimeout(x, 5)); await hb({ arimail: { version: '0.1.1', active: false, health: 'down' } }); + const a = ms.getMailService(d, r.created[0]); eq(a.health, 'down'); eq(a.lastSyncSource, 'agent'); ok(a.lastSyncAt > beforeSync); eq(a.instance.observedVersion, '0.1.1'); + eq(ms.readback(d).services[0].agentId, reg.json.agentId); +}); + +await test('운영자 API: 목록·상세·dry-run/적용(재인증)·되돌리기, 응답에 비밀 없음; CLI 와 같은 함수', async () => { + const { d, app } = makeHal(); const srv = core.createServer(d, { name: 'h1', role: 'mail' }); + const { createAccount, login } = await import('../hal/ops.mjs'); createAccount(d, { login: 'op1', password: 'operator-pass-1', displayName: 'op' }); + const l = await invoke(app, 'POST', '/ops/api/login', { body: { login: 'op1', password: 'operator-pass-1' } }); const cookie = l.cookies.aw_ops; const csrf = l.json.csrf; ok(cookie && csrf, '로그인'); + const opsCall = (m, p, b) => invoke(app, m, p, { body: b, cookies: { aw_ops: cookie }, headers: { 'x-csrf': csrf } }); + let r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.ok, true); eq(r.json.services, []); eq(r.json.readback.counts.total, 0); + r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'dry_run'); + r = await opsCall('POST', '/ops/api/mail-services/import', { discovery: discovery(), serverId: srv.id, apply: true }); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.mode, 'apply'); eq(r.json.result.created.length, 2); + r = await opsCall('GET', '/ops/api/mail-services'); eq(r.json.services.length, 2); ok(!JSON.stringify(r.json).includes('re_abc')); + r = await opsCall('GET', `/ops/api/mail-services/${r.json.services[0].id}`); eq(r.json.ok, true); ok(r.json.service.domain); ok(Array.isArray(r.json.audit)); + r = await opsCall('GET', '/ops/api/mail-services/imports'); eq(r.json.imports.length, 2); + const applied = r.json.imports.find((i) => i.mode === 'apply'); r = await opsCall('POST', `/ops/api/mail-services/imports/${applied.id}/rollback`, {}); eq(r.status, 200, JSON.stringify(r.json)); eq(r.json.result.deleted, 2); + eq((await opsCall('GET', '/ops/api/mail-services')).json.services.length, 0); + // 메일 서비스 화면(HAL 관리형 표 + 포털 중계) + ms.applyImport(d, discovery(), { serverId: srv.id, by: 'op1', dryRun: false }); + const page = await invoke(app, 'GET', '/ops/mail', { cookies: { aw_ops: cookie } }); ok(page.text.includes('arihakdang.com') && page.text.includes('legacy') && page.text.includes('unassigned'), '화면에 HAL 관리형 메일 서비스 표'); +}); + +done(); diff --git a/test/mail-discover.test.mjs b/test/mail-discover.test.mjs new file mode 100644 index 0000000..da0c0b5 --- /dev/null +++ b/test/mail-discover.test.mjs @@ -0,0 +1,46 @@ +/** test/mail-discover.test.mjs — scripts/mail-discover.mjs(읽기 전용 AriMail discovery) 회귀 테스트. + * 가짜 앱 디렉터리(AriMail 1.0.7+ 등록부 형식·실제 스키마 이름의 SQLite)로 문서 모양·집계·비밀 미포함·HAL 검증 통과를 확인한다. systemctl/ss/네트워크는 없는 환경에서도 돈다(--no-net). */ +process.env.ARIWEB_DISCOVER_NO_MAIN = '1'; +import { test, eq, ok, tmpDir, done } from '../lib/test.mjs'; +import { discover, dbCounts } from '../scripts/mail-discover.mjs'; +import { validateDiscovery, authorityOf } from '../hal/mailsvc.mjs'; +import { DatabaseSync } from 'node:sqlite'; +import { mkdirSync, writeFileSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; + +function fakeApp() { + const app = join(tmpDir('mail-disc-'), 'srv', 'arimail'); const rel = join(app, 'releases', '20261024-204656'); mkdirSync(rel, { recursive: true }); mkdirSync(join(app, 'data', 'domains', 'second.example'), { recursive: true }); mkdirSync(join(app, 'data', 'backups'), { recursive: true }); + symlinkSync(rel, join(app, 'current')); writeFileSync(join(rel, 'REVISION'), 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924\n'); writeFileSync(join(rel, 'package.json'), JSON.stringify({ name: 'arimail', version: '1.0.11' })); + writeFileSync(join(app, 'data', 'domains.json'), JSON.stringify({ _meta: { format: 1 }, primary: 'ibit.kr', domains: { 'ibit.kr': { domain: 'ibit.kr', enabled: true, primary: true, hosts: ['webmail.ibit.kr'], mailbox: 'admin@ibit.kr', auth: { mode: 'local' }, note: '' }, 'second.example': { domain: 'second.example', enabled: true, hosts: ['mail.second.example'], mailbox: 'x@second.example', auth: { mode: 'webmaker' }, ariweb: { kind: 'standalone', siteId: '', groupId: 'g_1', mailServiceId: 'msv_1' }, operating: 'frozen' } } })); + writeFileSync(join(app, 'data', 'config.json'), JSON.stringify({ _meta: {}, RESEND_API_KEY: 're_SECRETSECRETSECRETSECRETSECRET1234', inbound: { provider: 'resend' }, outbound: { provider: 'resend' } }), { mode: 0o600 }); + writeFileSync(join(app, 'data', 'haraka-status.json'), JSON.stringify({ enabled: false, running: false, inbound: false, outbound: false, listen: ['127.0.0.1:2525'], version: '3.3.4', dkim: false })); + writeFileSync(join(app, 'data', 'setup-token'), 'tokentokentokentokentoken', { mode: 0o600 }); + const mk = (f, users, msgs) => { const db = new DatabaseSync(f); db.exec("CREATE TABLE mail_users(id INTEGER PRIMARY KEY, login TEXT, password_hash TEXT, status TEXT); CREATE TABLE messages(id INTEGER PRIMARY KEY, subject TEXT); CREATE TABLE attachments(id INTEGER PRIMARY KEY); CREATE TABLE schema_migrations(id TEXT); CREATE TABLE outbound_queue(id INTEGER PRIMARY KEY)"); for (let i = 0; i < users; i++) db.prepare("INSERT INTO mail_users(login, password_hash, status) VALUES(?, 'HASHHASHHASHHASHHASHHASHHASHHASHHASHHASHHASH', 'active')").run('u' + i); for (let i = 0; i < msgs; i++) db.prepare('INSERT INTO messages(subject) VALUES(?)').run('s' + i); db.close(); }; + mk(join(app, 'data', 'mail-gateway.sqlite'), 3, 7); mk(join(app, 'data', 'domains', 'second.example', 'mail.sqlite'), 2, 1); + const etc = join(app, '..', 'etc'); mkdirSync(join(etc, 'tls'), { recursive: true }); writeFileSync(join(etc, 'gateway.env'), 'MAILBOX=admin@ibit.kr\nRESEND_API_KEY=re_SECRETSECRETSECRETSECRETSECRET1234\nPUBLIC_HOST=webmail.ibit.kr\n', { mode: 0o600 }); writeFileSync(join(etc, 'tls', 'privkey.pem'), '-----BEGIN PRIVATE KEY-----\nxxx\n-----END PRIVATE KEY-----\n'); + const vhost = join(etc, 'webmail.ibit.kr.conf'); writeFileSync(vhost, 'server { listen 80; server_name webmail.ibit.kr; location / { return 301 https://webmail.ibit.kr$request_uri; } }\nserver { listen 443 ssl; server_name webmail.ibit.kr; ssl_certificate /etc/letsencrypt/live/webmail.ibit.kr/fullchain.pem; location / { proxy_pass http://127.0.0.1:8790; } }'); + const hook = join(etc, 'arimail.sh'); writeFileSync(hook, '#!/bin/bash\nsystemctl restart arimail || true\n'); + return { app, etc, vhost, hook }; +} + +await test('dbCounts: 표 이름·행 수·무결성만(주소·해시는 읽지 않음)', () => { + const { app } = fakeApp(); const c = dbCounts(join(app, 'data', 'mail-gateway.sqlite')); + eq(c.accounts, 3); eq(c.activeAccounts, 3); eq(c.messages, 7); eq(c.integrity, 'ok'); eq(c.outboundQueue, 0); ok(!JSON.stringify(c).includes('HASH')); + eq(dbCounts(join(app, 'data', 'nope.sqlite')), null); +}); + +await test('discover(--no-net): 등록부 도메인별 집계(기본 도메인은 게이트웨이 DB, 나머지는 테넌트 DB), 리비전, 비밀은 참조만(키 이름만), HAL 검증 통과, authority', async () => { + const { app, etc, vhost, hook } = fakeApp(); + const doc = await discover({ unit: 'arimail.service', appDir: app, etcDir: etc, vhost, hook, net: false, host: 'jitsi' }); + eq(doc.format, 1); eq(doc.source.unit, 'arimail.service'); eq(doc.source.revision.commit, 'a07f69f1ffb6667de6e66b3d1ec43c1c66fab924'); eq(doc.source.packageVersion, '1.0.11'); ok(doc.source.currentTarget.endsWith('releases/20261024-204656')); eq(doc.source.releases, ['20261024-204656']); + eq(doc.domains.length, 2); const p = doc.domains.find((x) => x.domain === 'ibit.kr'); eq(p.primary, true); eq(p.hosts, ['webmail.ibit.kr']); eq(p.authMode, 'local'); ok(p.tenantDb.endsWith('mail-gateway.sqlite')); + const s = doc.domains.find((x) => x.domain === 'second.example'); eq(s.primary, false); eq(s.ariweb, { kind: 'standalone', siteId: null, groupId: 'g_1', mailServiceId: 'msv_1' }, '빈 값은 null 로 정규화'); eq(s.operating, 'frozen'); + eq(doc.aggregates.accounts, 5); eq(doc.aggregates.mailboxes, 5); eq(doc.aggregates.messages, 8); eq(doc.aggregates.byDomain['ibit.kr'].accounts, 3); eq(doc.aggregates.byDomain['second.example'].messages, 1); ok(doc.aggregates.storageBytes > 0); + eq(doc.provider.kind, 'resend'); eq(doc.provider.configKeys, ['RESEND_API_KEY', 'inbound', 'outbound']); eq(doc.evidence.haraka.enabled, false); + const txt = JSON.stringify(doc); ok(!txt.includes('re_SECRET') && !txt.includes('HASHHASH') && !txt.includes('BEGIN PRIVATE') && !txt.includes('tokentoken'), '비밀값 없음'); ok(!txt.includes('admin@ibit.kr'), '메일 주소 없음'); + const refs = doc.secretRefs.map((r) => r.ref); ok(refs.some((r) => r === `file:${join(app, 'data', 'config.json')}`) && refs.some((r) => r === `dir:${join(etc, 'tls')}`) && refs.some((r) => r.endsWith('setup-token')), JSON.stringify(refs)); + eq(doc.evidence.nginx.serverNames, ['webmail.ibit.kr']); eq(doc.evidence.nginx.proxyPass, ['http://127.0.0.1:8790']); eq(doc.evidence.nginx.httpRedirect, true); eq(doc.evidence.certbot.restartsUnit, true); + const v = validateDiscovery(doc); eq(v.errors, []); eq(authorityOf(doc), 'arimail:jitsi:arimail.service'); +}); + +done();