2026-10-26 11:58:15 +00:00
/**
* 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 <id> [--hours 8] 허용 네트워크 밖 기기용 접근권 URL 발급(§25)
* 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> 부트스트랩 때 선택한 릴리스 정보를 기록
2026-10-27 05:25:05 +00:00
* 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, 비밀 없음)
2026-10-26 11:58:15 +00:00
*/
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' ;
2026-10-27 05:25:05 +00:00
import * as mailsvc from './mailsvc.mjs' ;
2026-10-26 11:58:15 +00:00
import { purgeSessions } from '../lib/session.mjs' ;
import { randomToken } from '../lib/crypto.mjs' ;
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 });
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' )); const dir = join ( core . artifactDir ( d ), mf . version ); mkdirSync ( dir , { recursive : true });
for ( const [ m , a ] of Object . entries ( mf . artifacts )) { const src = join ( dirname ( opt ( 'manifest' )), a . file ); if ( existsSync ( src )) copyFileSync ( src , join ( dir , basename ( a . file ))); }
const r = core . registerRelease ( d , { version : mf . version , mainCommit : mf . mainCommit , builtAt : mf . builtAt , artifacts : Object . fromEntries ( Object . entries ( mf . artifacts ). map (([ m , a ]) => [ m , { file : basename ( a . file ), sha256 : a . sha256 , size : a . size }])), checks : mf . checks || null , approve : !! opt ( 'approve' ), by : opt ( 'by' , 'cli' ) });
console . log ( `릴리스 ${ r . version } 등록( ${ r . status } ) — artifact ${ Object . keys ( r . artifacts ). join ( ', ' ) } ` );
} else if ( cmd === 'link-portal' ) {
2026-10-26 13:13:39 +00:00
// [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]))" <값>)` );
2026-10-26 11:58:15 +00:00
} else if ( cmd === 'bootstrap-release' ) {
core . setBootstrap ( d , { release_version : opt ( 'version' ), main_commit : opt ( 'commit' ), artifact_sha256 : opt ( 'sha256' ) }); console . log ( '부트스트랩 릴리스 정보를 기록했습니다' );
2026-10-27 05:25:05 +00:00
} 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 ));
2026-10-26 11:58:15 +00:00
} 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 );
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 ); }