/** * hal/index.mjs — HAL 프로세스 진입점과 CLI. * * node hal/index.mjs 서버 실행(기본). 기동 때 "하나의 HAL" 임대를 잡고(§34) 실행 버전을 기록한다(§32). * node hal/index.mjs setup-token 최초 운영자 계정을 만들 설정 토큰을 발급(1회 표시, data/setup.token). 부트스트랩 스크립트가 부른다. * node hal/index.mjs grant --login [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25) * node hal/index.mjs register-release --manifest /manifest.json> [--approve] 릴리스 등록(부트스트랩·CI) * node hal/index.mjs link-portal 포털 연동 서비스 키 생성(service:portal / service:hal) — 포털 보관소에도 같은 값을 넣는다(출력 1회) * node hal/index.mjs bootstrap-release --version --commit --sha256 부트스트랩 때 선택한 릴리스 정보를 기록 * node hal/index.mjs server-token --server [--by ] 관리 서버의 Node Agent 일회용 등록 토큰(10분, 1회 표시) — 운영자 콘솔이 닿지 않는 부트스트랩 단계용(시스템 신원, 감사 기록) * node hal/index.mjs mail-import --discovery (--server | --create-server --name [--location ]) [--apply] [--by ] * 기존 AriMail 인스턴스를 HAL 관리형 메일 서비스로 가져오기(기본 dry-run, hal/mailsvc.mjs) * node hal/index.mjs mail-import-rollback --import [--by ] 적용된 가져오기 되돌리기 * node hal/index.mjs mail-services HAL 관리형 메일 서비스 readback(JSON, 비밀 없음) * node hal/index.mjs config-set [--publicUrl ] [--portalUrl ] [--offlineAfterMs ] [--metricsRetentionDays ] [--by ] [--reason ] * 운영 값(data/config.json) 기록 — 비밀값 거부, 감사 기록. 서비스 프로세스는 기동 때 읽으므로 재시작해야 반영된다 * node hal/index.mjs module-deploy --module --version --servers [--by ] [--reason ] [--nonce ] * 모듈 배포 작업(module.deploy)을 운영자 API 와 같은 멱등 규칙으로 큐에 넣는다(시스템 신원, 감사 기록). 실행은 serve 프로세스의 작업 엔진이 맡는다 * node hal/index.mjs job --id 작업·자식 작업 readback(JSON) * node hal/index.mjs mcp HAL 도구를 MCP(stdio) 서버로 내보낸다(0.3.0, 읽기 도구만 실행 — hal/ai/mcp.mjs) */ import { loadConfig } from './config.mjs'; import { createHalDeps, VERSION } from './deps.mjs'; import { buildApp } from './app.mjs'; 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 { audit } from '../lib/audit.mjs'; import { describe } from '../lib/jobs.mjs'; import { descriptor, profileOf } from './modules/index.mjs'; import { randomToken } from '../lib/crypto.mjs'; import { ensureTlsInfo, probePublicTls } from './tls.mjs'; // [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀) import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs'; import { join, dirname, basename } from 'node:path'; const log = (...a) => console.log(new Date().toISOString(), ...a); const cfg = loadConfig(); const args = process.argv.slice(2); const cmd = args[0] && !args[0].startsWith('--') ? args[0] : 'serve'; const opt = (name, def = null) => { const i = args.indexOf('--' + name); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : def; }; const d = createHalDeps({ config: cfg, log: cmd === 'mcp' ? (...a) => console.error(new Date().toISOString(), ...a) : log }); // mcp 모드는 stdout 을 JSON-RPC 전용으로 비워 둔다 if (cmd === 'setup-token') { if (core.ensureOpsAccountExists(d)) { console.error('운영자 계정이 이미 있습니다 — 설정 토큰은 최초 1회만 쓸 수 있습니다'); process.exit(1); } const t = randomToken(24); writeFileSync(join(cfg.dataDir, 'setup.token'), t, { mode: 0o600 }); console.log(`설정 토큰(1회 표시, 공유 금지): ${t}\n운영자 콘솔 ${cfg.publicUrl}/ops/ 의 최초 설정 또는 curl -X POST ${cfg.publicUrl}/ops/api/setup -d '{"setupToken":"…","login":"…","password":"…","displayName":"…"}' 로 첫 운영자 계정을 만드세요`); } else if (cmd === 'grant') { const g = ops.issueGrant(d, { accountId: opt('login'), hours: Number(opt('hours', 8)), by: null, reason: opt('reason', 'cli') }); console.log(`접근권 URL(1회, ${g.expiresAt} 까지): ${g.url}`); } else if (cmd === 'register-release') { const mf = JSON.parse(readFileSync(opt('manifest'), 'utf8')); // 0.2.0 검토 수정 4차: artifact 설치는 불변·fail-closed — 원본 바이트를 manifest 해시·크기와 대조하고, 승인된 버전은 같은 바이트·메타데이터일 때만 멱등(복사 없음), 다르면 기존 파일·DB 를 건드리지 않고 거부. 새 설치는 임시 파일 → rename let r; try { const artifacts = core.installArtifacts(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, checks: mf.checks || null, artifacts: Object.fromEntries(Object.entries(mf.artifacts).map(([m, a]) => [m, { file: basename(a.file), sha256: a.sha256, size: a.size, src: join(dirname(opt('manifest')), a.file) }])) }); r = core.registerRelease(d, { version: mf.version, mainCommit: mf.mainCommit, builtAt: mf.builtAt, artifacts, checks: mf.checks || null, approve: !!opt('approve'), by: opt('by', 'cli') }); } catch (e) { console.error(`릴리스 등록 거부 (${e.code || 'error'}): ${e.message}`); process.exit(1); } // 해시 불일치(artifact_hash_mismatch)·승인된 버전 불변(release_immutable)·승인 규칙(release_checks_failed) — 종료 1, 파일·DB 불변 console.log(`릴리스 ${r.version} 등록(${r.status}) — artifact ${Object.keys(r.artifacts).join(', ')}`); } else if (cmd === 'link-portal') { // [E2E 결함 수정] 포털은 양방향 모두 보관소 이름 'service:hal' 하나를 쓴다(포털→HAL 서명, HAL→포털 검증). 그래서 공유 비밀 하나를 HAL 의 두 이름(service:portal·service:hal)에 같이 둔다. const shared = randomToken(32); d.vault.set('service:portal', shared, { kind: 'service-key' }); d.vault.set('service:hal', shared, { kind: 'service-key' }); 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:). // [0.4.1] --replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지). --json: 스크립트(부트스트랩)가 읽는 한 줄 JSON. 설치 명령은 공개 주소 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다. const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server 가 필요합니다(없는 서버)'); process.exit(1); } await ensureTlsInfo(d); const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}`, replace: !!opt('replace') }); if (opt('json')) console.log(JSON.stringify({ token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand, tls: t.tls, replace: t.replace, existingAgent: t.existingAgent })); else console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}${t.tls && t.tls.trusted === false ? `\n(HAL 공개 주소의 인증서가 시스템 CA 로 신뢰되지 않아(${t.tls.reason}) 핀을 붙였습니다 — 설치 스크립트가 핀으로 HAL 의 CA 를 받아 씁니다)` : ''}${t.existingAgent && !t.replace ? `\n주의: 이 서버에는 이미 Agent ${t.existingAgent.id} 가 있습니다 — 이 토큰으로는 등록이 거부됩니다(--replace 로 재등록 허용)` : ''}`); } else if (cmd === 'server-create') { // [0.4.1] 부트스트랩이 HAL 서버 자신을 관리 서버(control)로 만들 때 쓴다(운영자 콘솔이 아직 없는 단계). 출력은 한 줄 JSON. const name = opt('name'); const role = opt('role', 'control'); if (!name || name === true) { console.error('--name <이름> 이 필요합니다'); process.exit(1); } const by = `cli:${opt('by', 'cli')}`; let s; try { s = core.createServer(d, { name, role, location: opt('location', '') === true ? '' : opt('location', ''), by }); } catch (e) { console.error(`서버 생성 실패: ${e.code || ''} ${e.message}`); process.exit(1); } const profile = opt('profile'); if (profile && profile !== true) { const prof = profileOf(profile); if (!prof) { console.error(`알 수 없는 프로필: ${profile}`); process.exit(1); } core.updateServer(d, s.id, { profile: prof.id, role: prof.role, slots_json: JSON.stringify(prof.slots || {}) }, by); s = core.getServer(d, s.id); } console.log(JSON.stringify({ id: s.id, name: s.name, role: s.role, status: s.status, profile: s.profile || null })); } else if (cmd === 'server-approve') { // [0.4.1] 부트스트랩이 HAL 서버의 첫 Agent 를 승인해 부트스트랩을 닫는다(사람 결정은 bootstrap-hal.sh 실행 자체). 감사 actor cli:. const sid = opt('server'); if (!sid || sid === true) { console.error('--server 가 필요합니다'); process.exit(1); } try { const s = core.approveServer(d, sid, `cli:${opt('by', 'cli')}`); const h = core.selfHealth(d); console.log(JSON.stringify({ id: s.id, name: s.name, status: s.status, agentId: s.agent_id, bootstrapComplete: h.bootstrap.complete, pendingModules: h.deployment ? h.deployment.pending : [] })); } catch (e) { console.error(`승인 실패: ${e.code || ''} ${e.message}`); process.exit(1); } } else if (cmd === 'tls-probe') { // [0.4.1] 공개 주소 TLS 신뢰 상태 readback(핀·신뢰 앵커 유무, 비밀 없음) const info = await probePublicTls(opt('url') && opt('url') !== true ? opt('url') : cfg.publicUrl, { dataDir: cfg.dataDir }); console.log(JSON.stringify({ url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caCount: (info.ca || []).length, caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, error: info.error || null }, null, 1)); } else if (cmd === 'mail-import') { const file = opt('discovery'); if (!file) { console.error('--discovery 이 필요합니다'); process.exit(1); } const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); } let serverId = opt('server'); const by = `cli:${opt('by', 'cli')}`; if (!serverId && opt('create-server')) { const s = core.createServer(d, { name: opt('name', doc.source.hostname), role: 'mail', location: opt('location', ''), by }); serverId = s.id; console.log(`서버 생성(pending, Agent 미연결): ${s.id} ${s.name}`); } if (!serverId) { console.error('--server 또는 --create-server 가 필요합니다'); process.exit(1); } try { const r = mailsvc.applyImport(d, doc, { serverId, by, dryRun: !opt('apply') }); console.log(JSON.stringify({ importId: r.importId, mode: r.mode, serverId, authority: r.plan.authority, summary: r.plan.summary, items: r.plan.items, missingInSource: r.plan.missingInSource, created: r.created, updated: r.updated }, null, 1)); if (r.mode === 'rejected') process.exit(3); } catch (e) { console.error(`가져오기 실패: ${e.code || ''} ${e.message}`); process.exit(2); } } else if (cmd === 'mail-import-rollback') { try { const r = mailsvc.rollbackImport(d, opt('import'), `cli:${opt('by', 'cli')}`); console.log(JSON.stringify(r)); } catch (e) { console.error(`되돌리기 실패: ${e.code || ''} ${e.message}`); process.exit(2); } } else if (cmd === 'mail-services') { console.log(JSON.stringify(mailsvc.readback(d), null, 1)); } else if (cmd === 'config-set') { // 운영 중 바꾸는 값만(hal/config.mjs 의 set — 비밀 키 이름은 거부). 받는 키를 고정해 비밀값이 설정 파일에 들어갈 길을 막는다. 감사 actor cli:. const patch = {}; for (const k of ['publicUrl', 'portalUrl', 'offlineAfterMs', 'metricsRetentionDays']) { const v = opt(k); if (v != null && v !== true) patch[k] = String(v); } if (!Object.keys(patch).length) { console.error('바꿀 값이 없습니다: --publicUrl / --portalUrl / --offlineAfterMs / --metricsRetentionDays 중 하나 이상'); process.exit(1); } const by = `cli:${opt('by', 'cli')}`; cfg.set(patch, by); audit(d.store, { actorType: 'operator', actorId: by, action: 'config.update', subjectType: 'config', subjectId: 'hal', detail: { keys: Object.keys(patch), values: patch, reason: opt('reason', '') } }); console.log(JSON.stringify({ updated: Object.keys(patch), file: cfg.file, note: '서비스 프로세스(ariweb-hal)는 기동 때 설정을 읽는다 — 재시작 뒤 반영' })); } else if (cmd === 'module-deploy') { // 운영자 API(POST /ops/api/modules/:name/deploy)와 같은 작업·멱등 키 규칙. 검증(모듈·릴리스·서버)은 여기서, 배포 전제 검사(승인·HAL 먼저·부트스트랩)는 작업의 precheck 단계가 한다. const module = opt('module'); const version = opt('version'); const serverIds = String(opt('servers', '') || '').split(',').map((x) => x.trim()).filter(Boolean); const by = `cli:${opt('by', 'cli')}`; if (!module || module === true || !version || version === true || !serverIds.length) { console.error('--module --version --servers 가 필요합니다'); process.exit(1); } try { descriptor(module); } catch (e) { console.error(e.message); process.exit(1); } if (!core.getRelease(d, version)) { console.error(`릴리스 ${version} 이(가) 등록되어 있지 않습니다`); process.exit(1); } const missing = serverIds.filter((sid) => !core.getServer(d, sid)); if (missing.length) { console.error(`없는 서버: ${missing.join(', ')}`); process.exit(1); } const r = d.jobs.enqueue({ kind: 'module.deploy', subject: { module, version, serverIds }, idempotencyKey: `module.deploy:${module}:${version}:${serverIds.slice().sort().join(',')}:${opt('nonce', '') === true ? '' : opt('nonce', '')}`, createdBy: by, actorType: 'operator', subjectType: 'module', subjectId: module, label: `${module} ${version} 배포` }); audit(d.store, { actorType: 'operator', actorId: by, action: 'deploy.module.request', subjectType: 'module', subjectId: module, detail: { version, serverIds, reason: opt('reason', ''), jobId: r.job.id, created: r.created } }); console.log(JSON.stringify({ jobId: r.job.id, created: r.created, status: r.job.status, kind: r.job.kind, label: r.job.label, note: '실행은 ariweb-hal 서비스 프로세스의 작업 엔진이 맡는다 — `job --id` 로 확인' })); } else if (cmd === 'job') { const j = opt('id') && opt('id') !== true ? d.jobs.get(opt('id')) : null; if (!j) { console.error('--id 가 필요합니다(없는 작업)'); process.exit(1); } console.log(JSON.stringify({ job: describe(j), children: d.jobs.list({ parentId: j.id, limit: 100 }).map(describe) }, null, 1)); } else if (cmd === 'mcp') { // 0.3.0: HAL 도구를 MCP(stdio) 서버로 내보낸다(hal/ai/mcp.mjs). 읽기 도구만 실행, 쓰기·고위험은 운영자 콘솔 승인 안내. 로그는 stderr. const { runMcpServer } = await import('./ai/mcp.mjs'); await runMcpServer(d); d.store.close(); } 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); } const app = buildApp(d); d.jobs.start(); d.jobsRunning = true; const every = (ms, fn) => setInterval(() => { try { const r = fn(); if (r && r.catch) r.catch((e) => log('[hal] 주기 작업 오류', e.message)); } catch (e) { log('[hal] 주기 작업 오류', e.message); } }, ms).unref(); every(20000, () => core.renewLease(d, d.instanceId)); every(30000, () => core.markOffline(d)); every(3000, () => core.flushPortalEvents(d)); every(60000, () => emergency.expireCredentials(d)); every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); }); every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); }); await app.listen(cfg.port, cfg.host); // [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀·CA 배포 근거) — 기동 직후 1회, 이후 10분마다(인증서 갱신 반영) ensureTlsInfo(d).then((t) => log(`[hal] 공개 주소 TLS: ${t.trusted === true ? '신뢰됨' : t.trusted === false ? `시스템 CA 로 신뢰되지 않음(${t.reason}) — 설치 명령에 핀을 붙입니다` : `확인 불가(${t.error || t.reason})`}`)); every(600000, () => ensureTlsInfo(d, { force: true })); log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`); process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); }); } else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }