// [0.4.1] 사설 CA·자체 서명 인증서 운영: 설치 스크립트가 핀(--pinnedpubkey)으로 받아 이후 curl·Node 의 CA 로 쓰는 신뢰 앵커 PEM(공개 정보, 비밀 없음). 시스템 CA 로 신뢰되는 주소면 404.
app.get('/agent/v1/hal-ca.pem',wrap(async(ctx)=>{constinfo=awaitensureTlsInfo(d);constpem=caBundle(info);if(!pem)thrownewHttpError(404,'no_private_ca',info&&info.trusted?'HAL 공개 주소는 시스템 CA 로 신뢰됩니다 — 별도 CA 가 필요 없습니다':'HAL 공개 주소의 신뢰 앵커를 구하지 못했습니다(운영자가 <data>/tls/hal-ca.pem 을 두면 제공합니다)');ctx.outHeaders['Cache-Control']='no-store';returnctx.text(pem);}));
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
app.get('/agent/v1/bootstrap-artifact',wrap((ctx)=>{consttok=String(ctx.headers['x-registration-token']||'');constt=d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?',sha256(tok));if(!t||t.used_at||Date.parse(t.expires_at)<Date.now())thrownewHttpError(401,'bad_token','등록 토큰이 올바르지 않거나 만료되었습니다');constversion=ctx.query.version||core.bootstrapState(d).release_version;consta=core.artifactPath(d,version,ctx.query.module||'agent');if(!a)thrownewHttpError(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.get('/agent/v1/settings/:module',wrap((ctx)=>{if(!ctx.agent.server_id)thrownewHttpError(403,'not_managed_server','관리 서버의 Agent 만 설정을 받을 수 있습니다');return{ok:true,settings:settingsStore.payloadForAgent(d,ctx.params.module,ctx.agent.server_id)};}));// [0.4.0] 자기 서버 범위의 유효 설정(비밀값 포함) — 다른 서버의 덮어쓰기는 받을 수 없다
app.post('/agent/v1/jobs/:id/result',wrap(async(ctx)=>{constb=awaitctx.body();constok=core.agentJobResult(d,ctx.params.id,ctx.agent.id,b);if(!ok)thrownewHttpError(409,'job_not_running','실행 중인 작업이 아닙니다');return{ok};}));
app.get('/agent/v1/releases/:version/artifact',wrap((ctx)=>{consta=core.artifactPath(d,ctx.params.version,ctx.query.module);if(!a)thrownewHttpError(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);}));
ctx.req.pipe(out);out.on('finish',()=>{constsum=hash.digest('hex');if(expected&&expected!==sum)returnreject(newHttpError(400,'checksum_mismatch','전송 파일 해시가 다릅니다'));core.createTransfer(d,{id,agentId:ctx.agent.id,sha256:sum,size,purpose:String(ctx.query.purpose||'')});resolve({ok:true,id,sha256:sum,size});});
app.post('/portal/v1/jobs',wrap(async(ctx)=>{constb=awaitctx.body();if(!d.jobs.kinds().includes(b.kind)||b.kind.startsWith('module.'))thrownewHttpError(400,'bad_kind',`포털이 요청할 수 없는 작업 종류: ${b.kind}`);const{job,created}=d.jobs.enqueue({kind:b.kind,subject:{target:b.target,payload:b.payload||{},_portal:true},idempotencyKey:b.idempotencyKey||null,createdBy:'portal',actorType:'system',groupId:b.groupId||null,subjectType:b.subjectType||null,subjectId:b.subjectId||null,label:b.label||null});return{ok:true,halJobId:job.id,status:job.status,created};}));
app.post('/portal/v1/external-agents/token',wrap(async(ctx)=>{constb=awaitctx.body();if(!b.siteId||!b.groupId)thrownewHttpError(400,'site_required','siteId·groupId 가 필요합니다');constt=core.issueRegistrationToken(d,{kind:'external',groupId:b.groupId,siteId:b.siteId,label:b.label||'',by:null});return{ok:true,agentId:null,token:t.token,expiresAt:t.expiresAt,installCommand:t.installCommand};}));
app.get('/portal/v1/external-agents/by-site/:siteId',wrap((ctx)=>{consta=d.store.get('SELECT id FROM agents WHERE kind=\'external\' AND site_id=? AND status=\'active\' ORDER BY registered_at DESC LIMIT 1',ctx.params.siteId);return{ok:true,agent:a?core.externalAgentStatus(d,a.id):null};}));
app.get('/portal/v1/external-agents/:id',wrap((ctx)=>{consts=core.externalAgentStatus(d,ctx.params.id);if(!s)thrownewHttpError(404,'agent_not_found','외부 Agent 가 없습니다');return{ok:true,agent:s};}));
if(ctx.path==='/ops/access'){constcode=ctx.query.code;try{constg=ops.bindGrant(d,code,{ua:ctx.headers['user-agent'],ip:ctx.ip});ctx.setCookie(ops.grantCookie(g.cookie,ctx.secure));returnctx.redirect('/ops/');}catch(e){thrownewHttpError(403,'access_denied','접근할 수 없습니다');}}
if(!gate.ok)thrownewHttpError(403,'access_denied','접근할 수 없습니다');// 로그인 화면도 주지 않는다(§25)
ctx.opsVia=gate.via;
consttok=ctx.cookies[session.cookieName('operator')];ctx.session=session.resolveSession(d.store,tok,'operator');ctx.operator=ctx.session?ops.publicAccount(d.store.get('SELECT * FROM ops_accounts WHERE id=?',ctx.session.user_id)):null;
if(ctx.path.startsWith('/ops/api/')&&!['/ops/api/login','/ops/api/setup','/ops/api/me'].includes(ctx.path)){if(!ctx.operator)thrownewHttpError(401,'unauthenticated','운영자 로그인이 필요합니다');if(!['GET','HEAD'].includes(ctx.method)&&!session.csrfOk(ctx.session,ctx.headers['x-csrf']))thrownewHttpError(403,'csrf','보안 토큰이 맞지 않습니다');}
});
constrequireRecent=(ctx)=>{if(!session.recentlyAuthed(ctx.session))thrownewHttpError(403,'reauth_required','고위험 작업은 최근 10분 안의 재인증이 필요합니다',{action:'reauth'});};
/** 고위험 작업 3단계 확인(§25): 영향 범위 확인 → 대상 식별정보 재입력 → 최종 실행 */
constthreeStep=(b,expectIds)=>{if(b.confirmImpact!==true)thrownewHttpError(400,'confirm_impact_required','1단계: 영향 범위를 확인해야 합니다');constids=[].concat(b.confirmIdentifiers||[]).sort();if(JSON.stringify(ids)!==JSON.stringify([].concat(expectIds).sort()))thrownewHttpError(400,'identifiers_mismatch','2단계: 대상 식별정보가 다릅니다');if(b.confirmExecute!==true)thrownewHttpError(400,'confirm_execute_required','3단계: 최종 실행을 확인해야 합니다');};
app.post('/ops/api/setup',wrap(async(ctx)=>{constb=awaitctx.body();constst=core.bootstrapState(d);consttokFile=join(d.config.dataDir,'setup.token');constexpected=existsSync(tokFile)?readFileSync(tokFile,'utf8').trim():'';if(core.ensureOpsAccountExists(d)||!expected||b.setupToken!==expected)thrownewHttpError(403,'setup_closed','최초 설정은 설정 토큰으로 한 번만 할 수 있습니다');consta=ops.createAccount(d,{login:b.login,password:b.password,displayName:b.displayName,by:null});try{(awaitimport('node:fs')).unlinkSync(tokFile);}catch(_){}return{ok:true,account:a,bootstrap:st};}));
app.get('/ops/api/access-grants',(ctx)=>({ok:true,grants:d.store.all('SELECT id, account_id, created_by, created_at, expires_at, bound_at, revoked_at, reason, ip FROM ops_access_grants ORDER BY created_at DESC LIMIT 200')}));
app.get('/ops/api/servers/:id',wrap((ctx)=>{consts=core.getServer(d,ctx.params.id);if(!s)thrownewHttpError(404,'server_not_found','서버가 없습니다');return{ok:true,server:core.publicServer(d,s),jobs:d.jobs.list({subjectType:'server',subjectId:s.id,limit:50}).map(describe),agentJobs:d.store.all('SELECT id, kind, status, progress, created_at, ended_at, error FROM agent_jobs WHERE agent_id=? ORDER BY created_at DESC LIMIT 50',s.agent_id||'')};}));
app.post('/ops/api/servers/:id/delete',wrap(async(ctx)=>{requireRecent(ctx);constb=awaitctx.body();consts=core.getServer(d,ctx.params.id);if(!s)thrownewHttpError(404,'server_not_found','서버가 없습니다');threeStep(b,[s.id]);consthosted=(s.capacity&&s.capacity.sitesHosted)||0;if(hosted>0&&!b.force)thrownewHttpError(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};}));
app.post('/ops/api/mail-services/import',wrap(async(ctx)=>{constb=awaitctx.body();if(b.apply)requireRecent(ctx);constv=mailsvc.validateDiscovery(b.discovery);if(!v.ok)thrownewHttpError(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.get('/ops/api/mail-services/:id',wrap((ctx)=>{consts=mailsvc.getMailService(d,ctx.params.id);if(!s)thrownewHttpError(404,'mail_service_not_found','메일 서비스가 없습니다');return{ok:true,service:s,audit:listAudit(d.store,{subjectType:'mail_service',subjectId:s.id,limit:50})};}));
app.post('/ops/api/provision',wrap(async(ctx)=>{requireRecent(ctx);constb=awaitctx.body();constprof=SERVER_PROFILES[b.profile];if(!prof)thrownewHttpError(400,'bad_profile',`모르는 프로필: ${String(b.profile).slice(0,30)}`);constsrv=core.getServer(d,b.serverId);if(!srv)thrownewHttpError(404,'server_not_found','서버가 없습니다');if(srv.status!=='ready'||!srv.agent_id)thrownewHttpError(409,'server_not_ready',`서버 ${srv.name} 이(가) 준비(Agent 연결·승인) 상태가 아닙니다(${srv.status})`);
constrel=core.getRelease(d,b.version);if(!rel||rel.status!=='approved')thrownewHttpError(400,'release_not_approved',`승인된 릴리스가 아닙니다: ${b.version}`);if(prof.singleton){constowner=d.store.get('SELECT server_id FROM module_targets WHERE module=\'portal\' AND active=1 AND server_id<>? LIMIT 1',srv.id);if(owner)thrownewHttpError(409,'portal_exists',`포털이 이미 다른 서버(${(core.getServer(d,owner.server_id)||{}).name || owner.server_id})에 설치되어 있습니다 — 추가 설치 대신 "포털 이전"을 쓰세요`);}
constr=d.jobs.enqueue({kind:'server.provision',subject:{serverId:srv.id,profile:prof.id,version:b.version},idempotencyKey:`provision:${srv.id}:${prof.id}:${b.version}:${b.nonce||''}`,createdBy:ctx.operator.id,actorType:'operator',subjectType:'server',subjectId:srv.id,label:`${prof.label} 통합 배포 ${b.version} — ${srv.name}`});return{ok:true,job:describe(r.job),created:r.created,modules:prof.modules};}));
app.post('/ops/api/portal/relocate',wrap(async(ctx)=>{requireRecent(ctx);constb=awaitctx.body();threeStep(b,[b.fromServerId,b.toServerId]);constfrom=core.getServer(d,b.fromServerId);constto=core.getServer(d,b.toServerId);if(!from||!to)thrownewHttpError(404,'server_not_found','서버가 없습니다');constr=d.jobs.enqueue({kind:'portal.relocate',subject:{fromServerId:from.id,toServerId:to.id,version:b.version},idempotencyKey:`portal.relocate:${from.id}:${to.id}:${b.version}`,createdBy:ctx.operator.id,actorType:'operator',subjectType:'module',subjectId:'portal',label:`포털 이전 ${from.name} → ${to.name} (${b.version})`});return{ok:true,job:describe(r.job),created:r.created};}));
app.post('/ops/api/modules/:name/health',wrap(async(ctx)=>{constb=awaitctx.body();constr=d.jobs.enqueue({kind:'module.health',subject:{module:ctx.params.name,serverIds:b.serverIds||null},createdBy:ctx.operator.id,actorType:'operator',subjectType:'module',subjectId:ctx.params.name,label:`${ctx.params.name} 건강 확인`});return{ok:true,job:describe(r.job)};}));
app.post('/ops/api/modules/reconcile',wrap(async(ctx)=>{requireRecent(ctx);constb=awaitctx.body();constr=d.jobs.enqueue({kind:'module.reconcile',subject:{module:b.module||null},createdBy:ctx.operator.id,actorType:'operator',label:'목표·실제 상태 맞추기'});return{ok:true,job:describe(r.job)};}));
app.get('/ops/api/jobs/:id',wrap((ctx)=>{constj=d.jobs.get(ctx.params.id);if(!j)thrownewHttpError(404,'job_not_found','작업이 없습니다');return{ok:true,job:{...describe(j),subject:redact(j.subject),result:j.result,log:j.log,createdBy:j.created_by,actorType:j.actor_type,groupId:j.group_id},children:d.jobs.childrenSummary(j.id),agentJobs:d.store.all('SELECT id, agent_id, kind, status, progress, error, user_message, created_at, ended_at FROM agent_jobs WHERE hal_job_id=? ORDER BY created_at',j.id)};}));
app.get('/ops/api/incidents',()=>({ok:true,incidents:d.store.all('SELECT * FROM incidents ORDER BY started_at DESC LIMIT 200'),notifications:listNotifications(d.store,{groupId:null,limit:200})}));
app.post('/ops/api/incidents',wrap(async(ctx)=>{constb=awaitctx.body();constopen=d.store.get('SELECT id FROM incidents WHERE key=? AND resolved_at IS NULL',b.key);if(open){d.store.run('UPDATE incidents SET severity=?, title=?, customer_impact=?, updated_at=? WHERE id=?',b.severity||'warning',b.title,b.customerImpact||null,nowIso(),open.id);return{ok:true,id:open.id,merged:true};}constid=newId('inc_');d.store.run('INSERT INTO incidents(id, key, severity, title, component, customer_impact, started_at, assignee, public, public_note, notes_json, updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,\'[]\',?)',id,b.key,b.severity||'warning',b.title,b.component||null,b.customerImpact||null,nowIso(),ctx.operator.id,b.public?1:0,b.publicNote||null,nowIso());return{ok:true,id};}));
app.patch('/ops/api/incidents/:id',wrap(async(ctx)=>{constb=awaitctx.body();constinc=d.store.get('SELECT * FROM incidents WHERE id=?',ctx.params.id);if(!inc)thrownewHttpError(404,'not_found','사건이 없습니다');if(b.resolve&&!b.verifiedUserPath)thrownewHttpError(400,'verify_required','실제 사용자 경로 확인 뒤에 종료할 수 있습니다(§31.2)');constnotes=parseJson(inc.notes_json,[]);if(b.note)notes.push({at:nowIso(),by:ctx.operator.id,note:b.note});d.store.run('UPDATE incidents SET severity=COALESCE(?, severity), assignee=COALESCE(?, assignee), public=COALESCE(?, public), public_note=COALESCE(?, public_note), notes_json=?, resolved_at=CASE WHEN ? THEN ? ELSE resolved_at END, verified_at=CASE WHEN ? THEN ? ELSE verified_at END, updated_at=? WHERE id=?',b.severity||null,b.assignee||null,b.public==null?null:(b.public?1:0),b.publicNote||null,toJson(notes),b.resolve?1:0,nowIso(),b.resolve?1:0,nowIso(),nowIso(),inc.id);return{ok:true};}));
app.get('/ops/api/dr',()=>({ok:true,readiness:dr.readiness(d),snapshots:dr.listSnapshots(d),restores:d.store.all('SELECT * FROM dr_restores ORDER BY rowid DESC LIMIT 50'),drills:d.store.all('SELECT * FROM dr_drills ORDER BY started_at DESC LIMIT 50')}));
// 포털 운영 화면 중계(§25·§26.2): 포털의 /internal/ops/* 를 운영자 세션으로 대신 부른다
app.route('*','/ops/api/portal/*',wrap(async(ctx)=>{constsecret=d.vault.get('service:hal');if(!secret)thrownewHttpError(503,'portal_not_linked','포털 연동 키가 없습니다');if(!['GET','HEAD'].includes(ctx.method))requireRecent(ctx);constrest=ctx.path.replace(/^\/ops\/api\/portal/,'')+(ctx.url.search||'');constbody=['GET','HEAD'].includes(ctx.method)?undefined:{...(awaitctx.body()),_operator:{id:ctx.operator.id,login:ctx.operator.login}};const{serviceFetch}=d.serviceauth;constr=awaitserviceFetch(d.config.portalUrl,'hal',secret,ctx.method,'/internal/ops'+rest,body,{timeoutMs:20000,fetchImpl:d.fetch||fetch}).catch((e)=>{thrownewHttpError(503,'portal_unreachable','포털에 연결할 수 없습니다 — 정보 확인 지연');});ctx.status=r.status;returnr.json||{ok:false,error:'portal_bad_response'};}));
if(!ids.length){ids=placed.filter((s)=>s.status!=='isolated').map((s)=>s.id);if(!ids.length)thrownewHttpError(400,'no_targets',`${module} 이(가) 배치된 서버가 없습니다 — 먼저 모듈을 배포하세요`);}
constnotPlaced=ids.filter((id)=>!placedIds.includes(id));if(notPlaced.length)thrownewHttpError(400,'not_placed',`${module} 이(가) 배치되지 않은 서버: ${notPlaced.join(', ')}`);
constr=d.jobs.enqueue({kind:'settings.apply',subject:{module,serverIds:ids,restart:rs},idempotencyKey:`settings.apply:${module}:${ids.slice().sort().join(',')}:${hashes}:${rs?1:0}:${nonce||''}`,createdBy:by,actorType,subjectType:'module',subjectId:module,label:`${cat.label} 설정 적용`});
return{ok:true,releases,running:core.selfHealth(d).bootstrap,deployment:core.selfHealth(d).deployment,modules:DEPLOYABLE_MODULES,deployOrder:DEPLOY_ORDER,latest,guides:MODULE_GUIDES,links:MODULE_LINKS,profiles:Object.values(SERVER_PROFILES),servers,targets:d.store.all('SELECT * FROM module_targets ORDER BY module, server_id')};
constmodState=(m)=>{constt=d.store.all('SELECT health, actual_version, last_check_at FROM module_targets WHERE module=?',m);if(!t.length)returnnull;conststale=t.some((x)=>!x.last_check_at||Date.now()-Date.parse(x.last_check_at)>10*60*1000);return{ok:t.every((x)=>x.health==='ok'),stale,versions:[...newSet(t.map((x)=>x.actual_version))]};};
incidents:d.store.all('SELECT id, key, severity, title, started_at, customer_impact FROM incidents WHERE resolved_at IS NULL ORDER BY started_at DESC'),drift:d.store.all('SELECT module, server_id, target_version, actual_version FROM module_targets WHERE target_version IS NOT NULL AND (actual_version IS NULL OR actual_version<>target_version)'),dr:dr.readiness(d),emergencyOpen:d.store.all('SELECT id, status, incident_key FROM emergency_sessions WHERE status<>\'closed\''),generatedAt:nowIso()};
}
/** 공개 상태(§31.3): 내부 이름·고객 정보 없음. 고객 영향이 없는 내부 경고는 올리지 않는다. */
return{ok:true,generatedAt:o.generatedAt,components:o.components.map((c)=>({name:label[c.name]||c.name,status:c.status==='ok'?'ok':c.status==='down'?'down':'unknown',label:c.status==='ok'?'정상':c.status==='down'?'장애':'확인 중'})),incidents:o.incidents.filter((i)=>d.store.get('SELECT public FROM incidents WHERE id=?',i.id)?.public).map((i)=>({severity:i.severity,title:i.title,startedAt:i.started_at,note:d.store.get('SELECT public_note FROM incidents WHERE id=?',i.id)?.public_note||''})),maintenance:d.store.all('SELECT name, maintenance_note FROM servers WHERE status=\'maintenance\'').map((s)=>({note:s.maintenance_note||'계획된 유지보수'}))};
}
functionstatusPage(d){consts=publicStatus(d);return`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Ariweb 서비스 상태</title><style>body{font-family:-apple-system,"Noto Sans KR",sans-serif;max-width:720px;margin:40px auto;padding:0 16px;color:#1f2933}li{padding:8px 0;border-bottom:1px solid #eee}.ok{color:#1a7f4b}.down{color:#b3261e}.unknown{color:#b26a00}</style></head><body><h1>Ariweb 서비스 상태</h1><p>갱신 ${esc(s.generatedAt)}</p><ul>${s.components.map((c)=>`<li><b class="${c.status}">${c.status==='ok'?'●':c.status==='down'?'✘':'◔'}${esc(c.label)}</b> — ${esc(c.name)}</li>`).join('')}</ul><h2>진행 중인 사건</h2>${s.incidents.length?`<ul>${s.incidents.map((i)=>`<li><b>${esc(i.title)}</b> (${esc(i.startedAt)})<br>${esc(i.note)}</li>`).join('')}</ul>`:'<p>진행 중인 사건이 없습니다.</p>'}<h2>계획된 유지보수</h2>${s.maintenance.length?`<ul>${s.maintenance.map((m)=>`<li>${esc(m.note)}</li>`).join('')}</ul>`:'<p>예정된 유지보수가 없습니다.</p>'}</body></html>`;}