/** 등록 전 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.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.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/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'};}));
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>`;}