Compare commits

..
Author SHA1 Message Date
oz 12958ab695 feat(hal): inventory existing Webmaker sites 2026-10-27 08:57:21 +00:00
zzabisagent-labandClaude Fable 5.1 4bd698452b 메일 서비스 ↔ HAL(hal21) 연동 실제 적용 기록: docs/verification/mail-hal-link-2026-10-27.md(호스트·인스턴스·백업 sha256·dry-run/apply/readback·Agent heartbeat·회귀·blocker), VERSION_HISTORY 결과 요약
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:35:12 +00:00
zzabisagent-labandClaude Fable 5.1 79b45ca961 기존 AriMail 인스턴스 ↔ HAL 관리형 MailService/Node 가져오기: hal/mailsvc.mjs(등록부·dry-run·트랜잭션 적용·되돌리기·heartbeat 동기화) + scripts/mail-discover.mjs(읽기 전용 discovery) + CLI/운영자 API/화면, 테스트 9건, RUNBOOK §9
- 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>
2026-10-27 05:25:05 +00:00
zzabisagent-labandClaude Fable 5.1 03cb9c0777 운영 Webmaker ↔ HAL 연동 점검 도구와 dry-run 기록: scripts/webmaker-inventory.mjs(읽기 전용 inventory·안정 식별자 대조·충돌/누락 판정·비밀 차단) + 테스트 5건, RUNBOOK §8, 검증 기록
- hostinger webmaker.ariweb.net 실데이터 결과: 프로젝트 10(연결 일치 3·고유 7), 회원 12(SSO 3·고유 9), Gitea refs 13, HAL 모듈 0.1.1 ok — 충돌 0·누락 0, 적용 변경 없음(교체 불필요)
- 사전 스냅샷·복원 검증·되돌리기 절차·사후 readback 은 docs/verification/webmaker-hal-link-2026-10-27.md (비밀값 없음)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 05:06:37 +00:00
22 changed files with 1232 additions and 10 deletions
+12
View File
@@ -118,6 +118,18 @@
- **`deploy/bootstrap-hal.sh --portal-host none`**: 포털을 다른 호스트에서 운영하는 HAL 전용 호스트에는 `my.ariweb.net` nginx 접근 경로를 만들지 않는다(이전에는 항상 만들어 포털이 없는 호스트에도 죽은 vhost 가 남았다). 기본값(`my.ariweb.net`)의 동작은 그대로다.
- bear(Ubuntu 24.04, nginx 1.24, Node 24.22, certbot nginx 플러그인 운영)에 HAL 만 최초 부트스트랩: 인증서는 호스트별 certbot(webroot `/var/www/html`, ECDSA, 갱신 훅 `systemctl reload nginx`) — `--cert /etc/letsencrypt/live/hal21.ariweb.net --acme /var/www/html`. 기존 Webmaker·Gitea·Matrix·기본 서버는 건드리지 않았다. 절차·검증·되돌리기는 `docs/RUNBOOK-0.1.1.md` §7. 배포 artifact: main `ab75b7d`, hal sha256 `f574d875…`(deploy/ 변경으로 ariweb.net 호스트의 `e7741899…` 와 다름). 알려진 사소한 결함: 부트스트랩 5단계의 HTTPS 자체 확인이 nginx reload 직후 실행되어 거짓 경고가 날 수 있다(결과에 영향 없음).
- **HAL 기본 주소 안내**(`hal/app.mjs`): `https://hal21.ariweb.net/` 접속이 JSON 404를 반환하던 동작을 운영자 콘솔 표준 경로 `/ops/`로 이동하도록 수정했다. `/ops`의 기존 사설망/VPN·기기 접근권 관문과 별도 운영자 로그인은 그대로 적용된다.
- **기존 사이트 인벤토리**(`agent/webmaker-inventory.mjs`, HAL 서버 상세): Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조해 모든 디렉터리의 ID·제목·계정 식별자·배포 상태(`deployed`/`unpublished`/`draft`/`unknown`)·서비스 상태·도메인·크기·파일 수를 HAL에 연결한다. 메타데이터가 없는 디렉터리도 `missing`으로 남기며 총 수·수집 수·잘림 여부·스캔 오류를 함께 기록한다. 2 MiB heartbeat 제한과 1,000항목 제한을 넘는 인벤토리는 512 KiB 이하 인증 배치로 전송하고 마지막 배치에서만 원자 반영한다. 페이지 본문·프롬프트·연락처·인증정보는 전송하지 않는다.
### 운영 Webmaker ↔ HAL 연동 점검 도구(2026-10-27, webmaker.ariweb.net)
- **`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-<g8>-<u8>`) 신원, 도메인, Gitea `sites/<id>/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:<hostname>:<unit>`, 도메인별 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 <json> (--server <id> | --create-server --name …) [--apply]`, `mail-import-rollback --import <id>`, `mail-services`(readback JSON), `server-token --server <id>`(운영자 콘솔이 닿지 않는 부트스트랩·연동 단계의 시스템 신원 토큰 발급, 감사 `cli:<by>`). **운영자 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`).
- 2026-10-27 실제 적용: bear HAL 을 커밋 79b45ca 릴리스로 교체(재시작 2.6초, 메일 서비스 프로브 무중단) → 서버 2(pending) · MailService 7(hostinger 인스턴스 6 도메인 + jitsi 1, 모두 `unassigned_legacy_managed`, duplicates 0, secretLeaks 0) · jitsi Node Agent 등록(heartbeat → `last_sync_source=agent`, 승인은 운영자 결정) · hostinger 는 기존 HAL(`hal.ariweb.net`) 제어 평면과의 이중 제어를 피해 Agent 미설치(관측 전용). 원본 메일 서비스 변경 0. 결과·백업 sha256·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` 에 기록한다.
+20 -1
View File
@@ -12,6 +12,7 @@ import { createSys } from './sys.mjs';
import { createRunner } from './runner.mjs';
import { KINDS } from './jobs/index.mjs';
import { sample, facts } from './metrics.mjs';
import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs';
import { readFileSync, existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { hostname } from 'node:os';
@@ -36,9 +37,27 @@ if (cmd === 'register') {
const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') });
const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log });
let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null;
/** 기존 Webmaker 사이트: 운영에 필요한 식별자·상태·도메인·배포 용량만 facts에 싣고 본문·프롬프트·연락처·비밀은 제외한다. */
function inventoryFacts(f) {
return configuredWebmakerInventory(f);
}
/** 2 MiB 요청 한도와 무관하게 전체 인벤토리를 512 KiB 이하 배치로 순차 전송한다. */
async function syncWebmakerInventory(inv) {
if (!inv) return; const batches = webmakerInventoryBatches(inv); let offset = 0;
for (const body of batches) { const r = await client.request('POST', '/agent/v1/inventory/webmaker', body); offset = Number(r.nextOffset); }
if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`);
}
/** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */
async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: facts(sys) }; const sites = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))).length : 0; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: lastFacts.f, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
async function heartbeat() { try {
if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false };
const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : [];
const legacy = lastFacts.f.webmakerInventory?.sites?.map((x) => x.id) || [];
const sites = new Set([...managed, ...legacy]).size;
if (lastFacts.f.webmakerInventory && !lastFacts.inventorySynced) { try { await syncWebmakerInventory(lastFacts.f.webmakerInventory); lastFacts.inventorySynced = true; } catch (e) { log(`[agent] Webmaker 인벤토리 전송 실패: ${e.message}`); } }
const heartbeatFacts = { ...lastFacts.f }; delete heartbeatFacts.webmakerInventory;
await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: heartbeatFacts, sitesHosted: sites, jobs: { running } });
} catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } }
async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } }
// 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개
await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); }
+116
View File
@@ -0,0 +1,116 @@
/**
* agent/webmaker-inventory.mjs — 기존 Webmaker 사이트 디렉터리를 HAL 서버 인벤토리로 정리한다.
*
* 운영 정보만 보낸다. 페이지 본문, 프롬프트, 사용자 연락처, 인증정보와 비밀값은 결과에
* 넣지 않는다. 사이트 작업 디렉터리도 존재할 수 있으므로 배포 이력을 함께 읽어 상태를
* deployed/unpublished/draft/unknown으로 구분한다. 메타데이터 없는 디렉터리도 누락하지 않는다.
*/
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const SITE_ID = /^[a-f0-9]{32}$/;
const text = (v, max = 240) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, max) : null;
function domainsOf(site) {
const src = Array.isArray(site?.domains) ? site.domains : [];
return [...new Set(src.map((d) => typeof d === 'string' ? d : d?.domain || d?.hostname || d?.host || d?.name)
.map((d) => text(d, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100);
}
function treeStat(root, errors, siteId) {
let bytes = 0; let files = 0; let complete = true; const stack = [root];
const note = (e) => { complete = false; const code = e?.code || 'read_failed'; const marker = `site:${siteId}:${code}`; if (!errors.includes(marker)) errors.push(marker); };
while (stack.length) {
const dir = stack.pop(); let entries = [];
try { entries = readdirSync(dir, { withFileTypes: true }); } catch (e) { note(e); continue; }
for (const e of entries) {
const p = join(dir, e.name); try {
if (e.isSymbolicLink()) continue;
if (e.isDirectory()) stack.push(p);
else if (e.isFile()) { bytes += lstatSync(p).size; files += 1; }
} catch (error) { note(error); }
}
}
return { bytes, files, complete };
}
function deploymentState(site) {
if (!site) return 'unknown';
if (site.unpublishedAt) return 'unpublished';
const history = Array.isArray(site.deployments) ? site.deployments : [];
const lastLifecycle = [...history].reverse().find((d) => d && ((d.kind === 'unpublish' && d.ok !== false) || (d.ok && d.kind !== 'unpublish' && (d.ready === true || d.phase === 'ready' || d.phase === 'transferred' || !d.phase))));
if (lastLifecycle?.kind === 'unpublish') return 'unpublished';
if (lastLifecycle || site.deployedAt || site.publishedAt) return 'deployed';
return 'draft';
}
export function scanWebmakerInventory({ dataRoot, dataDir = null, sitesRoot, maxSites = Infinity } = {}) {
const metaRoot = dataDir || join(dataRoot || '', 'sites');
let dirs = []; let metadataIds = []; let complete = true; const errors = [];
try { dirs = readdirSync(sitesRoot, { withFileTypes: true }).filter((e) => e.isDirectory() && SITE_ID.test(e.name)).map((e) => e.name); }
catch (e) { complete = false; errors.push(`sites:${e.code || 'read_failed'}`); }
try { metadataIds = readdirSync(metaRoot, { withFileTypes: true }).filter((e) => e.isFile() && SITE_ID.test(e.name.replace(/\.json$/i, '')) && /\.json$/i.test(e.name)).map((e) => e.name.slice(0, -5)); } catch (e) { complete = false; errors.push(`metadata:${e.code || 'read_failed'}`); }
const deployed = new Set(dirs); const ids = [...new Set([...dirs, ...metadataIds])].sort();
const limit = Number.isFinite(maxSites) && maxSites >= 0 ? Math.floor(maxSites) : ids.length;
const selected = ids.slice(0, limit);
const sites = selected.map((id) => {
let site = null; let metadata = 'missing';
const metadataFile = join(metaRoot, `${id}.json`);
try {
site = JSON.parse(readFileSync(metadataFile, 'utf8'));
if (!site || typeof site !== 'object' || Array.isArray(site)) { site = null; metadata = 'invalid'; const marker = `metadata:${id}:invalid_shape`; if (!errors.includes(marker)) errors.push(marker); }
else metadata = 'ok';
} catch (e) {
if (e?.code === 'ENOENT' && !existsSync(metadataFile)) metadata = 'missing';
else { metadata = 'invalid'; const marker = `metadata:${id}:${e?.code || 'invalid_json'}`; if (!errors.includes(marker)) errors.push(marker); }
}
const size = deployed.has(id) ? treeStat(join(sitesRoot, id), errors, id) : { bytes: 0, files: 0, complete: true };
if (!size.complete) complete = false;
return {
id,
title: text(site?.title || site?.name || id, 200),
ownerId: text(site?.ownerId, 128),
status: text(site?.status, 80),
deploymentState: deploymentState(site),
domains: domainsOf(site),
updatedAt: text(site?.updatedAt || site?.deployedAt || site?.publishedAt, 40),
metadata,
bytes: size.bytes,
files: size.files,
};
});
if (sites.length < ids.length) complete = false;
return {
dataRoot: dataRoot || null,
dataDir: metaRoot || null,
sitesRoot: sitesRoot || null,
total: ids.length,
returned: sites.length,
truncated: sites.length < ids.length,
errors,
complete,
scannedAt: new Date().toISOString(),
sites,
};
}
/** 운영 경로는 Agent unit에 명시적으로 주입한다. systemd 설정을 추측·재해석해 다른 데이터 루트를 스캔하지 않는다. */
export function configuredWebmakerInventory(facts, env = process.env) {
if (!facts?.units?.webmaker) return facts;
const dataRoot = env.ARIWEB_WEBMAKER_DATA_ROOT; const dataDir = env.ARIWEB_WEBMAKER_DATA_DIR; const sitesRoot = env.ARIWEB_WEBMAKER_SITES_ROOT;
if (![dataRoot, dataDir, sitesRoot].every((p) => typeof p === 'string' && p.startsWith('/'))) return { ...facts, webmakerInventory: { dataRoot: null, dataDir: null, sitesRoot: null, total: 0, returned: 0, truncated: false, errors: ['configuration:ARIWEB_WEBMAKER_DATA_ROOT_DATA_DIR_SITES_ROOT_required'], complete: false, scannedAt: new Date().toISOString(), sites: [] } };
return { ...facts, webmakerInventory: scanWebmakerInventory({ dataRoot, dataDir, sitesRoot }) };
}
/** HAL JSON 본문 상한과 무관하게 전체 사이트를 byte·항목 수 기준 배치로 나눈다. */
export function webmakerInventoryBatches(inv, maxBytes = 512 * 1024) {
if (!Number.isInteger(maxBytes) || maxBytes < 64 * 1024) throw new Error('인벤토리 배치 한도가 너무 작습니다');
if (inv?.complete === false) throw new Error('inventory_incomplete');
const sites = Array.isArray(inv?.sites) ? inv.sites : []; const rawErrors = Array.isArray(inv?.errors) ? inv.errors : []; const meta = {
dataRoot: String(inv?.dataRoot || '').slice(0, 4096), dataDir: String(inv?.dataDir || '').slice(0, 4096), sitesRoot: String(inv?.sitesRoot || '').slice(0, 4096), total: Number.isFinite(Number(inv?.total)) ? Number(inv.total) : sites.length, truncated: !!inv?.truncated,
errors: rawErrors.slice(0, 1000).map((x) => String(x).slice(0, 500)), errorsTruncated: rawErrors.length > 1000, scannedAt: String(inv?.scannedAt || new Date().toISOString()).slice(0, 80),
}; const scanId = meta.scannedAt || String(Date.now()); const out = []; let offset = 0; let part = [];
const body = (items) => ({ scanId, offset, itemTotal: sites.length, meta, sites: items });
for (const site of sites) {
const candidate = [...part, site]; if (candidate.length > 1000 || Buffer.byteLength(JSON.stringify(body(candidate))) > maxBytes) {
if (!part.length) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다'); out.push(body(part)); offset += part.length; part = [site]; if (Buffer.byteLength(JSON.stringify(body(part))) > maxBytes) throw new Error('Webmaker 인벤토리 단일 항목이 배치 한도를 넘습니다');
} else part = candidate;
}
if (part.length || !sites.length) { const last = body(part); if (Buffer.byteLength(JSON.stringify(last)) > maxBytes) throw new Error('Webmaker 인벤토리 메타데이터가 배치 한도를 넘습니다'); out.push(last); } return out;
}
+36
View File
@@ -85,3 +85,39 @@ sudo rm /etc/nginx/sites-enabled/ariweb-hal.conf /etc/nginx/sites-available/ariw
sudo rm /etc/sudoers.d/ariweb-agent; sudo rm -rf /srv/ariweb /var/www/ariweb-sites # 데이터 포함 — 보관이 필요하면 /srv/ariweb/hal/data 먼저 복사
sudo certbot delete --cert-name hal21.ariweb.net # 인증서까지 지울 때만
```
## 8. 운영 Webmaker(webmaker.ariweb.net) ↔ HAL 연동 점검·dry-run (2026-10-27)
사실: `webmaker.ariweb.net` = hostinger, 이미 HAL 작업으로 설치된 Ariweb Suite Webmaker 모듈 0.1.1(상류 1.7.3)이며 hostinger HAL 의 `module_targets.webmaker` 에 등록돼 있다(교체 불필요). HAL 은 사이트·계정을 모델링하지 않으므로 "HAL 리소스" 는 모듈 등록이고, 프로젝트 연결은 포털 ↔ Webmaker 양쪽 등록부로만 이뤄진다. 기록: `docs/verification/webmaker-hal-link-2026-10-27.md`.
```bash
# 1) 사전 스냅샷(hostinger, master, 사설 0700) — /srv/webmaker/backups/pre-hal-link-<UTC>/ : data·sites tar, 원본 32,608 파일 sha256, conf, Gitea refs, HAL·포털 DB VACUUM INTO 사본, MANIFEST.sha256, ROLLBACK.md. 만든 뒤 임시 디렉터리에 풀어 체크섬 전수 대조(RESTORE_TEST_OK)
# 2) 읽기 전용 inventory + dry-run (비밀값 없음, 충돌·누락이 있으면 종료 코드 2)
node --no-warnings scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker --sites-root /var/www/webmaker-sites --app-dir /srv/webmaker/current \
--portal-db /srv/ariweb/portal/data/portal.sqlite --hal-db /srv/ariweb/hal/data/hal.sqlite --nginx-enabled /etc/nginx/sites-enabled \
--gitea-refs <ls-remote --heads 출력> --out <백업 디렉터리>/dry-run.json
# 3) 사후 readback: (cd / && sha256sum --quiet -c <백업>/checksums-source.txt) · git fsck · ls-remote diff · systemctl show webmaker -p NRestarts · nginx -t · 외부 URL
```
판정 뜻: `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 밖) — <app>/backups/pre-hal-onboard-<UTC>/ : 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-<UTC>/ (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 <srv_id> --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 <srv_id> --by hera # 1회 표시 토큰 → 원본 호스트에서 install.sh (--role mail)
# 운영자 콘솔에서 서버 '승인' 은 사람 결정(첫 Agent 승인은 부트스트랩을 닫고 수동 설치를 잠근다 §32.2)
```
되돌리기: `node hal/index.mjs mail-import-rollback --import <mimp_id>`(그 적용이 만든 행 삭제·갱신 행 복원), 서버 행은 운영자 콘솔 서버 삭제(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 미연결)으로 두었다 — 제어 평면 이전은 사람 결정.
@@ -0,0 +1,67 @@
# 기존 메일 서비스 ↔ HAL(bear hal21) 연동 기록 — 2026-10-27 (Hera)
대상: `https://mail.arihakdang.com/` · `https://webmail.ibit.kr/` → bear HAL `https://hal21.ariweb.net` 의 관리형 MailService/Node 등록. 기존 송수신·계정·메일·DNS/MX·TLS·provider 는 바꾸지 않았다. 이 문서에 비밀값은 없다(경로·키 이름·해시만).
## 1. 소스·배포 리비전
| 항목 | 값 |
|---|---|
| 작업 시작 시 origin/main | `ac072e6f12a910ccd60dc7e63a9724363617a46b` (rebase 뒤 `03cb9c0777ddc3dc2003893d3dddd53a01565f2b`) |
| 코드 커밋(배포한 HAL) | `79b45ca96102213d2bd30856ef79b6d732758349` — release.mjs build: testsPassed=true, dirty=false, hal artifact sha256 `bf636f847266e319c888f3ba9f40fc8a22c9f5e6f43775d61d967752742db489` |
| bear HAL 배포 전 | `/srv/ariweb/hal/releases/0.1.1-ac072e6f12a9` (hal.sqlite servers 0 · agents 0 · ops_accounts 1 · mail_services 표 없음) |
| bear HAL 배포 후 | `/srv/ariweb/hal/releases/0.1.1-79b45ca96102` → `current`, `ariweb-hal` 05:28:55Z 재시작(재시작→/api/health 2.6초, NRestarts 0), `/api/health.bootstrap.mainCommit=79b45ca…`, 릴리스 0.1.1 artifact 재등록(approved, by hera-mail-onboard) |
## 2. 실제 호스트·인스턴스(읽기 전용 조사)
| | mail.arihakdang.com | webmail.ibit.kr |
|---|---|---|
| 호스트 | hostinger `srv1953374` (187.53.133.173, Ubuntu, Node v24.14.1 /opt/arihakdang-node) | `jitsi.ibit.kr` (115.68.194.192, Ubuntu 22.04, Node v22.23.3 /opt/arimail-node) |
| 유닛 / 실행 계정 / cwd | `arihakdang-mail.service` (+`-haraka`) / arihakdang / `/srv/arihakdang-mail/current` | `arimail.service` (+`-haraka`) / arimail / `/srv/arimail/current` |
| 리비전 | `releases/0.1.1` REVISION.json: Suite arimail 0.1.1, upstream AriMail 1.0.11, 2026-10-26 설치(hostinger HAL 모듈 배포) | `releases/20261024-204656` REVISION `a07f69f1ffb6667de6e66b3d1ec43c1c66fab924`, package 1.0.11 |
| 설정·비밀 참조 | `/etc/arihakdang-mail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arihakdang-mail/tls/` · `data/setup-token` | `/etc/arimail/gateway.env`(0600) · `data/config.json`(0600) · `/etc/arimail/tls/` · `data/setup-token` |
| 도메인 등록부 | 6: arihakdang.com(primary, auth www) · ariweb.net(webmaker) · check/solo-mvpcgayc·mvpcu283.ariweb.net(4개, Suite E2E 테넌트, operating=frozen, hostinger 포털 group/mailService 링크) | 1: ibit.kr(primary, auth local) |
| 집계(계정=메일함 / 메일 / 저장) | 10 / 195 / 30.4MB(DB 기준; data 디렉터리 545MB — 과거 pre-* DB 백업 포함) | 3 / 0 / 2.1MB |
| 리스너(유닛 PID 소유) | 110·143·465·587·993·995(0.0.0.0), 8790·8791(127.0.0.1). 25 없음 | 110·143·465·587·993·995, 8790. 25 없음 |
| provider | Resend(inbound/outbound), Haraka 설치됨·비활성 | Resend, Haraka 비활성. journal: `resend._domainkey.ibit.kr` TXT 검증 실패 반복(기존 상태, 변경 없음) |
| DNS(공개 리졸버) | A 187.53.133.173 · MX arihakdang.com→inbound-smtp.ap-northeast-1.amazonaws.com | A 115.68.194.192 · MX ibit.kr→inbound-smtp.ap-northeast-1.amazonaws.com |
| TLS | LE `CN=mail.arihakdang.com`(+SAN webmail.ariweb.net) 만료 2027-01-12 | LE `CN=webmail.ibit.kr` 만료 2027-01-19 |
| nginx / certbot 훅 | `sites-available/mail.arihakdang.conf` → 127.0.0.1:8790, 80→301 https · deploy 훅 `arihakdang-mail.sh`(갱신 시 유닛 재시작) | `webmail.ibit.kr.conf` → 127.0.0.1:8790, 80→301 · 훅 `arimail.sh` |
| 기존 제어 평면 | hostinger HAL(`hal.ariweb.net`, 부트스트랩 완료) `module_targets.arimail` 0.1.1 ok, Agent `ag_01a178829f…` 등록 | 없음 |
두 URL 은 **서로 다른 호스트·다른 AriMail 인스턴스**다(같은 멀티도메인 리스너가 아님).
## 3. 사전 스냅샷(비밀 제외 sha256, 모두 root 0700/0600, git 밖, `sha256sum -c` 통과, SQLite `integrity_check=ok`)
| 위치 | archive.tgz | db.tgz | code.tgz |
|---|---|---|---|
| hostinger `/srv/arihakdang-mail/backups/pre-hal-onboard-20261027T051350Z/` (RESTORE.md 포함) | `abb731f26e6a06c4…` (32 entries: data·config·domains.json·gateway.env·유닛·nginx·훅) | `1ba2eb9c2ba6c8ad…` (VACUUM INTO 사본 6개: 게이트웨이 + 테넌트 5) | `releases/0.1.1` 트리 106 entries |
| jitsi `/srv/arimail/backups/pre-hal-onboard-20261027T051352Z/` | `7e36bcbff719dc36…` (20 entries) | `26035be7a4afdc71…` (사본 1) | `releases/20261024-204656` 66 entries |
| bear `/srv/ariweb/backups/hal-pre-mail-onboard-20261027T051353Z/` | `534c9eb1228c011e…` (vault·config·유닛·nginx) | `da848bc05c84ef7d…` (hal.sqlite 사본, servers 0/agents 0/ops_accounts 1) | `releases/0.1.1-ac072e6f12a9` 79 entries |
복원 순서는 각 디렉터리의 `RESTORE.md`(메일 복원은 서비스 중단 동반 → 운영자 승인 뒤에만). HAL 되돌리기: `mail-import-rollback --import <id>` 또는 RESTORE.md(hal.sqlite 사본 + current 심링크 `0.1.1-ac072e6f12a9`).
## 4. discovery → dry-run → apply → readback (bear, HAL CLI, 시스템 신원 `cli:hera`)
- discovery 문서: `scripts/mail-discover.mjs`(sudo, 읽기 전용) — sha256 `1cfb2042138d1de3…`(hostinger, 05:22:06Z) · `18cda7182ac68dd7…`(jitsi, 05:22:07Z). `validateDiscovery` ok(비밀값 0, 메일 주소 0).
- 서버(노드) 생성: `srv_01a17bd4dbcdb55877f2d9fcab` "srv1953374 (mail.arihakdang.com)" role mail · `srv_01a17bd4dc2a2bc3050054fee0` "jitsi.ibit.kr (webmail.ibit.kr)" role mail — 둘 다 pending.
- dry-run: `mimp_01a17bd4dbd076ebd300d5a681`(create 6·reject 0) · `mimp_01a17bd4dc2e21a185a4cefc09`(create 1·reject 0). DB 변경 없음(mail_services 0).
- apply: `mimp_01a17bd5f2685f8929d7638a28` → 생성 6(`mls_01a17bd5f26934dd18266db83a` arihakdang.com, `…f26a6e17e69ff81adb` ariweb.net, `…f26afc33723ce9c622` check-mvpcgayc, `…f26a1478f1bafcdca7` solo-mvpcgayc, `…f26aa7eab73a48883e` check-mvpcu283, `…f26bbb6aa9e045b23b` solo-mvpcu283) · `mimp_01a17bd5f2eaf5667cbc127dac` → 생성 1(`mls_01a17bd5f2eaf032215343d7cb` ibit.kr).
- 멱등·충돌 검증: 같은 문서 재적용 `mimp_01a17bd5f35c29928b0609ec45` = update 6·create 0(id 유지) · jitsi 문서를 hostinger 서버에 적용 시도 `mimp_01a17bd5f3c482c2797e178e46` = rejected(ibit.kr collision) — 부분 적용 없음.
- readback(`hal/index.mjs mail-services`, 05:33Z): total 7 · active 7 · 도메인 7(각 1) · duplicates [] · secretLeaks 0 · perServer {hostinger 6, jitsi 1} · 모두 kind legacy_managed · ownership **unassigned_legacy_managed**(ariweb.net 테넌트의 hostinger 포털 group 링크는 domain_json 증거로만 보관, 매핑하지 않음). 각 행: 서버·유닛·appDir·리비전·집계·리스너·TLS 만료·DNS MX·nginx·secretRefs(참조 4건, 값 없음).
- 감사: `server.create` 2 · `mail_service.import_dry_run` 2 · `mail_service.import` 13(immutable) · `mail_service.import_rejected` 1 · `release.register/approve`. hal.sqlite `integrity_check=ok`, 마이그레이션 `hal_0003_mail_services` 적용(기존 표 변경 없음).
## 5. Node Agent / heartbeat
- jitsi: 토큰 `hal/index.mjs server-token --server srv_01a17bd4dc2a2…`(1회, 화면·로그 미기록) → `https://hal21.ariweb.net/agent/v1/install.sh --role mail --label jitsi.ibit.kr`. 결과 Agent `ag_01a17bd7201c9a73c157e29968`(managed, 05:30:58Z), 유닛 `ariweb-agent`(master, `/srv/ariweb/agent/current` 0.1.1) active NRestarts 0, 비밀은 `/srv/ariweb/agent/data` 보관소만. 설치가 만든 것: `/srv/ariweb/agent`, `/srv/ariweb/backups`, `/var/www/ariweb-sites`, `/etc/systemd/system/ariweb-agent.service`, `/etc/sudoers.d/ariweb-agent`, `/etc/nginx/conf.d/awstats.conf`(log_format, `nginx -t` 성공·graceful reload). **arimail 유닛·PID·리스너 변화 없음**(PID 3304850 / 3304871 그대로).
- heartbeat readback(05:32:59Z): servers.last_seen 갱신, agent_version 0.1.1, metrics_samples 5건, module_targets `arimail actual 1.0.11 health ok`(REVISION.json 없음 → package.json), 시계 차이 25,053ms(경고 기준 60초 미만 — 호스트 시각 확인 권장). `mail_services`(ibit.kr) `last_sync_source=agent`, health ok, observedVersion 1.0.11.
- hostinger: hal21용 Agent **설치하지 않음** — 기존 Agent 가 hostinger HAL 에 등록되어 있고 같은 유닛을 두 제어 평면이 배포하면 §34 "하나의 HAL" 위반. 서버 행은 pending(Agent 미연결), 메일 서비스 6건은 `last_sync_source=import`(관측 전용). 제어 평면 이전은 사람 결정.
- 서버 승인은 하지 않았다(첫 Agent 승인이 부트스트랩을 닫고 수동 설치를 잠근다 — 운영자 결정).
## 6. Data Plane 독립·회귀(적용 뒤)
- HAL 재시작 구간(05:28:5x) 동안 외부 프로브: `mail.arihakdang.com /api/health` 200 · `webmail.ibit.kr /api/health` 200 · IMAPS 993 `* OK` 연속 유지(12회, 실패 0). AriMail 유닛은 HAL 을 참조하지 않는다(EnvironmentFile·유닛 파일 sha256 변화 없음).
- 두 호스트 사후 점검: 유닛 active/enabled, NRestarts 0, 시작 시각·PID 변화 없음(hostinger 1046648/1046634, jitsi 3304850/3304871) · 리스너 동일 · `/api/health` {ok, 1.0.11, domains 6 / 1} · DB 계정/메일 수 동일(2+2+2+1+2+1 / 189+6, 3 / 0) · gateway.env·config.json·domains.json·유닛·nginx vhost·fullchain.pem sha256 변화 없음 · `nginx -t` 성공 · journal 60분 오류 0(hostinger) / 1(jitsi, 기존 Resend DKIM TXT 검증 실패 반복) · outbound_queue 0 · Haraka 비활성 그대로.
- 외부 프로토콜(05:33Z): 두 호스트 모두 http→https 301, https 200, 587 STARTTLS·465·993·995 인증서 일치(verify 0), IMAPS `* OK`·POP3S `+OK`·SMTPS `220` greeting, 25 닫힘, MX/A 변화 없음.
- hostinger HAL 제어 평면 변화 없음(`module_targets.arimail` 0.1.1 ok, Agent 1051076 active).
- HAL 외부: `/api/health` 200, `/status` 200, `/` → `/ops/` 302, `/ops/mail` 200(로그인 화면), `/ops/api/mail-services` 401(운영자 세션 필요), 인증서 `hal21.ariweb.net` 2027-01-25.
## 7. 테스트
`node test/run.mjs`: 21 파일 144 건 통과·실패 0(신규 `hal-mailimport` 7, `mail-discover` 2). `bash -n deploy/*.sh` 통과(shellcheck 미설치). `scripts/release.mjs build/verify` 통과.
## 8. 바꾸지 않은 것 · blocker · 미확인
- 바꾸지 않음: DNS/A/MX/TXT, 인증서, AriMail 코드·유닛·env·config·domains.json·DB·메일·계정, 리스너, Haraka 상태, hostinger HAL/포털/Agent, bear nginx. 테스트 메일 발송·임시 계정 생성 없음.
- blocker(사용자 조작 필요): (1) hal21 운영자 자격 증명 없음 → 운영자 API/콘솔 readback·서버 승인·접근권 발급은 수행하지 않았다(readback 은 HAL DB/CLI 로 수행). (2) hostinger 의 제어 평면(hal.ariweb.net ↔ hal21) 결정 전에는 hostinger Agent 를 hal21 에 등록하지 않는다. (3) 4개 E2E 테넌트(frozen)와 ariweb.net 의 소유 조직 매핑은 포털 그룹 대조 뒤 운영자가 지정.
- 미확인: jitsi 시계 차이 25초(원인 미조사), ibit.kr Resend DKIM TXT 미검증(기존).
@@ -0,0 +1,71 @@
# webmaker.ariweb.net ↔ HAL 연동 점검·dry-run 기록 (2026-10-27)
작업 승인: 오즈. 실행: Claude Fable 5.1(세션 ariweb-suite-8a). 저장소 기준 main `ac072e6` 에서 도구 추가(`scripts/webmaker-inventory.mjs`, `test/webmaker-inventory.test.mjs`). 비밀값(해시·토큰·키·세션)은 어디에도 출력·기록하지 않았다.
## 1. 실제 호스트 (DNS·운영 위치, 개발 저장소로 추정하지 않음)
| 항목 | 실제 값 |
|---|---|
| `webmaker.ariweb.net` A | 187.53.133.173 = hostinger(`srv1953374`, Ubuntu 24.04.4, Node v22.23.2, nginx 1.24.0) |
| 서비스 | systemd `webmaker.service`(User master, ExecStart `node server.js`, 127.0.0.1:3080) + `webmaker-setup.service` |
| 코드 | `/srv/webmaker/current → /srv/webmaker/releases/0.1.1`(프로세스 cwd 동일), `REVISION.json` = 모듈 0.1.1 / 상류 1.7.3, HAL 작업으로 2026-10-26 14:00:21Z 설치 — 즉 **이미 Ariweb Suite Webmaker(도입 모듈)** 이며 교체 대상이 아니다 |
| 데이터 | `DATA_ROOT=/data/webmaker`(0700, 691M) · `DATA_DIR=/data/webmaker/sites` · `SITES_ROOT=/var/www/webmaker-sites`(969M) · `STATS_LOG=/var/log/nginx/webmaker-sites.log` · 설정 `/data/webmaker/config.json`(값은 보관소) |
| 저장 구조 | JSON 파일(원자적 저장 + `.bak`): `users.json`(회원·scrypt 해시) · `sites/<id>.json`(프로젝트·배포 이력·git 변경) · `domains.json` · `deploy-targets.json` · `ariweb-links.json`(포털 연결 등록부) · `mail-servers.json` · `credits.json` 등. 로컬 git `/data/webmaker/git/<id>`(7개) → Gitea `127.0.0.1:3030`(hostinger 로컬, 저장소 `webmaker/sites`, 브랜치 `sites/<id>/main`) |
| HAL | hostinger 자체 HAL(`ariweb-hal`, 127.0.0.1:8900, nginx `hal.ariweb.net` — 공개 DNS 없음) 부트스트랩 완료, 서버 `srv_01a178829d5848f93a15f93a17`(control, ariweb.net) + Agent `ag_01a178829f1c3d88381d2c0c75` 활성, `module_targets.webmaker` 0.1.1/0.1.1 health ok |
| bear HAL(`hal21.ariweb.net`) | 운영자 1·서버 0·Agent 0·부트스트랩 미완료. 운영자 로그인 정보는 이 세션에 없다(피어 세션 ariweb-suite-36 도 만들지 않았다고 답함) |
## 2. inventory (원본, 안정 식별자)
| 자원 | 수 | 식별자·비고 |
|---|---|---|
| Webmaker 회원 | 12 | 고유 9(`oz-admin`, `admin`, `guest01`, `icqmomo`, `keytron`, `thewings`, `mingks01`, `visionplus`, `yunmi7266`) + 포털 SSO 3(`aw-01a17894-01a17894`, `aw-01a17895-01a17895`, `aw-01a1789f-01a1789f`) |
| 프로젝트(사이트) | 10 | 32-hex id. 배포 이력 있음 3(`291da329…` sample.ariweb.net 14회, `41ca22fd…` guide.ariweb.net 3회, `572a1d94…` hs-company 1회), 로컬 git 7, 포털 연결 3(검증 사이트) |
| 도메인(Webmaker 등록부) | 2 | sample.ariweb.net, guide.ariweb.net(모두 icqmomo, active). `hs-company.ariweb.net` 은 프로젝트 도메인 목록에만 있고 DNS 는 bear(121.152.149.45)를 가리킴 |
| 배포 대상 | 1 | `a7ae448c0593` 기본서버-자체호스팅(local, `/var/www/webmaker-sites`, `https://webmaker.ariweb.net`) |
| 메일 서버 | 0 | `mail-servers.json` items 비어 있음(메일 연동은 `config.mail.provider=arimail`, `https://webmail.ariweb.net`) |
| Gitea refs | 13 heads | `sites/<id>/main` 7개 + `changes/*` 5개 + `main` |
| 사이트 디렉터리 | 10 | 파일 수: 291da 13 · 41ca 16 · 4cbe 61 · 572a 31,780(961M) · 7e10 8 · b354 4 · cf7c 4 · 검증 3개 0 |
| nginx | 3 | `webmaker.ariweb.net.conf`, `wh-291da…conf`, `wh-41ca…conf` (+`wh-default.conf`) |
| 포털 DB | users 7 · groups 10 · sites 3 · domains 6 · mail_services 4 · deployments 0 | 사이트 3개 모두 `webmaker_project_id` 로 검증 프로젝트에 연결 |
## 3. 사전 스냅샷 (hostinger, 사설, 비밀 미출력)
위치 `/srv/webmaker/backups/pre-hal-link-20261027T045715Z/`(master 0700, 파일 0600). 내용: `data-webmaker.tar.gz`(708,460,310B) · `sites-root.tar.gz`(76,467,803B) · `checksums-source.txt`(원본 32,608 파일 sha256) · `conf/`(webmaker·webmaker-setup 유닛, nginx 3+1 파일, sites-enabled 목록, 유닛 상태) · `gitea-refs.txt`(13) · `local-git-heads.txt` · `hal.sqlite`·`portal.sqlite`(VACUUM INTO 일관 사본) · `MANIFEST.sha256` · `ROLLBACK.md` · `dry-run.json`.
MANIFEST.sha256 자체 sha256 앞 16자리 `b3d7d97e66ace583`, data tar `29d13e5846871a63`, sites tar `8690f9de6e1246fd`, checksums `8d01acf5721ac7ec`.
복원 가능성 검증: 두 tar 를 임시 디렉터리에 풀어 `checksums-source.txt` 32,608건 전부 대조 → `RESTORE_TEST_OK 32608 files`(04:58:32Z), 임시 디렉터리 삭제. 되돌리기 절차는 `ROLLBACK.md`(정지 → 현재 보존 → tar 복원 → 설정 복원 → 기동 → 체크섬·refs 대조).
## 4. 연동 경로 조사 (코드·테스트)
- HAL 은 사이트·계정을 모델링하지 않는다(테이블: servers·agents·modules·module_targets·releases·jobs…). Webmaker 의 "HAL 리소스" 는 모듈 등록(`module_targets`)뿐이며 이미 등록·건강 ok.
- 포털 ↔ Webmaker 연결은 양쪽 등록부(`ariweb-links.json` ↔ `sites.webmaker_project_id`)로만 이뤄지고, 기존 프로젝트 연결은 포털 사용자 세션의 `사이트 생성(projectMode=existing)` 경로뿐이다(`portal/sites/service.mjs`). Webmaker 내부 API `/api/internal/ariweb/*` 는 포털 서비스 서명 필요. "콘텐츠 가져오기" 는 이번 버전 미지원(`portal/sites/ui.mjs`).
- 비밀 입력 없이 쓸 수 있는 경로: 읽기 전용 inventory/dry-run(`scripts/webmaker-inventory.mjs`)뿐. 고유 회원 9명을 포털 계정으로 만드는 일은 사용자 자격 증명·동의가 필요해 하지 않았다(해시·세션 재사용 금지).
## 5. dry-run 결과 (hostinger 실데이터, 읽기 전용, `dry-run.json` sha256 `e94116e71a5c1a81…`)
| 판정 | 수 |
|---|---|
| linked_consistent(양쪽 등록부 일치) | 3 (`85e1859a…`→`s_01a1789f…`, `899f5bbe…`→`s_01a17895…`, `ad015867…`→`s_01a17894…`) |
| unlinked_native(Webmaker 고유, 변경 대상 아님) | 7 |
| 충돌(link_mismatch·duplicate·한쪽만·SSO 신원 누락·도메인 불일치·HAL 버전/건강) | **0** |
| 누락(missing_project·Gitea ref·HAL 등록) | **0** (Gitea `sites/<id>/main` 7/7, 마지막 푸시 커밋 일치) |
| SSO 회원 ↔ 포털 그룹·사용자 | 3/3 일치, 고유 회원 9 = 포털 신원 없음(keep) |
| HAL 모듈 | registered, server `srv_01a178829d…`, target 0.1.1 = actual 0.1.1 = 설치 0.1.1(상류 1.7.3), health ok, Agent online |
| applyPlan | **no-op — 적용할 변경 없음** |
## 6. 적용 (5단계)
적용한 변경 **없음**. 교체 불필요(이미 Ariweb Suite Webmaker 모듈 0.1.1). 운영 Webmaker 의 회원·프로젝트·도메인·배포·메일 연결·Gitea·설정·세션은 바이트 단위로 보존(아래 7).
## 7. 사후 검증 (05:01~05:03Z, 모두 readback)
- 콘텐츠 체크섬: `/data/webmaker` + `/var/www/webmaker-sites` 32,608 파일 `sha256sum -c` 불일치 0.
- git fsck 로컬 7개 저장소 오류 0 · Gitea `ls-remote --heads` 13건 스냅샷과 동일.
- `webmaker.service` active, MainPID 1041361, NRestarts 0, ActiveEnter 2026-10-26 14:00:23Z(스냅샷 전후 동일), 최근 2시간 err 저널 0. ariweb-hal/portal/agent, arihakdang-mail, nginx, webmaker-setup 모두 active·재시작 0.
- nginx `-t` 성공, 유닛·vhost 4개 sha256 스냅샷과 동일. TLS: webmaker.ariweb.net CN 전용(2027-01-12), sample/guide `*.ariweb.net`(2027-01-13).
- 외부 HTTP: `https://webmaker.ariweb.net/` 302→`/login`(200, 제목 "로그인 — AI Webmaker" — 화면·세션 호환, 자격 증명 미사용) · sample.ariweb.net `/`,`/en/`,`/ja/` 200 · guide.ariweb.net `/`,`/en/` 200 · webmaker.ariweb.net 미리보기 경로 3개(`/291da…/`, `/41ca…/`, `/572a…/`) 200.
- HAL readback: `module_targets.webmaker` 0.1.1/0.1.1 health ok last_check 05:01:44Z. `session.secret`·`users.json` mtime 변동 없음.
## 8. 미해결 blocker (사람 결정 필요)
1. bear HAL(`hal21.ariweb.net`)에 hostinger 를 서버로 등록하거나 Agent 를 재등록하려면 hal21 운영자 로그인(등록 토큰 발급)이 필요하다 — 이 세션에는 자격 증명이 없고, 운영자 1건은 누가 만들었는지 확인되지 않았다. 또한 hostinger Agent 는 hostinger HAL 에 등록돼 있어 제어 평면을 hal21 로 옮기는 것은 별도 결정·절차(§34 "하나의 HAL")가 필요하다.
2. Webmaker 고유 회원 9명·고유 프로젝트 7개를 포털 자원(계정·그룹·사이트)으로 옮기는 기능은 없고, 만들려면 각 사용자의 자격 증명·동의가 필요하다.
+8
View File
@@ -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)
@@ -58,6 +59,7 @@ export function buildApp(d) {
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(404, 'artifact_not_found', 'artifact 가 없습니다'); ctx.ended = true; ctx.res.writeHead(200, { 'Content-Type': 'application/gzip', 'Content-Length': a.size, 'X-Sha256': a.sha256, 'Cache-Control': 'no-store' }); createReadStream(a.path).pipe(ctx.res); }));
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
app.get('/agent/v1/jobs/unfinished', wrap((ctx) => ({ ok: true, jobs: core.unfinishedAgentJobs(d, ctx.agent.id) })));
app.post('/agent/v1/jobs/:id/progress', wrap(async (ctx) => { const b = await ctx.body(); const ok = core.agentJobProgress(d, ctx.params.id, ctx.agent.id, b); return { ok, cancelRequested: core.isAgentJobCancelled(d, ctx.params.id) }; }));
@@ -128,6 +130,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) }; }));
+36 -2
View File
@@ -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'];
@@ -135,19 +136,52 @@ export function externalAgentStatus(d, id) {
lastDeploy: last ? { id: last.id, status: last.status, at: last.ended_at || last.started_at || last.created_at, result: parseJson(last.result_json, null), error: last.error } : null,
lastCheck: lastCheck ? { at: lastCheck.ended_at, result: parseJson(lastCheck.result_json, null) } : null };
}
/** Webmaker 인벤토리를 2 MiB HTTP 한도 아래의 순차 배치로 받아 마지막 배치에서만 공개 facts에 원자 반영한다. */
export function webmakerInventoryBatch(d, agent, body = {}) {
if (!agent.server_id || agent.kind !== 'managed') throw new JobError('inventory_not_allowed', '관리 서버 Agent만 인벤토리를 제출할 수 있습니다');
const scanId = String(body.scanId || ''); const offset = Number(body.offset); const itemTotal = Number(body.itemTotal); const sites = body.sites;
if (!scanId || scanId.length > 100 || !Number.isInteger(offset) || offset < 0 || !Number.isInteger(itemTotal) || itemTotal < 0 || itemTotal > 10000 || !Array.isArray(sites) || sites.length > 1000) throw new JobError('bad_inventory_batch', '인벤토리 배치 형식이 올바르지 않습니다');
const safeText = (v, n) => typeof v === 'string' ? v.replace(/[\u0000-\u001f\u007f]/g, '').slice(0, n) : null;
const safeCount = (v) => Number.isSafeInteger(Number(v)) && Number(v) >= 0 ? Number(v) : 0;
const states = new Set(['deployed', 'unpublished', 'draft', 'unknown']); const metadataStates = new Set(['ok', 'missing', 'invalid']);
const cleanSites = sites.map((site) => {
if (!site || typeof site !== 'object' || Array.isArray(site) || !/^[a-f0-9]{32}$/.test(String(site.id || ''))) throw new JobError('bad_inventory_batch', '사이트 항목 형식이 올바르지 않습니다');
const deploymentState = states.has(site.deploymentState) ? site.deploymentState : 'unknown'; const metadata = metadataStates.has(site.metadata) ? site.metadata : 'missing';
const domains = Array.isArray(site.domains) ? [...new Set(site.domains.map((x) => safeText(x, 253)?.toLowerCase()).filter(Boolean))].slice(0, 100) : [];
return { id: site.id, title: safeText(site.title, 200), ownerId: safeText(site.ownerId, 128), status: safeText(site.status, 80), deploymentState, domains, updatedAt: safeText(site.updatedAt, 40), metadata, bytes: safeCount(site.bytes), files: safeCount(site.files) };
});
const metaIn = body.meta && typeof body.meta === 'object' && !Array.isArray(body.meta) ? body.meta : {}; const safeTotal = Number(metaIn.total); const meta = { dataRoot: safeText(metaIn.dataRoot, 4096), dataDir: safeText(metaIn.dataDir, 4096), sitesRoot: safeText(metaIn.sitesRoot, 4096), total: Number.isSafeInteger(safeTotal) && safeTotal >= 0 && safeTotal <= 10000 ? safeTotal : itemTotal, truncated: !!metaIn.truncated, errors: Array.isArray(metaIn.errors) ? metaIn.errors.slice(0, 1000).map((x) => safeText(x, 500)).filter(Boolean) : [], errorsTruncated: !!metaIn.errorsTruncated, scannedAt: safeText(metaIn.scannedAt, 100) };
if (meta.total !== itemTotal || meta.truncated) throw new JobError('inventory_incomplete', '완전한 Webmaker 인벤토리만 반영할 수 있습니다');
return d.store.tx(() => {
d.store.run('DELETE FROM agent_inventory_staging WHERE updated_at < ?', new Date(Date.now() - 24 * 3600 * 1000).toISOString());
let row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
if (offset === 0) { d.store.run('INSERT INTO agent_inventory_staging(agent_id, kind, scan_id, total, next_offset, meta_json, items_json, updated_at) VALUES(?,?,?,?,0,?,?,?) ON CONFLICT(agent_id, kind) DO UPDATE SET scan_id=excluded.scan_id,total=excluded.total,next_offset=0,meta_json=excluded.meta_json,items_json=\'[]\',updated_at=excluded.updated_at', agent.id, 'webmaker', scanId, itemTotal, toJson(meta), '[]', nowIso()); row = d.store.get('SELECT * FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker'); }
if (!row || row.scan_id !== scanId || row.total !== itemTotal || row.next_offset !== offset) throw new JobError('inventory_batch_order', '인벤토리 배치 순서가 맞지 않습니다 — 처음부터 다시 보내세요');
const items = parseJson(row.items_json, []); items.push(...cleanSites); if (items.length > itemTotal || offset + cleanSites.length !== items.length) throw new JobError('bad_inventory_batch', '인벤토리 항목 수가 선언값과 다릅니다'); const itemsJson = toJson(items); if (Buffer.byteLength(itemsJson) > 16 * 1024 * 1024) throw new JobError('inventory_too_large', '인벤토리 전체 크기가 안전 한도를 넘습니다');
const complete = items.length === itemTotal; d.store.run('UPDATE agent_inventory_staging SET next_offset=?, items_json=?, updated_at=? WHERE agent_id=? AND kind=?', items.length, itemsJson, nowIso(), agent.id, 'webmaker');
if (!complete) return { ok: true, complete: false, nextOffset: items.length };
const inventory = { ...parseJson(row.meta_json, {}), returned: items.length, sites: items }; const aFacts = parseJson(d.store.get('SELECT facts_json FROM agents WHERE id=?', agent.id)?.facts_json, {}) || {}; aFacts.webmakerInventory = inventory;
const sFacts = parseJson(d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id)?.facts_json, {}) || {}; sFacts.webmakerInventory = inventory;
d.store.run('UPDATE agents SET facts_json=? WHERE id=?', toJson(aFacts), agent.id); d.store.run('UPDATE servers SET facts_json=?, updated_at=? WHERE id=?', toJson(sFacts), nowIso(), agent.server_id); d.store.run('DELETE FROM agent_inventory_staging WHERE agent_id=? AND kind=?', agent.id, 'webmaker');
return { ok: true, complete: true, nextOffset: items.length };
});
}
/** heartbeat 처리: 지표 저장, 서버 last_seen·시계 차이·모듈 실제 버전·용량 갱신 */
export function heartbeat(d, agent, body) {
const now = nowIso(); const m = body.metrics || {}; const skew = Number.isFinite(body.clockSkewMs) ? Math.round(body.clockSkewMs) : null;
d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, body.facts ? toJson(redact(body.facts)) : null, agent.id);
const mergeFacts = (currentText) => { if (!body.facts) return null; const current = parseJson(currentText, {}) || {}; const incoming = redact(body.facts); if (!Object.hasOwn(incoming, 'webmakerInventory') && current.webmakerInventory) incoming.webmakerInventory = current.webmakerInventory; return toJson(incoming); };
const agentFacts = mergeFacts(agent.facts_json); d.store.run('UPDATE agents SET last_seen_at=?, version=?, facts_json=COALESCE(?, facts_json) WHERE id=?', now, body.version || agent.version, agentFacts, agent.id);
if (!agent.server_id) return { ok: true };
const capacity = { diskFreeBytes: m.disk ? m.disk.total - m.disk.used : 0, diskTotalBytes: m.disk?.total || 0, memFreeBytes: m.mem ? m.mem.total - m.mem.used : 0, cpuLoad: m.load1 || 0, sitesHosted: body.sitesHosted || 0, reservedBytes: body.reservedBytes || 0, exhaustEta: body.exhaustEta || null };
d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), body.facts ? toJson(redact(body.facts)) : null, now, agent.server_id);
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
for (const [name, mod] of Object.entries(body.modules || {})) {
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 (_) {} }
+23
View File
@@ -7,6 +7,11 @@
* node hal/index.mjs register-release --manifest <dist/<ver>/manifest.json> [--approve] 릴리스 등록(부트스트랩·CI)
* node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회)
* node hal/index.mjs bootstrap-release --version <v> --commit <sha> --sha256 <artifact sha> 부트스트랩 때 선택한 릴리스 정보를 기록
* node hal/index.mjs server-token --server <srv_id> [--by <who>] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록)
* node hal/index.mjs mail-import --discovery <json> (--server <srv_id> | --create-server --name <n> [--location <l>]) [--apply] [--by <who>]
* 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs)
* node hal/index.mjs mail-import-rollback --import <mimp_id> [--by <who>] 적용된 가져오기 되돌리기
* 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:<by>).
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); 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 <json> 이 필요합니다'); 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 <srv_id> 또는 --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); }
+197
View File
@@ -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:<hostname>:<unit>`), 도메인별 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;
}
+19
View File
@@ -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,22 @@ 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,
// Webmaker 전체 인벤토리는 heartbeat 2 MiB 한도를 넘을 수 있어 인증된 배치로 원자적으로 조립한다.
{ id: 'hal_0004_webmaker_inventory_stage', sql: `
CREATE TABLE IF NOT EXISTS agent_inventory_staging (
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE, kind TEXT NOT NULL, scan_id TEXT NOT NULL, total INTEGER NOT NULL, next_offset INTEGER NOT NULL DEFAULT 0,
meta_json TEXT NOT NULL, items_json TEXT NOT NULL DEFAULT '[]', updated_at TEXT NOT NULL,
PRIMARY KEY(agent_id, kind)
);
` },
// 사전 릴리스에서 FK 없이 같은 staging 표를 만든 경우에도 고아 row를 정리하고 이후 삭제를 보장한다.
{ id: 'hal_0005_webmaker_inventory_cleanup', sql: `
DELETE FROM agent_inventory_staging WHERE agent_id NOT IN (SELECT id FROM agents);
CREATE TRIGGER IF NOT EXISTS agent_inventory_staging_agent_cleanup
AFTER DELETE ON agents BEGIN
DELETE FROM agent_inventory_staging WHERE agent_id=OLD.id;
END;
` },
];
+20 -1
View File
@@ -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: `<h2>HAL 관리형 메일 서비스(기존 AriMail 인스턴스 가져오기)</h2>${mailServicesView(mailsvc.readback(d))}<h2>포털 메일 서비스</h2><div id="portal-mail">${R.statePanel('loading', '포털에서 불러오는 중')}</div>` }));
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: `<div class="actions"><button class="btn primary" data-act="server-new">서버 등록</button></div>${R.serversTable(core.listServers(d))}<p class="muted">등록 절차: 역할·위치 입력 → 일회용 등록 정보(10분, 1회) → 서버에서 Node Agent 설치 → 연결·신원 확인 → 기능·용량 확인 → 운영자 승인 → 배치 가능. 등록 정보는 다시 표시하지 않습니다(§28.2).</p>` }));
@@ -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(`<div class="alert critical">⚠ 도메인 active owner 중복: ${esc(rb.duplicates.join(', '))}</div>`); if (rb.secretLeaks) warn.push(`<div class="alert critical">⚠ 등록부에 비밀 형태의 값 ${rb.secretLeaks}건 — 즉시 확인</div>`);
const t = R.table([
{ l: '도메인', f: (s) => `<b>${esc(s.domain)}</b><br><span class="muted">${esc(s.kind)} · ${esc(s.ownership)}${s.ownerGroupId ? ' · ' + esc(s.ownerGroupId) : ''}</span>` },
{ l: '공개 주소', f: (s) => s.webUrl ? `<a href="${esc(s.webUrl)}" rel="noopener" target="_blank">${esc(s.publicHost)}</a>` : '—' },
{ l: '서버·인스턴스', f: (s) => `<a href="/ops/servers/${esc(s.serverId)}">${esc(s.serverName || s.serverId)}</a><br><span class="muted mono">${esc(s.instance.unit || '')} · ${esc(s.instance.appDir || '')}</span>` },
{ l: '리비전', f: (s) => `${esc(s.instance.revision?.version || '—')} <span class="muted">(AriMail ${esc(s.instance.revision?.upstreamVersion || s.instance.packageVersion || '—')})</span>${s.instance.observedVersion ? `<br><span class="muted">Agent 관측 ${esc(s.instance.observedVersion)}</span>` : ''}` },
{ 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')}<br><span class="muted">${esc(s.lastSyncSource || '—')} · ${esc(R.ago(s.lastSyncAt))}${s.agentId ? '' : ' · Agent 없음(관측 전용)'}</span>` },
{ l: 'TLS·DNS', f: (s) => `${s.evidence.tls?.validTo ? '인증서 ' + R.fmt(s.evidence.tls.validTo) : '인증서 정보 없음'}<br><span class="muted">${esc((s.evidence.dns && s.evidence.dns[s.domain] && (s.evidence.dns[s.domain].mx || []).join(', ')) || 'MX 정보 없음')}</span>` },
{ l: '비밀', f: (s) => `<span class="muted">참조 ${(s.secretRefs || []).length}건(값 없음)</span>` },
], rb.services, { empty: 'HAL 관리형 메일 서비스가 없습니다 — node hal/index.mjs mail-import 또는 운영자 API /ops/api/mail-services/import 로 가져옵니다' });
return warn.join('') + t + `<p class="muted">총 ${rb.counts.total} · active ${rb.counts.active} · 도메인 ${rb.counts.domains} · 서버 ${Object.keys(rb.perServer).length}. 기존 AriMail 의 송수신·계정·메일·DNS/MX·TLS 는 HAL 이 바꾸지 않습니다(등록·감시·통제용 등록부).</p>`;
}
+1 -1
View File
@@ -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); } },
+12 -1
View File
@@ -55,10 +55,21 @@ export function serversTable(servers) {
], servers, { empty: '등록된 서버가 없습니다' });
}
export function serverDetail(s, { jobs = [], agentJobs = [] } = {}) {
const webmakerData = s.facts?.webmakerInventory || null; const webmakerSites = Array.isArray(webmakerData?.sites) ? webmakerData.sites.filter((x) => x && typeof x === 'object' && !Array.isArray(x)) : []; const inventoryErrors = Array.isArray(webmakerData?.errors) ? webmakerData.errors : [];
const webmakerInventory = webmakerData ? `<h2>Webmaker 사이트 디렉터리 인벤토리</h2><p class="muted">Node Agent가 사이트 디렉터리와 Webmaker 메타데이터·배포 이력을 대조한 운영 인벤토리입니다. 전체 ${Number(webmakerData.total || 0).toLocaleString('ko-KR')}개 · 수집 ${Number(webmakerData.returned || 0).toLocaleString('ko-KR')}개${webmakerData.truncated ? ' · 일부만 표시됨' : ''}. 페이지 본문·프롬프트·연락처·인증정보는 HAL로 보내지 않습니다.</p>${inventoryErrors.length || webmakerData.errorsTruncated ? statePanel('failed', `인벤토리 스캔 오류 ${inventoryErrors.length}건${webmakerData.errorsTruncated ? ' 이상(전송 한도에 따라 일부 오류만 표시)' : ''}: ${inventoryErrors.join(', ')}`) : ''}${table([
{ l: '사이트', f: (x) => `<span class="mono">${esc(x.id)}</span><br><b>${esc(x.title || '제목 정보 없음')}</b>` },
{ l: '계정 연결', f: (x) => esc(x.ownerId || '메타데이터 없음') },
{ l: '배포 상태', f: (x) => badge(x.deploymentState === 'deployed' ? 'ok' : x.deploymentState === 'unknown' ? 'warn' : 'neutral', x.deploymentState || 'unknown') },
{ l: '서비스 상태', f: (x) => esc(x.status || '—') },
{ l: '도메인', f: (x) => (Array.isArray(x.domains) ? x.domains : []).map(esc).join('<br>') || '—' },
{ l: '크기·파일', f: (x) => `${bytes(x.bytes || 0)} · ${Number(x.files || 0).toLocaleString('ko-KR')}개` },
{ l: '최근 변경', f: (x) => fmt(x.updatedAt) },
{ l: '메타데이터', f: (x) => badge(x.metadata === 'ok' ? 'ok' : 'warn', x.metadata || 'unknown') },
], webmakerSites, { empty: '사이트 디렉터리가 없습니다' })}` : '';
return `<div class="cards"><div class="card"><h2>연결·역할</h2>${kv([['역할·위치', `${esc(s.role)} · ${esc(s.location || '—')}`], ['연결 상태', badge(s.status === 'ready' ? 'ok' : s.status === 'offline' ? 'failed' : 'warn', SERVER_LABEL[s.status] || s.status)], ['마지막 heartbeat', `${fmt(s.lastSeenAt)} (${esc(ago(s.lastSeenAt))})`], ['Agent', `<span class="mono">${esc(s.agentId || '미연결')}</span> ${esc(s.agentVersion || '')}`], ['시계 차이', s.clockSkewMs == null ? '—' : `${s.clockSkewMs}ms`], ['승인', s.approvedAt ? fmt(s.approvedAt) : badge('waiting', '승인 대기')], ['신규 배치', s.acceptsNew ? '허용' : '중지'], ['유지보수·격리', esc(s.maintenanceNote || s.isolatedReason || '—')]])}</div>
<div class="card"><h2>용량</h2>${kv([['디스크', s.capacity.diskTotalBytes ? `${bytes(s.capacity.diskTotalBytes - s.capacity.diskFreeBytes)} / ${bytes(s.capacity.diskTotalBytes)} (여유 ${bytes(s.capacity.diskFreeBytes)})` : '정보 없음'], ['예약량', bytes(s.capacity.reservedBytes)], ['안전 여유', bytes(s.capacity.safetyMarginBytes)], ['배치 서비스', `${s.capacity.sitesHosted || 0}개`], ['고갈 예상', s.capacity.exhaustEta ? fmt(s.capacity.exhaustEta) : '예상 없음'], ['백업 저장소', s.facts && s.facts.backupStore ? `${bytes(s.facts.backupStore.usedBytes)} / ${bytes(s.facts.backupStore.totalBytes)} · 최근 검증 ${fmt(s.facts.backupStore.lastVerifiedAt)}` : '해당 없음']])}</div>
<div class="card"><h2>모듈(목표 vs 실제)</h2>${table([{ l: '모듈', k: 'name' }, { l: '목표', k: 'target' }, { l: '실제', f: (m) => m.drift ? `<span class="err">${esc(m.actual || '없음')}</span>` : esc(m.actual || '—') }, { l: '건강', f: (m) => healthBadge(m.health) }, { l: '마지막 확인', f: (m) => fmt(m.lastCheckAt) }, { l: '오류', f: (m) => `<span class="err">${esc(m.lastError || '')}</span>` }], Object.entries(s.modules || {}).map(([name, m]) => ({ name, ...m })), { empty: '배치된 모듈이 없습니다' })}</div></div>
<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
${webmakerInventory}<h2>대기열·반복 실패</h2>${agentJobsTable(agentJobs)}<h2>이 서버의 작업</h2>${table([{ l: '작업', f: (j) => `<a href="/ops/jobs/${esc(j.id)}">${esc(j.label)}</a>` }, { l: '상태', f: (j) => jobBadge(j.status) }, { l: '갱신', f: (j) => fmt(j.updatedAt) }], jobs)}`;
}
/** 90일 지표 SVG(§28.1). samples 는 최신순. key: 'cpu'|'disk'|'mem'|'net'. */
export function metricsSvg(samples, key = 'cpu', { w = 720, h = 160 } = {}) {
+127
View File
@@ -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 <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);
}
+146
View File
@@ -0,0 +1,146 @@
/**
* scripts/webmaker-inventory.mjs — 운영 Webmaker ↔ 포털 ↔ HAL 연동 inventory 와 dry-run(읽기 전용).
*
* node scripts/webmaker-inventory.mjs --webmaker-data /data/webmaker [--sites-root /var/www/webmaker-sites]
* [--portal-db /srv/ariweb/portal/data/portal.sqlite] [--hal-db /srv/ariweb/hal/data/hal.sqlite]
* [--app-dir /srv/webmaker/current] [--nginx-enabled /etc/nginx/sites-enabled] [--gitea-refs <git ls-remote --heads 출력 파일>] [--out <json>]
*
* 무엇을 하나: 세 저장소(Webmaker JSON·포털 DB·HAL DB)를 **읽기만** 하고 안정 식별자로 대조해 연동 판정을 낸다.
* - 정본(source of truth): Webmaker 프로젝트·회원·도메인·배포 이력은 Webmaker 데이터(`data/*.json`)가 정본이다. 포털은 "연결 등록부"
* (Webmaker `ariweb-links.json` ↔ 포털 `sites.webmaker_project_id`)로 프로젝트를 자기 사이트에 연결할 뿐 프로젝트를 복제하지 않는다.
* HAL 은 사이트·계정을 모델링하지 않고 모듈(webmaker) 설치·버전·건강만 등록부(`module_targets`)에 둔다. 그래서 "HAL 리소스로 가져오기" 의
* 실체는 (1) HAL 모듈 등록(서버·버전·건강) (2) 포털 연결 등록부의 일치 확인이며, 그 밖의 복제는 없다. Data Plane(Webmaker 서비스·배포 사이트)은
* HAL 이 죽어도 그대로 동작한다(HAL 은 제어 평면이다).
* - 안정 식별자: Webmaker 프로젝트 id(32 hex, 변하지 않음) · 포털 site id(`s_…`) · group id(`g_…`) · HAL server id(`srv_…`).
* 포털 SSO 사용자의 Webmaker 로그인은 `aw-<group8>-<user8>` 이므로 접두어로 포털 그룹·사용자와 대조한다.
* - 판정: linked_consistent(양쪽 등록부 일치) · link_portal_only / link_webmaker_only(한쪽만 — 충돌) · link_mismatch(다른 사이트를 가리킴 — 충돌)
* · duplicate(같은 프로젝트를 둘 이상의 사이트가 가리킴 — 충돌) · missing_project(포털이 없는 프로젝트를 가리킴 — 누락) · unlinked_native(포털 연결이
* 없는 Webmaker 고유 프로젝트 — 변경 대상 아님, 가져오려면 포털 계정이 있어야 하므로 사람 결정 필요).
* - 비밀값(해시·salt·토큰·키·세션)은 읽지도 출력하지도 않는다. 이메일은 앞 2글자만 남긴다.
*/
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
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 readJson = (f, fb = null) => { try { return JSON.parse(readFileSync(f, 'utf8')); } catch (_) { return fb; } };
const maskEmail = (e) => (typeof e === 'string' && e.includes('@') ? e.slice(0, 2) + '…@' + e.split('@')[1] : null);
const SECRET = /pass|hash|salt|secret|token|key|session|cookie|credential/i;
const countFiles = (dir) => { let n = 0; const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { if (e.isDirectory()) walk(join(d, e.name)); else if (e.isFile()) n++; } }; try { walk(dir); } catch (_) { return null; } return n; };
/** Webmaker 데이터 루트에서 비밀 없는 inventory 를 읽는다. */
export function readWebmaker(dataRoot, { sitesRoot = null, nginxEnabled = null, appDir = null } = {}) {
const usersRaw = readJson(join(dataRoot, 'users.json'), {});
const users = Object.values(usersRaw).filter((u) => u && u.id).map((u) => ({ id: u.id, name: u.name, role: u.role || 'user', accountType: u.accountType || 'domain', sso: u.sso || null, createdAt: u.createdAt || null, lastLoginAt: u.lastLoginAt || null }));
const links = (readJson(join(dataRoot, 'ariweb-links.json'), { links: {} }) || {}).links || {};
const sitesDir = join(dataRoot, 'sites'); const projects = [];
if (existsSync(sitesDir)) for (const f of readdirSync(sitesDir).filter((x) => /^[0-9a-f]{32}\.json$/.test(x)).sort()) {
const s = readJson(join(sitesDir, f)); if (!s || !s.id) continue;
const deployments = Array.isArray(s.deployments) ? s.deployments : []; const last = deployments[deployments.length - 1] || null;
const changes = (s.git && Array.isArray(s.git.changes)) ? s.git.changes : []; const pushed = changes.filter((c) => c.pushed).slice(-1)[0] || null;
const vhost = nginxEnabled ? existsSync(join(nginxEnabled, `wh-${s.id}.conf`)) : null;
projects.push({ id: s.id, title: String(s.title || '').slice(0, 60), ownerId: s.ownerId || null, status: s.status || null, mode: s.mode || null, createdAt: s.createdAt || null, updatedAt: s.updatedAt || null,
domains: Array.isArray(s.domains) ? s.domains.map((d) => (typeof d === 'string' ? d : d.name || d.domain)).filter(Boolean) : [],
deployments: deployments.length, lastDeploy: last ? { target: last.targetId || last.target || null, at: last.at || last.deployedAt || null, url: last.url || last.publicUrl || null } : null,
git: { changes: changes.length, lastPushed: pushed ? { no: pushed.no, kind: pushed.kind, commit: pushed.commit, branch: pushed.branch } : null },
langs: s.langs && Array.isArray(s.langs.list) ? s.langs.list : null, unpublishedAt: s.unpublishedAt || null,
siteDirFiles: sitesRoot ? countFiles(join(sitesRoot, s.id)) : null, vhost,
link: links[s.id] ? { siteId: links[s.id].siteId, groupId: links[s.id].groupId, siteName: links[s.id].siteName, linkedAt: links[s.id].linkedAt } : null });
}
const domainsRaw = readJson(join(dataRoot, 'domains.json'), { domains: [] }) || {};
const domains = (Array.isArray(domainsRaw) ? domainsRaw : domainsRaw.domains || []).map((d) => ({ name: d.name || d.domain, siteId: d.siteId || null, ownerId: d.ownerId || d.userId || null, status: d.status || null }));
const dt = readJson(join(dataRoot, 'deploy-targets.json'), { defaultId: null, targets: [] }) || {};
const deployTargets = (dt.targets || []).map((t) => ({ id: t.id, name: t.name, type: t.type, baseDir: t.baseDir || null, publicBaseUrl: t.publicBaseUrl || null, isDefault: t.id === dt.defaultId }));
const ms = readJson(join(dataRoot, 'mail-servers.json'), { items: [] }) || {}; const msItems = Array.isArray(ms.items) ? ms.items : Object.values(ms.items || {});
const mailServers = msItems.map((m) => ({ id: m.id, name: m.name || null, provider: m.provider || m.kind || m.type || null, isDefault: m.id === ms.defaultId }));
const linkEntries = Object.entries(links).map(([projectId, l]) => ({ projectId, siteId: l.siteId, groupId: l.groupId }));
const rev = appDir ? (readJson(join(appDir, 'REVISION.json')) || null) : null; // HAL 이 설치한 모듈은 앱 디렉터리(current/)에 REVISION.json 을 둔다
const config = readJson(join(dataRoot, 'config.json'), {}) || {}; const v = config.values || {};
const out = { users, projects, domains, deployTargets, mailServers, links: linkEntries,
config: { publicBaseUrl: v.service?.publicBaseUrl || null, sourceProvider: v.source?.provider || null, mailProvider: v.mail?.provider || null, portalUrl: v.ariweb?.portalUrl || null }, revision: rev };
assertNoSecrets(out); return out;
}
/** 포털 DB(읽기 전용) inventory. */
export function readPortal(dbFile) {
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
const out = {
users: all('SELECT id, email, display_name, status, is_operator, created_at FROM users').map((u) => ({ id: u.id, email: maskEmail(u.email), displayName: u.display_name, status: u.status, isOperator: !!u.is_operator, createdAt: u.created_at })),
groups: all('SELECT id, name, kind, owner_user_id, status FROM groups').map((g) => ({ id: g.id, name: g.name, kind: g.kind, ownerUserId: g.owner_user_id, status: g.status })),
sites: all('SELECT id, group_id, name, webmaker_project_id, lifecycle, deploy_mode, primary_domain, server_id, current_version FROM sites').map((s) => ({ id: s.id, groupId: s.group_id, name: s.name, webmakerProjectId: s.webmaker_project_id, lifecycle: s.lifecycle, deployMode: s.deploy_mode, primaryDomain: s.primary_domain, serverId: s.server_id, currentVersion: s.current_version })),
domains: all('SELECT id, group_id, site_id, name, purpose, is_primary, ownership_state, dns_state FROM domains').map((x) => ({ id: x.id, groupId: x.group_id, siteId: x.site_id, name: x.name, purpose: x.purpose, isPrimary: !!x.is_primary, ownershipState: x.ownership_state, dnsState: x.dns_state })),
mailServices: all('SELECT id, group_id, site_id, kind, domain, status FROM mail_services').map((m) => ({ id: m.id, groupId: m.group_id, siteId: m.site_id, kind: m.kind, domain: m.domain, status: m.status })),
deployments: all('SELECT id, site_id, status, version_label, created_at FROM deployments').map((x) => ({ id: x.id, siteId: x.site_id, status: x.status, version: x.version_label, createdAt: x.created_at })),
};
d.close(); assertNoSecrets(out); return out;
}
/** HAL DB(읽기 전용) inventory — 서버·Agent·모듈 등록부·릴리스. */
export function readHal(dbFile) {
const { DatabaseSync } = requireSqlite(); const d = new DatabaseSync(dbFile, { readOnly: true }); const all = (sql) => d.prepare(sql).all();
const out = {
servers: all('SELECT id, name, role, location, status, agent_id, agent_version, last_seen_at FROM servers').map((s) => ({ id: s.id, name: s.name, role: s.role, location: s.location, status: s.status, agentId: s.agent_id, agentVersion: s.agent_version, lastSeenAt: s.last_seen_at })),
agents: all('SELECT id, server_id, kind, status, version, last_seen_at FROM agents').map((a) => ({ id: a.id, serverId: a.server_id, kind: a.kind, status: a.status, version: a.version, lastSeenAt: a.last_seen_at })),
modules: all('SELECT name, target_version FROM modules'),
moduleTargets: all('SELECT module, server_id, target_version, actual_version, health, active, last_check_at, last_error FROM module_targets').map((m) => ({ module: m.module, serverId: m.server_id, targetVersion: m.target_version, actualVersion: m.actual_version, health: m.health, active: !!m.active, lastCheckAt: m.last_check_at, lastError: m.last_error })),
releases: all('SELECT version, main_commit, status FROM releases').map((r) => ({ version: r.version, mainCommit: r.main_commit, status: r.status })),
bootstrap: (all('SELECT release_version, completed_at FROM bootstrap_state WHERE id=1')[0] || null),
opsAccounts: all('SELECT count(*) AS c FROM ops_accounts')[0].c,
};
d.close(); assertNoSecrets(out); return out;
}
/** dry-run: 세 inventory 를 안정 식별자로 대조해 판정과 충돌·누락·중복 목록을 만든다(아무것도 쓰지 않는다). */
export function dryRun({ webmaker, portal = null, hal = null, giteaRefs = null }) {
const findings = []; const add = (severity, kind, detail) => findings.push({ severity, kind, ...detail });
const portalSites = portal ? portal.sites : []; const byProject = new Map();
for (const s of portalSites) if (s.webmakerProjectId) { if (!byProject.has(s.webmakerProjectId)) byProject.set(s.webmakerProjectId, []); byProject.get(s.webmakerProjectId).push(s); }
const projects = webmaker.projects.map((p) => {
const ps = byProject.get(p.id) || []; let verdict;
if (ps.length > 1) { verdict = 'duplicate'; add('conflict', 'duplicate_portal_sites', { projectId: p.id, siteIds: ps.map((s) => s.id) }); }
else if (ps.length === 1 && p.link) { if (p.link.siteId === ps[0].id && p.link.groupId === ps[0].groupId) verdict = 'linked_consistent'; else { verdict = 'link_mismatch'; add('conflict', 'link_mismatch', { projectId: p.id, webmakerLink: p.link.siteId, portalSite: ps[0].id }); } }
else if (ps.length === 1 && !p.link) { verdict = 'link_portal_only'; add('conflict', 'link_portal_only', { projectId: p.id, portalSite: ps[0].id }); }
else if (!ps.length && p.link) { verdict = portal ? 'link_webmaker_only' : 'link_unverified'; if (portal) add('conflict', 'link_webmaker_only', { projectId: p.id, webmakerLink: p.link.siteId }); }
else verdict = 'unlinked_native';
return { projectId: p.id, ownerId: p.ownerId, verdict, portalSiteId: ps[0]?.id || null, portalGroupId: ps[0]?.groupId || null, domains: p.domains, deployed: p.deployments > 0, action: verdict === 'linked_consistent' ? 'none' : verdict === 'unlinked_native' ? 'keep (가져오기는 포털 계정 필요 — 사람 결정)' : 'human_review' };
});
const known = new Set(webmaker.projects.map((p) => p.id));
for (const s of portalSites) if (s.webmakerProjectId && !known.has(s.webmakerProjectId)) add('missing', 'missing_project', { siteId: s.id, projectId: s.webmakerProjectId });
// 회원: SSO 로그인(aw-<g8>-<u8>)은 포털 그룹·사용자 접두어와 대조, 고유 회원은 포털 신원 없음(변경 대상 아님)
const g8 = new Map((portal ? portal.groups : []).map((g) => [g.id.replace(/^g_/, '').slice(0, 8).toLowerCase(), g.id])); const u8 = new Map((portal ? portal.users : []).map((u) => [u.id.replace(/^u_/, '').slice(0, 8).toLowerCase(), u.id]));
const users = webmaker.users.map((u) => { const m = /^aw-([0-9a-f]{8})-([0-9a-f]{8})$/.exec(u.id); if (!m) return { userId: u.id, role: u.role, verdict: 'native_no_portal_identity', action: 'keep' }; const gid = g8.get(m[1]) || null, uid = u8.get(m[2]) || null; if (!portal) return { userId: u.id, role: u.role, verdict: 'sso_unverified', action: 'none' }; if (gid && uid) return { userId: u.id, role: u.role, verdict: 'sso_consistent', portalGroupId: gid, portalUserId: uid, action: 'none' }; add('conflict', 'sso_identity_missing', { userId: u.id, portalGroupId: gid, portalUserId: uid }); return { userId: u.id, role: u.role, verdict: 'sso_identity_missing', action: 'human_review' }; });
// 도메인: 이름으로 대조
const pd = new Map((portal ? portal.domains : []).map((d) => [d.name.toLowerCase(), d]));
const domains = webmaker.domains.map((d) => { const p = pd.get(String(d.name).toLowerCase()); if (!portal) return { name: d.name, siteId: d.siteId, verdict: 'unverified' }; if (!p) return { name: d.name, siteId: d.siteId, verdict: 'webmaker_only', action: 'keep' }; const site = projects.find((x) => x.projectId === d.siteId); if (site && site.portalSiteId && p.siteId === site.portalSiteId) return { name: d.name, siteId: d.siteId, verdict: 'consistent', portalDomainId: p.id }; add('conflict', 'domain_site_mismatch', { name: d.name, webmakerSite: d.siteId, portalSite: p.siteId }); return { name: d.name, siteId: d.siteId, verdict: 'mismatch', action: 'human_review' }; });
// Gitea refs: 프로젝트마다 sites/<id>/main 이 있고 마지막 푸시 커밋과 같은지
let gitea = null;
if (giteaRefs) { const refs = new Map(giteaRefs.split('\n').map((l) => l.trim().split(/\s+/)).filter((x) => x.length === 2).map(([sha, ref]) => [ref.replace(/^refs\/heads\//, ''), sha])); gitea = webmaker.projects.filter((p) => p.git.changes > 0).map((p) => { const main = refs.get(`sites/${p.id}/main`) || null; const lp = p.git.lastPushed; const expect = lp ? refs.get(lp.branch) || null : null; const verdict = !main ? 'main_missing' : lp && expect !== lp.commit ? 'last_push_mismatch' : 'ok'; if (verdict !== 'ok') add('missing', 'gitea_ref', { projectId: p.id, verdict, main, lastPushed: lp }); return { projectId: p.id, main, verdict }; }); }
// HAL: webmaker 모듈 등록(서버·버전·건강)과 설치 REVISION 대조
let halModule = null;
if (hal) { const mts = hal.moduleTargets.filter((m) => m.module === 'webmaker'); if (!mts.length) { add('missing', 'hal_module_target_missing', { module: 'webmaker' }); halModule = { registered: false }; } else { const rev = webmaker.revision; const t = mts[0]; const versionOk = !rev || rev.version === t.actualVersion; if (!versionOk) add('conflict', 'hal_version_mismatch', { installed: rev.version, halActual: t.actualVersion }); if (t.health !== 'ok') add('conflict', 'hal_health', { health: t.health, error: t.lastError }); halModule = { registered: true, serverId: t.serverId, targetVersion: t.targetVersion, actualVersion: t.actualVersion, installedVersion: rev ? rev.version : null, upstreamVersion: rev ? rev.upstreamVersion : null, health: t.health, lastCheckAt: t.lastCheckAt, agentOnline: hal.agents.some((a) => a.serverId === t.serverId && a.status === 'active') }; } }
const summary = {
webmaker: { users: webmaker.users.length, usersNative: users.filter((u) => u.verdict.startsWith('native')).length, usersSso: users.filter((u) => u.verdict.startsWith('sso')).length, projects: webmaker.projects.length, projectsDeployed: webmaker.projects.filter((p) => p.deployments > 0).length, domains: webmaker.domains.length, deployTargets: webmaker.deployTargets.length, mailServers: webmaker.mailServers.length, links: webmaker.links.length },
portal: portal ? { users: portal.users.length, groups: portal.groups.length, sites: portal.sites.length, sitesLinked: portalSites.filter((s) => s.webmakerProjectId).length, domains: portal.domains.length, mailServices: portal.mailServices.length } : null,
hal: hal ? { servers: hal.servers.length, agents: hal.agents.length, moduleTargets: hal.moduleTargets.length, opsAccounts: hal.opsAccounts, bootstrapComplete: !!(hal.bootstrap && hal.bootstrap.completed_at) } : null,
verdicts: countBy(projects.map((p) => p.verdict)), conflicts: findings.filter((f) => f.severity === 'conflict').length, missing: findings.filter((f) => f.severity === 'missing').length,
};
const out = { generatedAt: new Date().toISOString(), authority: { projects: 'webmaker', accounts: 'webmaker(native)/portal(sso)', links: 'webmaker ariweb-links.json ↔ portal sites.webmaker_project_id (both must agree)', module: 'hal module_targets' }, summary, projects, users, domains, gitea, halModule, findings, applyPlan: findings.length ? 'blocked: 충돌·누락이 0 이 될 때까지 적용하지 않는다' : 'no-op: 등록부가 이미 일치한다 — 적용할 변경 없음' };
assertNoSecrets(out); return out;
}
function countBy(list) { const o = {}; for (const x of list) o[x] = (o[x] || 0) + 1; return o; }
function requireSqlite() { return process.getBuiltinModule ? process.getBuiltinModule('node:sqlite') : null; }
/** 출력 객체 어디에도 비밀처럼 보이는 키가 없어야 한다(방어선). */
export function assertNoSecrets(o, path = '') { if (!o || typeof o !== 'object') return; for (const [k, v] of Object.entries(o)) { if (SECRET.test(k) && !/^(sessionGen|isDefault|lastCheckAt)$/.test(k)) throw new Error(`비밀로 보이는 키가 출력에 있습니다: ${path}${k}`); assertNoSecrets(v, `${path}${k}.`); } }
if (import.meta.url === `file://${process.argv[1]}`) {
const dataRoot = opt('webmaker-data'); if (!dataRoot) { console.error('--webmaker-data <Webmaker 데이터 루트> 가 필요합니다'); process.exit(1); }
const webmaker = readWebmaker(dataRoot, { sitesRoot: opt('sites-root'), nginxEnabled: opt('nginx-enabled'), appDir: opt('app-dir') });
const portal = opt('portal-db') ? readPortal(opt('portal-db')) : null; const hal = opt('hal-db') ? readHal(opt('hal-db')) : null;
const giteaRefs = opt('gitea-refs') ? readFileSync(opt('gitea-refs'), 'utf8') : null;
const result = { inventory: { webmaker, portal, hal }, dryRun: dryRun({ webmaker, portal, hal, giteaRefs }) };
const text = JSON.stringify(result, null, 1);
if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(opt('out'), text, { mode: 0o600 }); }
const s = result.dryRun.summary; console.log(JSON.stringify({ summary: s, halModule: result.dryRun.halModule, findings: result.dryRun.findings, applyPlan: result.dryRun.applyPlan }, null, 1));
process.exit(s.conflicts || s.missing ? 2 : 0);
}
+31 -2
View File
@@ -6,7 +6,8 @@ import { createRunner } from '../agent/runner.mjs';
import { KINDS } from '../agent/jobs/index.mjs';
import { ingestLog } from '../agent/jobs/site.mjs';
import { sample } from '../agent/metrics.mjs';
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync } from 'node:fs';
import { scanWebmakerInventory, webmakerInventoryBatches, configuredWebmakerInventory } from '../agent/webmaker-inventory.mjs';
import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, readlinkSync, copyFileSync, statSync, appendFileSync, chmodSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
@@ -32,6 +33,33 @@ function env(over = {}) {
/** 사이트 번들 디렉터리 만들기 */
function bundle(base, name, files = { 'index.html': '<h1>hello marker-1</h1>' }) { const d = join(base, 'bundles', name); mkdirSync(d, { recursive: true }); for (const [f, c] of Object.entries(files)) { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); } return d; }
await test('Webmaker 사이트 인벤토리: 모든 디렉터리·배포 상태·메타데이터를 비밀 없이 HAL에 전달', async () => {
const base = tmpDir(); const dataRoot = join(base, 'data'); const dataDir = join(base, 'custom-meta'); const sitesRoot = join(base, 'published');
mkdirSync(dataDir, { recursive: true }); for (const id of ['a', 'b', 'c', 'e'].map((x) => x.repeat(32))) { mkdirSync(join(sitesRoot, id), { recursive: true }); writeFileSync(join(sitesRoot, id, 'index.html'), id[0]); }
writeFileSync(join(dataDir, 'a'.repeat(32) + '.json'), JSON.stringify({ id: 'a'.repeat(32), title: '사이트 A', status: 'idle', ownerId: 'owner-a', domains: [{ name: 'a.example.test' }], deployments: [{ ok: true, kind: 'deploy' }], content: 'HAL에 보내면 안 되는 본문', commands: [{ prompt: '비밀' }] }));
writeFileSync(join(dataDir, 'b'.repeat(32) + '.json'), JSON.stringify({ id: 'b'.repeat(32), title: '초안', deployments: [] }));
writeFileSync(join(dataDir, 'c'.repeat(32) + '.json'), JSON.stringify({ id: 'c'.repeat(32), title: '중지', deployments: [{ ok: true, kind: 'deploy' }, { ok: true, kind: 'unpublish' }], unpublishedAt: '2026-01-01T00:00:00Z' }));
writeFileSync(join(dataDir, 'd'.repeat(32) + '.json'), JSON.stringify({ id: 'd'.repeat(32), title: '배포 전 초안', deployments: [] }));
writeFileSync(join(dataDir, 'f'.repeat(32) + '.json'), JSON.stringify([]));
const waitingId = 'f'.repeat(31) + 'd'; const legacyId = 'f'.repeat(31) + 'e';
writeFileSync(join(dataDir, waitingId + '.json'), JSON.stringify({ title: 'DNS 대기', deployments: [{ ok: true, phase: 'dns-wait' }] }));
writeFileSync(join(dataDir, legacyId + '.json'), JSON.stringify({ title: '구형 배포 기록', deployments: [{ ok: true }] }));
writeFileSync(join(dataDir, 'A'.repeat(32) + '.json'), JSON.stringify({ title: '대문자 ID는 비정상' }));
writeFileSync(join(dataDir, 'g'.repeat(32) + '.json.swp'), JSON.stringify({ title: '임시 파일은 제외' }));
const inv = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot });
eq(inv.total, 8); eq(inv.returned, 8); eq(inv.truncated, false); eq(inv.complete, true); eq(inv.sites[0].id, 'a'.repeat(32)); eq(inv.sites[0].title, '사이트 A'); eq(inv.sites[0].ownerId, 'owner-a'); eq(inv.sites[0].domains, ['a.example.test']); eq(inv.sites[0].deploymentState, 'deployed'); ok(inv.sites[0].bytes > 0);
eq(inv.sites[1].deploymentState, 'draft'); eq(inv.sites[2].deploymentState, 'unpublished'); eq(inv.sites[3].deploymentState, 'draft'); eq(inv.sites[3].files, 0, '메타데이터에만 있는 미배포 초안도 포함'); eq(inv.sites[4].metadata, 'missing', '배포 디렉터리에만 있는 사이트도 포함'); eq(inv.sites.find((x) => x.id === 'f'.repeat(32)).metadata, 'invalid', '배열·원시 JSON은 유효한 사이트 메타데이터가 아님'); eq(inv.sites.find((x) => x.id === waitingId).deploymentState, 'draft', 'DNS 대기 단계는 운영 배포 완료가 아님'); eq(inv.sites.find((x) => x.id === legacyId).deploymentState, 'deployed', 'phase 도입 전 성공 기록은 배포됨'); ok(inv.errors.some((x) => x.includes('invalid_shape'))); ok(!JSON.stringify(inv).includes('대문자 ID') && !JSON.stringify(inv).includes('임시 파일은 제외')); ok(!JSON.stringify(inv).includes('본문') && !JSON.stringify(inv).includes('비밀'));
const limited = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot, maxSites: 2 }); eq(limited.total, 8); eq(limited.returned, 2); eq(limited.truncated, true); eq(limited.complete, false);
const broken = scanWebmakerInventory({ dataRoot, dataDir, sitesRoot: join(base, 'missing-root') }); eq(broken.total, 7); eq(broken.complete, false); ok(broken.errors.some((x) => x.startsWith('sites:')), '배포 루트 오류를 숨기지 않으면서 메타데이터 프로젝트는 보존');
const missingMeta = scanWebmakerInventory({ dataRoot, dataDir: join(base, 'missing-meta'), sitesRoot }); eq(missingMeta.complete, false); ok(missingMeta.errors.some((x) => x.startsWith('metadata:')), '메타데이터 루트 읽기 실패 표시'); eq(missingMeta.sites[0].metadata, 'missing');
const aliasRoot = join(base, 'alias-root'); symlinkSync(dataRoot, aliasRoot, 'dir'); const aliasInv = scanWebmakerInventory({ dataRoot: aliasRoot, dataDir, sitesRoot }); eq(aliasInv.total, 8); eq(aliasInv.errors.filter((x) => x.includes('invalid_shape')).length, 1, '실경로가 같은 metadata root는 한 번만 스캔');
const largeSites = Array.from({ length: 100 }, (_, i) => ({ id: String(i).padStart(32, 'a'), title: 'x'.repeat(200), domains: Array.from({ length: 100 }, (_, n) => `${String(n).padStart(240, 'd')}.test`) })); const batches = webmakerInventoryBatches({ ...inv, sites: largeSites, returned: 100 }); ok(Buffer.byteLength(JSON.stringify({ facts: { webmakerInventory: { ...inv, sites: largeSites } } })) > 2 * 1024 * 1024, '단일 heartbeat면 2MiB 초과 fixture'); ok(batches.length > 1 && batches.every((b) => b.sites.length <= 1000 && Buffer.byteLength(JSON.stringify(b)) <= 512 * 1024)); eq(batches.flatMap((b) => b.sites).map((x) => x.id), largeSites.map((x) => x.id), 'byte 배치 후 전체 사이트 무누락');
const manySites = Array.from({ length: 1500 }, (_, i) => ({ id: `s${i}` })); const countBatches = webmakerInventoryBatches({ ...inv, sites: manySites, returned: 1500 }); ok(countBatches.length === 2 && countBatches.every((b) => b.sites.length <= 1000)); eq(countBatches.flatMap((b) => b.sites).length, 1500, 'HAL 항목 제한 배치 후 무누락');
const noisy = webmakerInventoryBatches({ ...inv, sites: [], errors: Array.from({ length: 100000 }, () => 'scan:EACCES'.repeat(40)) }); eq(noisy.length, 1); ok(Buffer.byteLength(JSON.stringify(noisy[0])) <= 512 * 1024 && noisy[0].meta.errorsTruncated, '빈 사이트·대량 오류도 HTTP 한도 내 명시적 절단');
const unconfigured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, {}); ok(unconfigured.webmakerInventory.errors[0].startsWith('configuration:') && unconfigured.webmakerInventory.complete === false, '명시적 운영 경로 없이는 추측 스캔하지 않음'); let incompleteRejected = false; try { webmakerInventoryBatches(unconfigured.webmakerInventory); } catch (e) { incompleteRejected = e.message === 'inventory_incomplete'; } ok(incompleteRejected, '불완전 스캔은 HAL의 마지막 정상 인벤토리를 덮어쓰지 않음');
const configured = configuredWebmakerInventory({ units: { webmaker: 'webmaker' } }, { ARIWEB_WEBMAKER_DATA_ROOT: dataRoot, ARIWEB_WEBMAKER_DATA_DIR: dataDir, ARIWEB_WEBMAKER_SITES_ROOT: sitesRoot }); eq(configured.webmakerInventory.total, 8); eq(configured.webmakerInventory.complete, true);
});
await test('site.deploy: 레이아웃(releases/current)·버전 기록·vhost·내부 확인·previous, rollback, 실패 시 이전 버전 유지와 leftovers, remove', async () => {
const e = env(); const b1 = bundle(e.base, 'b1'); mkdirSync(e.roots.wildcardCert, { recursive: true }); writeFileSync(join(e.roots.wildcardCert, 'fullchain.pem'), 'x');
let r = await e.run('site.deploy', { siteId: 'site1', versionRef: 'v1', bundlePath: b1, domains: ['site1.ariweb.net'], primaryDomain: 'site1.ariweb.net', expectMarker: 'marker-1' }); eq(r.status, 'done', JSON.stringify(r));
@@ -152,8 +180,9 @@ await test('module.install(실제 환경 결함 수정): REVISION.json 의 Suite
const wmDir = join(e.roots.srv, 'webmaker'); mkdirSync(join(wmDir, 'releases', 'old'), { recursive: true }); execFileSync('ln', ['-sfn', join(wmDir, 'releases', 'old'), join(wmDir, 'current')]);
const wmData = join(e.base, 'wmdata'); mkdirSync(wmData, { recursive: true }); writeFileSync(join(wmData, 'ok.json'), '{}'); writeFileSync(join(wmData, 'unreadable.json'), '{}'); chmodSync(join(wmData, 'unreadable.json'), 0o000); // root 가 남긴 파일처럼 읽을 수 없는 항목
e.sys.unitText = async (u) => (u === 'webmaker' ? `[Service]\nUser=${(await import('node:os')).userInfo().username}\nWorkingDirectory=${wmDir}/current\nProtectSystem=strict\nEnvironment=DATA_ROOT=${wmData}\n` : '');
const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir });
const before = e.sys.calls.length; const runner2 = createRunner({ kinds: KINDS, roots: e.roots, sys: e.sys, client: fakeClient({ artifacts: { '/art/wm': wtgz } }), dataDir: e.dataDir }); const oldLocale = process.env.LC_ALL; process.env.LC_ALL = 'C';
r = await runner2.execute({ id: 'u2', kind: 'module.install', payload: { module: 'webmaker', version: '0.1.1', artifactUrl: '/art/wm', sha256: wsum, descriptor: { name: 'webmaker', unit: 'webmaker', unitTemplate: 'webmaker.service.tmpl', keepExistingUnit: true, health: { url: 'http://127.0.0.1:1/x', expect: 'any' }, version: { file: 'package.json' }, layout: 'releases', preStop: ['webmaker.backupData'], postStart: [], extraUnits: ['webmaker-setup'] }, appDir: wmDir, unitTemplate: 'WorkingDirectory={{APP_DIR}}/current\n{{ENV_LINES}}\nExecStart={{NODE}} server.js' } });
if (oldLocale == null) delete process.env.LC_ALL; else process.env.LC_ALL = oldLocale;
eq(r.status, 'done', JSON.stringify(r)); eq(r.result.actualVersion, '0.1.1'); eq(r.result.upstreamVersion, '1.7.3'); eq(r.steps.find((s) => s.id === 'unit').result.skipped, 'existing_unit_kept');
{ const pre = r.steps.find((s) => s.id === 'pre:webmaker.backupData').result; ok(pre.backup && existsSync(pre.backup), '데이터 백업 파일'); ok(pre.unreadable && pre.unreadable.length === 1 && /unreadable\.json/.test(pre.unreadable[0]), '읽지 못한 파일은 경고로: ' + JSON.stringify(pre.unreadable)); }
ok(!e.sys.calls.slice(before).some((c) => c.cmd === 'sudo' && c.args.includes('tee')), '유닛 파일을 다시 쓰지 않는다'); ok(e.sys.calls.slice(before).some((c) => c.args.join(' ').includes('systemctl restart webmaker-setup')));
+147
View File
@@ -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();
+4 -2
View File
@@ -42,13 +42,15 @@ await test('화면 접근: 허용 네트워크 밖 403, 세션 없음/고객 쿠
await test('주요 화면 렌더: 서버(등록·용량·모듈 차이)·서버 상세·릴리스(HAL 먼저)·작업(부분 실패)·사건·감사·복구·계정', async () => {
const h = makeHal(); const op = await loginOp(h);
const s = core.createServer(h.d, { name: '웹1', role: 'web', location: '서울' }); const t = core.issueRegistrationToken(h.d, { serverId: s.id });
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1' }, version: '0.1.1' } }); ok(reg.json.ok);
const reg = await invoke(h.app, 'POST', '/agent/v1/register', { body: { token: t.token, facts: { hostname: 'web1', webmakerInventory: { total: 1, returned: 1, truncated: false, sites: [{ id: 'a'.repeat(32), title: '기존 사이트 <A>', ownerId: 'owner-a', status: '<idle>', domains: ['a.example.test'], deploymentState: 'deployed', bytes: 1234, files: 3, updatedAt: '2026-10-27T00:00:00Z', metadata: 'ok' }] } }, version: '0.1.1' } }); ok(reg.json.ok);
h.d.store.run('UPDATE servers SET last_seen_at=?, capacity_json=?, metrics_json=? WHERE id=?', new Date().toISOString(), JSON.stringify({ diskFreeBytes: 10e9, diskTotalBytes: 100e9, sitesHosted: 3, reservedBytes: 1e9 }), JSON.stringify({ cpu: 12, memUsed: 50, memTotal: 100, netIn: 1000, netOut: 2000 }), s.id);
core.approveServer(h.d, s.id, 'ozops');
h.d.store.run('INSERT INTO module_targets(module, server_id, target_version, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?,?)', 'portal', s.id, '0.1.1', '0.1.0', 'ok', 1, new Date().toISOString(), new Date().toISOString());
core.registerRelease(h.d, { version: '0.1.1', mainCommit: 'abcdef1234567890', builtAt: new Date().toISOString(), artifacts: { hal: { file: 'hal.tgz', sha256: 'sha256:x', size: 1 } }, checks: { testsPassed: true } });
let r = await op.as('GET', '/ops/servers'); eq(r.status, 200); ok(r.text.includes('웹1') && r.text.includes('배치 가능') && r.text.includes('0.1.0') && r.text.includes('0.1.1'), '서버 표에 상태·모듈 차이');
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"'));
r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('모듈(목표 vs 실제)') && r.text.includes('서버 삭제') && r.text.includes('id="metrics"')); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('owner-a') && r.text.includes('a.example.test') && r.text.includes('기존 사이트 &lt;A&gt;') && !r.text.includes('기존 사이트 <A>') && r.text.includes('&lt;idle&gt;') && !r.text.includes('<idle>'));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 0, returned: 0, truncated: false, errors: ['sites:EACCES<script>'], errorsTruncated: true, sites: [] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); ok(r.text.includes('Webmaker 사이트 디렉터리 인벤토리') && r.text.includes('인벤토리 스캔 오류 1건') && r.text.includes('EACCES&lt;script&gt;') && !r.text.includes('EACCES<script>'));
h.d.store.run('UPDATE servers SET facts_json=? WHERE id=?', JSON.stringify({ hostname: 'web1', webmakerInventory: { total: 'bad', returned: {}, errors: '<script>', sites: [null, { id: '<id>', domains: '<img onerror=1>', files: {} }] } }), s.id); r = await op.as('GET', `/ops/servers/${s.id}`); eq(r.status, 200); ok(r.text.includes('&lt;id&gt;') && !r.text.includes('<img onerror=1>'), 'null 등 malformed agent facts도 렌더러를 깨거나 HTML로 실행하지 않음');
r = await op.as('GET', '/ops/releases'); ok(r.text.includes('HAL 을 먼저 배포') && r.text.includes('abcdef123456') && r.text.includes('테스트 통과') && r.text.includes('승인 대기'));
// 작업: 부모 + 자식(하나 실패) → 부분 실패 표시
h.d.jobs.register('ui.demo', { title: '데모', steps: [{ id: 'a', title: 'A' }], async run(job, ctx) { await ctx.step('a', () => { if (job.subject.fail) throw new Error('대상 실패'); return 1; }); } });
+12
View File
@@ -64,6 +64,18 @@ await test('heartbeat → 지표·용량·모듈 버전 저장, 무응답 → of
await sleep(80); eq(core.markOffline(h.d).length, 1); eq(core.listServers(h.d)[0].status, 'offline'); await a.hb(); eq(core.listServers(h.d)[0].status, 'ready', 'heartbeat 복귀');
h.d.store.run('INSERT INTO metrics_samples(server_id, at, cpu) VALUES(?,?,?)', a.serverId, new Date(Date.now() - 100 * 86400000).toISOString(), 1); eq(core.purgeMetrics(h.d), 1); ok(core.metrics(h.d, a.serverId, { since: new Date(0).toISOString() }).every((m) => Date.now() - Date.parse(m.at) < 91 * 86400000));
});
await test('Webmaker 인벤토리 배치: 2MiB heartbeat와 분리·순서 검증·완료 시 원자 반영·후속 heartbeat 보존', async () => {
const h = makeHal(); const a = await registerAgent(h); const path = '/agent/v1/inventory/webmaker'; const send = (body) => call(h.app, `agent:${a.agentId}`, a.secret, 'POST', path, body);
const sites = ['a', 'b', 'c'].map((x) => ({ id: x.repeat(32), title: `site-${x}`, domains: [`${x}.test`], files: 1, bytes: 2 })); sites[0].privateContact = 'do-not-store@example.test'; sites[0].nested = { secret: 'do-not-store' }; const meta = { total: 3, errors: [], truncated: false, scannedAt: '2026-10-27T00:00:00.000Z' };
let r = await send({ scanId: 'scan-1', offset: 1, itemTotal: 3, meta, sites: [sites[1]] }); eq(r.status, 400); eq(r.json.error, 'inventory_batch_order');
r = await send({ scanId: 'scan-1', offset: 0, itemTotal: 3, meta, sites: sites.slice(0, 2) }); eq(r.status, 200); eq(r.json.complete, false); eq(core.getServer(h.d, a.serverId).facts.webmakerInventory, undefined, '중간 배치는 공개하지 않음');
r = await send({ scanId: 'scan-1', offset: 2, itemTotal: 3, meta, sites: sites.slice(2) }); eq(r.status, 200); eq(r.json.complete, true); const stored = core.getServer(h.d, a.serverId).facts.webmakerInventory; eq(stored.sites.map((x) => x.id), sites.map((x) => x.id)); eq(stored.truncated, false); ok(!JSON.stringify(stored).includes('privateContact') && !JSON.stringify(stored).includes('do-not-store'), '허용되지 않은 개인정보·중첩 필드 저장 금지'); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0);
r = await send({ scanId: 'scan-partial', offset: 0, itemTotal: 1, meta: { ...meta, total: 2, truncated: true }, sites: sites.slice(0, 1) }); eq(r.status, 400); eq(r.json.error, 'inventory_incomplete');
r = await send({ scanId: 'scan-bad', offset: 0, itemTotal: 1, meta, sites: [{ id: '../bad', title: 'bad' }] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
r = await send({ scanId: 'scan-too-many', offset: 0, itemTotal: 10001, meta, sites: [] }); eq(r.status, 400); eq(r.json.error, 'bad_inventory_batch');
await a.hb({ facts: { hostname: 'web-after' } }); const facts = core.getServer(h.d, a.serverId).facts; eq(facts.hostname, 'web-after'); eq(facts.webmakerInventory.returned, 3, '작은 heartbeat가 완성된 인벤토리를 지우지 않음');
r = await send({ scanId: 'scan-cascade', offset: 0, itemTotal: 2, meta: { ...meta, total: 2 }, sites: sites.slice(0, 1) }); eq(r.status, 200); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 1); ok(h.d.store.get("SELECT name FROM sqlite_master WHERE type='trigger' AND name='agent_inventory_staging_agent_cleanup'"), '이전 스키마에도 적용 가능한 Agent 삭제 cleanup trigger'); h.d.store.run('DELETE FROM agents WHERE id=?', a.agentId); eq(h.d.store.get('SELECT COUNT(*) AS n FROM agent_inventory_staging').n, 0, 'Agent 삭제 시 미완료 staging cascade');
});
await test('setBootstrap: HAL 기동 전(행 없음) CLI bootstrap-release 의 기록이 남는다(실제 환경 결함 수정)', async () => {
const h = makeHal(); eq(h.d.store.get('SELECT COUNT(*) AS n FROM bootstrap_state').n, 0); core.setBootstrap(h.d, { release_version: '0.1.1', main_commit: 'abc', artifact_sha256: 'def' });
eq(core.bootstrapState(h.d).release_version, '0.1.1'); eq(core.bootstrapState(h.d).main_commit, 'abc');
+46
View File
@@ -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();
+81
View File
@@ -0,0 +1,81 @@
/** test/webmaker-inventory.test.mjs — 운영 Webmaker ↔ 포털 ↔ HAL inventory/dry-run 도구(scripts/webmaker-inventory.mjs) 회귀 테스트.
* 가짜 Webmaker 데이터 루트(JSON)와 실제 스키마로 만든 포털·HAL SQLite 로 판정·충돌·누락·중복·비밀 차단을 확인한다. */
import { test, eq, ok, tmpDir, done } from '../lib/test.mjs';
import { readWebmaker, readPortal, readHal, dryRun, assertNoSecrets } from '../scripts/webmaker-inventory.mjs';
import { openDb } from '../lib/db.mjs';
import { allMigrations } from '../portal/schema.mjs';
import { MIGRATIONS as HAL_MIGRATIONS } from '../hal/schema.mjs';
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
const P1 = 'a'.repeat(32), P2 = 'b'.repeat(32), P3 = 'c'.repeat(32), P4 = 'd'.repeat(32);
function webmakerRoot() {
const root = tmpDir('wm-inv-'); const data = join(root, 'data'); mkdirSync(join(data, 'sites'), { recursive: true }); mkdirSync(join(root, 'sites-root', P1), { recursive: true }); writeFileSync(join(root, 'sites-root', P1, 'index.html'), '<html>');
writeFileSync(join(data, 'users.json'), JSON.stringify({ icqmomo: { id: 'icqmomo', name: '오즈', salt: 'S', passHash: 'H', role: 'admin', accountType: 'domain', createdAt: '2026-10-14T00:00:00Z' }, 'aw-01a17894-01a17894': { id: 'aw-01a17894-01a17894', name: '검증', salt: 'S', passHash: 'H', role: 'user', accountType: 'domain', sso: 'ariweb' }, 'aw-deadbeef-deadbeef': { id: 'aw-deadbeef-deadbeef', name: '고아', salt: 'S', passHash: 'H', role: 'user', sso: 'ariweb' } }));
const site = (id, extra) => writeFileSync(join(data, 'sites', id + '.json'), JSON.stringify({ id, title: 'T' + id.slice(0, 2), ownerId: 'icqmomo', status: 'idle', domains: [], deployments: [], git: { changes: [] }, sessionId: 'SECRET', ...extra }));
site(P1, { domains: ['sample.example'], deployments: [{ targetId: 't1', at: '2026-10-15T07:27:00Z', url: 'https://x/' }], git: { changes: [{ no: 1, kind: 'deploy', branch: `sites/${P1}/main`, commit: '1111', pushed: true }] } });
site(P2, { ownerId: 'aw-01a17894-01a17894' }); site(P3, {}); site(P4, {});
writeFileSync(join(data, 'ariweb-links.json'), JSON.stringify({ version: 1, links: { [P2]: { siteId: 's_01a17894dd8ab3df984ae079f3', groupId: 'g_01a17894dd24bf64a49ff70600', siteName: '검증', linkedAt: 'x' }, [P4]: { siteId: 's_gone', groupId: 'g_gone' } } }));
writeFileSync(join(data, 'domains.json'), JSON.stringify({ domains: [{ name: 'sample.example', siteId: P1, ownerId: 'icqmomo', status: 'active' }], contacts: {} }));
writeFileSync(join(data, 'deploy-targets.json'), JSON.stringify({ defaultId: 't1', targets: [{ id: 't1', name: '기본', type: 'local', baseDir: '/var/www/x', publicBaseUrl: 'https://x', privateKey: 'SECRET' }] }));
writeFileSync(join(data, 'mail-servers.json'), JSON.stringify({ version: 1, items: [{ id: 'm1', name: 'AriMail', provider: 'arimail', apiToken: 'SECRET' }], defaultId: 'm1' }));
writeFileSync(join(data, 'config.json'), JSON.stringify({ version: 1, values: { service: { publicBaseUrl: 'https://wm.example' }, source: { provider: 'gitea', token: 'SECRET' } } }));
writeFileSync(join(root, 'REVISION.json'), JSON.stringify({ module: 'webmaker', version: '0.1.1', upstreamVersion: '1.7.3' }));
mkdirSync(join(root, 'nginx')); writeFileSync(join(root, 'nginx', `wh-${P1}.conf`), 'server {}');
return { root, data };
}
async function portalDb(root, rows) {
const db = openDb(join(root, 'portal.sqlite'), await allMigrations()); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO users (id,email,display_name,status,created_at,updated_at) VALUES (?,?,?,?,?,?)', 'u_01a17894dce305ff35231f3676', 'alice@check.example', '앨리스', 'active', now, now);
db.run('INSERT INTO groups (id,name,kind,owner_user_id,status,created_at,updated_at) VALUES (?,?,?,?,?,?,?)', 'g_01a17894dd24bf64a49ff70600', '팀', 'team', 'u_01a17894dce305ff35231f3676', 'active', now, now);
for (const [id, pid] of rows) db.run('INSERT INTO sites (id,group_id,name,webmaker_project_id,lifecycle,deploy_mode,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', id, 'g_01a17894dd24bf64a49ff70600', 'S', pid, 'draft', 'managed', now, now);
db.close(); return join(root, 'portal.sqlite');
}
function halDb(root, { webmakerTarget = true, health = 'ok' } = {}) {
const db = openDb(join(root, 'hal.sqlite'), HAL_MIGRATIONS); const now = '2026-10-26T00:00:00Z';
db.run('INSERT INTO servers (id,name,role,location,status,agent_id,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)', 'srv_1', 'srv', 'control', 'x', 'ready', 'ag_1', now, now);
db.run('INSERT INTO agents (id,server_id,kind,status,version,registered_at,secret_hash) VALUES (?,?,?,?,?,?,?)', 'ag_1', 'srv_1', 'managed', 'active', '0.1.1', now, 'x');
if (webmakerTarget) db.run('INSERT INTO module_targets (module,server_id,target_version,actual_version,health,active,updated_at) VALUES (?,?,?,?,?,?,?)', 'webmaker', 'srv_1', '0.1.1', '0.1.1', health, 1, now);
db.close(); return join(root, 'hal.sqlite');
}
await test('Webmaker inventory: 비밀 없이 회원·프로젝트·도메인·배포 대상·연결 등록부를 읽는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data, { sitesRoot: join(root, 'sites-root'), nginxEnabled: join(root, 'nginx'), appDir: root });
eq(w.users.length, 3); eq(w.projects.length, 4); eq(w.domains.length, 1); eq(w.deployTargets.length, 1); eq(w.mailServers.length, 1); eq(w.links.length, 2);
const p1 = w.projects.find((p) => p.id === P1); eq(p1.deployments, 1); eq(p1.siteDirFiles, 1); eq(p1.vhost, true); eq(p1.git.lastPushed.commit, '1111'); eq(w.revision.version, '0.1.1');
ok(!JSON.stringify(w).includes('SECRET'), '비밀값이 출력에 없어야 한다'); ok(!JSON.stringify(w).includes('passHash'));
});
await test('dry-run: 양쪽 등록부 일치는 linked_consistent, 고유 프로젝트는 unlinked_native, 모두 일치하면 no-op', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
// P4 는 Webmaker 쪽에만 연결이 있으므로 충돌 — 이 테스트는 P4 연결을 지운 데이터로 본다
w.projects.find((p) => p.id === P4).link = null; w.links = w.links.filter((l) => l.projectId !== P4); w.users = w.users.filter((u) => u.id !== 'aw-deadbeef-deadbeef'); // 고아 SSO 회원은 충돌 테스트용
const portal = readPortal(await portalDb(root, [['s_01a17894dd8ab3df984ae079f3', P2]])); const hal = readHal(halDb(root));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `1111\trefs/heads/sites/${P1}/main\n` });
eq(r.summary.verdicts.linked_consistent, 1); eq(r.summary.verdicts.unlinked_native, 3); eq(r.summary.conflicts, 0); eq(r.summary.missing, 0);
eq(r.users.find((u) => u.userId === 'aw-01a17894-01a17894').verdict, 'sso_consistent'); eq(r.users.find((u) => u.userId === 'icqmomo').verdict, 'native_no_portal_identity');
eq(r.domains[0].verdict, 'webmaker_only'); eq(r.gitea[0].verdict, 'ok'); eq(r.halModule.registered, true); eq(r.halModule.agentOnline, true);
ok(r.applyPlan.startsWith('no-op'), r.applyPlan);
});
await test('dry-run: 한쪽만 있는 연결·다른 사이트를 가리키는 연결·중복·없는 프로젝트·HAL 등록 누락·Gitea ref 누락을 모두 충돌/누락으로 잡는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data);
const portal = readPortal(await portalDb(root, [['s_x', P2], ['s_y', P3], ['s_z', P3], ['s_ghost', 'f'.repeat(32)]])); const hal = readHal(halDb(root, { webmakerTarget: false }));
const r = dryRun({ webmaker: w, portal, hal, giteaRefs: `2222\trefs/heads/sites/${P1}/main\n` });
const kinds = r.findings.map((f) => f.kind).sort();
eq(kinds, ['duplicate_portal_sites', 'gitea_ref', 'hal_module_target_missing', 'link_mismatch', 'link_webmaker_only', 'missing_project', 'sso_identity_missing']);
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_mismatch'); eq(r.projects.find((p) => p.projectId === P3).verdict, 'duplicate'); eq(r.projects.find((p) => p.projectId === P4).verdict, 'link_webmaker_only');
ok(r.applyPlan.startsWith('blocked'), r.applyPlan); ok(r.summary.conflicts >= 4 && r.summary.missing >= 3);
});
await test('포털 없이(HAL 만) 돌리면 연결은 unverified 로 두고 판정하지 않는다', async () => {
const { root, data } = webmakerRoot(); const w = readWebmaker(data); const r = dryRun({ webmaker: w, hal: readHal(halDb(root)) });
eq(r.projects.find((p) => p.projectId === P2).verdict, 'link_unverified'); eq(r.summary.portal, null); eq(r.summary.conflicts, 0);
});
await test('assertNoSecrets 는 비밀로 보이는 키를 거부한다', async () => {
let threw = false; try { assertNoSecrets({ a: { passHash: 'x' } }); } catch (_) { threw = true; } ok(threw);
assertNoSecrets({ sessionGen: 1, isDefault: true, lastCheckAt: 'x' });
});
done();