/** * hal/emergency.mjs — 긴급 복구(§33). * * HAL 의 정상 작업 기능을 쓸 수 없는 실제 장애에만 허용한다: 선언(incident key + 사유) 때 HAL 자체 건강이 정상이고 열린 critical 사건이 없으면 거부한다. * 단계: declared → approved(서로 다른 내용을 확인하는 3단계: 1 영향 범위, 2 대상 식별정보 재입력, 3 최종 실행·되돌리기 가능 여부) → credential_issued(짧은 유효 시간, 1회 표시) → * performing(허용 범위 안의 행동만: hal.restart / hal.restore_snapshot / vault.verify / cert.reissue_internal / artifact.restore / agent.reconnect) → verified → credential_revoked → reconciled → closed. * 운영자가 한 명이어도 되지만 세 단계의 내용과 시각이 모두 기록되어야 한다. 일반 고객 서비스 배포(module.deploy 등)는 어떤 경우에도 거부한다. */ import { newId, randomToken, sha256, safeEqual } from '../lib/crypto.mjs'; import { nowIso, toJson, parseJson } from '../lib/db.mjs'; import { audit } from '../lib/audit.mjs'; import { JobError } from '../lib/jobs.mjs'; import * as core from './core.mjs'; export const SCOPES = ['hal', 'hal_data', 'vault', 'cert_base', 'artifact_store', 'first_agent']; export const ACTIONS = { 'hal.restart': 'hal', 'hal.restore_snapshot': 'hal_data', 'vault.verify': 'vault', 'cert.reissue_internal': 'cert_base', 'artifact.restore': 'artifact_store', 'agent.reconnect': 'first_agent' }; const STEP_TITLES = ['영향 범위 확인', '변경되지 않는 대상 식별정보 재확인', '최종 실행과 되돌리기 가능 여부 확인']; function get(d, id) { const r = d.store.get('SELECT * FROM emergency_sessions WHERE id=?', id); if (!r) throw new JobError('emergency_not_found', '긴급 복구 세션이 없습니다'); return { ...r, scope: parseJson(r.scope_json, []), approvals: parseJson(r.approvals_json, []), actions: parseJson(r.actions_json, []), verify: parseJson(r.verify_json, null), reconcile: parseJson(r.reconcile_json, null) }; } export const getSession = get; export function listSessions(d) { return d.store.all('SELECT * FROM emergency_sessions ORDER BY declared_at DESC LIMIT 50').map((r) => get(d, r.id)); } export function declare(d, { incidentKey, reason, scope, by, force = false }) { if (!incidentKey || !reason) throw new JobError('declare_incomplete', '사건 번호와 복구 이유가 필요합니다'); const bad = (scope || []).filter((s) => !SCOPES.includes(s)); if (bad.length || !scope?.length) throw new JobError('bad_scope', `허용 대상은 ${SCOPES.join(', ')} 입니다`); const health = core.selfHealth(d); const openCritical = d.store.get('SELECT id FROM incidents WHERE severity=\'critical\' AND resolved_at IS NULL'); if (health.ok && !openCritical && !force) throw new JobError('hal_healthy', 'HAL 정상 작업 기능을 쓸 수 있는 상태입니다 — 긴급 복구가 아니라 일반 작업을 쓰세요. 실제 장애면 사건을 먼저 등록하세요'); const id = newId('em_'); const now = nowIso(); d.store.run('INSERT INTO emergency_sessions(id, incident_key, reason, scope_json, status, declared_by, declared_at, hal_healthy_at_declare, updated_at) VALUES(?,?,?,?,\'declared\',?,?,?,?)', id, incidentKey, reason, toJson(scope), by, now, health.ok ? 1 : 0, now); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.declare', subjectType: 'emergency', subjectId: id, detail: { incidentKey, reason, scope, forced: force && health.ok }, immutable: true }); return get(d, id); } /** 3단계 승인. 각 단계는 서로 다른 입력을 요구한다(같은 문구 반복 불가). */ export function approve(d, id, { step, by, impactText, identifiers, finalConfirm, revertible }) { const s = get(d, id); if (!['declared', 'approving'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 승인할 수 없습니다`); const n = s.approvals.length + 1; if (step !== n) throw new JobError('wrong_step', `지금은 ${n}단계(${STEP_TITLES[n - 1]})를 진행해야 합니다`); let record; if (n === 1) { if (!impactText || String(impactText).length < 10) throw new JobError('impact_required', '영향 범위를 구체적으로 적어야 합니다(10자 이상)'); record = { step: 1, title: STEP_TITLES[0], impactText, by, at: nowIso() }; } else if (n === 2) { const need = s.scope.slice().sort(); const got = (identifiers || []).slice().sort(); if (JSON.stringify(need) !== JSON.stringify(got)) throw new JobError('identifiers_mismatch', `대상 식별정보가 선언한 범위(${need.join(', ')})와 다릅니다`); record = { step: 2, title: STEP_TITLES[1], identifiers: got, by, at: nowIso() }; } else { if (finalConfirm !== `복구 실행 ${s.incident_key}`) throw new JobError('final_confirm_mismatch', `최종 확인 문구는 "복구 실행 ${s.incident_key}" 이어야 합니다`); if (typeof revertible !== 'boolean') throw new JobError('revertible_required', '되돌리기 가능 여부를 명시해야 합니다'); record = { step: 3, title: STEP_TITLES[2], finalConfirm: true, revertible, by, at: nowIso() }; } const approvals = [...s.approvals, record]; const status = approvals.length === 3 ? 'approved' : 'approving'; d.store.run('UPDATE emergency_sessions SET approvals_json=?, status=?, updated_at=? WHERE id=?', toJson(approvals), status, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: `emergency.approve_${n}`, subjectType: 'emergency', subjectId: id, detail: record, immutable: true }); return get(d, id); } /** 자격 발급(승인 3단계 완료 뒤, 1회 표시, 짧은 유효 시간) */ export function issueCredential(d, id, by) { const s = get(d, id); if (s.status !== 'approved' || s.approvals.length < 3) throw new JobError('approvals_insufficient', `승인이 부족합니다(${s.approvals.length}/3)`); const cred = randomToken(32); const exp = new Date(Date.now() + d.config.emergencyTtlMinutes * 60000).toISOString(); d.store.run('UPDATE emergency_sessions SET credential_hash=?, credential_issued_at=?, credential_expires_at=?, status=\'credential_issued\', updated_at=? WHERE id=?', sha256(cred), nowIso(), exp, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.credential_issue', subjectType: 'emergency', subjectId: id, detail: { expiresAt: exp }, immutable: true }); return { credential: cred, expiresAt: exp }; } function checkCredential(s, credential) { if (!s.credential_hash) throw new JobError('no_credential', '자격이 발급되지 않았습니다'); if (s.credential_revoked_at) throw new JobError('credential_revoked', '자격이 폐기되었습니다'); if (Date.parse(s.credential_expires_at) < Date.now()) throw new JobError('credential_expired', '긴급 자격이 만료되었습니다 — 새로 승인·발급하세요'); if (!safeEqual(sha256(String(credential || '')), s.credential_hash)) throw new JobError('bad_credential', '자격이 올바르지 않습니다'); } /** 행동 수행. 허용 범위 밖·일반 배포는 거부. 실행기(d.emergencyRunner)가 실제 동작을 하고 결과를 돌려준다(테스트는 가짜). */ export async function perform(d, id, { credential, action, target = null, by }) { const s = get(d, id); checkCredential(s, credential); if (!['credential_issued', 'performing'].includes(s.status)) throw new JobError('bad_state', `지금 상태(${s.status})에서는 수행할 수 없습니다`); const scopeOf = ACTIONS[action]; if (!scopeOf) { log(d, id, { action, target, by, result: 'rejected', reason: 'not_an_emergency_action' }); throw new JobError('action_forbidden', `긴급 복구에서 허용되지 않는 행동입니다: ${action} (일반 배포는 HAL 정상화 뒤 HAL 작업으로만)`); } if (!s.scope.includes(scopeOf)) { log(d, id, { action, target, by, result: 'rejected', reason: 'out_of_scope' }); throw new JobError('out_of_scope', `선언한 허용 대상(${s.scope.join(', ')}) 밖의 행동입니다: ${action}`); } let result; try { result = d.emergencyRunner ? await d.emergencyRunner(action, target, s) : { ok: true, simulated: true }; } catch (e) { log(d, id, { action, target, by, result: 'failed', error: e.message }); throw e; } log(d, id, { action, target, by, result: 'ok', detail: result }); d.store.run('UPDATE emergency_sessions SET status=\'performing\', updated_at=? WHERE id=?', nowIso(), id); return result; } function log(d, id, entry) { const s = get(d, id); const actions = [...s.actions, { at: nowIso(), ...entry }]; d.store.run('UPDATE emergency_sessions SET actions_json=?, updated_at=? WHERE id=?', toJson(actions), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: entry.by, action: 'emergency.action', subjectType: 'emergency', subjectId: id, outcome: entry.result === 'ok' ? 'ok' : entry.result === 'rejected' ? 'denied' : 'failed', detail: entry, immutable: true }); } /** 실제 결과 확인 기록 → 자격 폐기 */ export function verifyAndRevoke(d, id, { by, verify }) { const s = get(d, id); if (!['performing', 'credential_issued'].includes(s.status)) throw new JobError('bad_state', '수행 단계가 아닙니다'); const health = core.selfHealth(d); const v = { ...(verify || {}), halHealth: health.ok, at: nowIso() }; d.store.run('UPDATE emergency_sessions SET verify_json=?, credential_revoked_at=?, status=\'verified\', updated_at=? WHERE id=?', toJson(v), nowIso(), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.verify_revoke', subjectType: 'emergency', subjectId: id, detail: v, immutable: true }); return get(d, id); } /** HAL 정상화 뒤 실제 상태와 원하는 상태 비교(모듈 목표·실제 버전 차이, 오프라인 서버) → 차이 목록 */ export function reconcile(d, id, by) { const s = get(d, id); if (s.status !== 'verified') throw new JobError('bad_state', '검증·자격 폐기 뒤에 조정할 수 있습니다'); const drift = d.store.all('SELECT module, server_id, target_version, actual_version, health FROM module_targets WHERE (target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)) OR health=\'down\''); const offline = core.markOffline(d); const r = { at: nowIso(), drift, offlineServers: offline.map((x) => x.id), halHealth: core.selfHealth(d).ok }; d.store.run('UPDATE emergency_sessions SET reconcile_json=?, status=\'reconciled\', updated_at=? WHERE id=?', toJson(r), nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.reconcile', subjectType: 'emergency', subjectId: id, detail: r, immutable: true }); return r; } export function close(d, id, by, note = '') { const s = get(d, id); if (!['reconciled', 'verified', 'declared', 'approving', 'approved'].includes(s.status)) throw new JobError('bad_state', '지금 상태에서는 종료할 수 없습니다'); if (s.credential_hash && !s.credential_revoked_at) d.store.run('UPDATE emergency_sessions SET credential_revoked_at=? WHERE id=?', nowIso(), id); d.store.run('UPDATE emergency_sessions SET status=\'closed\', closed_at=?, closed_by=?, updated_at=? WHERE id=?', nowIso(), by, nowIso(), id); audit(d.store, { actorType: 'operator', actorId: by, action: 'emergency.close', subjectType: 'emergency', subjectId: id, detail: { note }, immutable: true }); return get(d, id); } /** 만료 자격 자동 폐기(주기) */ export function expireCredentials(d) { return d.store.run('UPDATE emergency_sessions SET credential_revoked_at=?, updated_at=? WHERE credential_hash IS NOT NULL AND credential_revoked_at IS NULL AND credential_expires_at