/** * agent/index.mjs — Node Agent 진입점·CLI(§5·§28.2·§10.2). * * node agent/index.mjs register --hal --token [--role web|mail|backup|control] [--external --site ] [--label <이름>] * node agent/index.mjs run 서비스 루프: heartbeat(30초) + 작업 long-poll + 재시작 뒤 미완료 작업 재개 * node agent/index.mjs status 등록 상태·HAL 연결 확인 * 비밀(Agent secret)은 데이터 디렉터리의 보관소에만 둔다. 등록 응답의 비밀은 그 자리에서 저장하고 화면에 보이지 않는다. */ import { loadAgentConfig } from './config.mjs'; import { createClient } from './client.mjs'; import { createSys } from './sys.mjs'; import { createRunner } from './runner.mjs'; import { KINDS } from './jobs/index.mjs'; import { sample, facts } from './metrics.mjs'; import { configuredWebmakerInventory, webmakerInventoryBatches } from './webmaker-inventory.mjs'; import { applyHalCa, friendlyFetchError } from './halca.mjs'; // [0.4.1] 사설 CA·읽을 수 있는 연결 오류 import { readFileSync, existsSync, readdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { hostname } from 'node:os'; const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; const log = (...a) => console.log(new Date().toISOString(), ...a); const args = process.argv.slice(2); const cmd = args[0] || 'run'; const opt = (n, d = null) => { const i = args.indexOf('--' + n); return i >= 0 ? (args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true) : d; }; const cfg = loadAgentConfig(); const sys = createSys({ log }); // [0.4.1] HAL 사설 CA: --ca > ARIWEB_HAL_CA > agent.json 의 caFile. 기동 때 이 프로세스의 TLS 기본 CA 에 더한다(유닛의 NODE_EXTRA_CA_CERTS 와 병행). const caFile = (() => { const o = opt('ca'); return o && o !== true ? resolve(String(o)) : cfg.caFile; })(); const caState = applyHalCa(caFile); if (caState.error) console.error(`[agent] HAL CA 적용 실패: ${caState.error}`); if (cmd === 'register') { const hal = opt('hal'); const token = opt('token'); if (!hal || !token) { console.error('--hal 과 --token 이 필요합니다'); process.exit(1); } // [0.4.1] 이미 등록된 Agent 가 있으면 조용히 덮어쓰지 않는다(요구 1 — "이미 등록된 서버" 메시지). --force 는 새 토큰(재등록 허용)으로 바꿀 때만. if (cfg.id && !opt('force')) { console.error(`이미 등록된 서버입니다 — Agent ${cfg.id}(${cfg.kind || 'managed'}${cfg.serverId ? `, 서버 ${cfg.serverId}` : ''}) 가 ${cfg.registeredAt || '이전'} 에 ${cfg.halUrl} 에 등록되었습니다. 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔에서 "재등록 허용" 토큰을 받아 --force 로 실행하세요`); process.exit(2); } const f = facts(sys); f.role = opt('role', 'web'); f.location = opt('location', ''); let r; try { r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }), signal: AbortSignal.timeout(30000) }); } catch (e) { const fe = friendlyFetchError(e, hal); console.error(`등록 실패: ${fe.message}`); process.exit(1); } // 스택 대신 원인·조치 한 줄(이전: TypeError: fetch failed … SELF_SIGNED_CERT_IN_CHAIN) const j = await r.json().catch(() => ({})); if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(j.error === 'server_already_registered' || j.error === 'token_used' ? 3 : 1); } cfg.vault().set('agent-secret', j.secret, { kind: 'agent-secret' }); cfg.save({ id: j.agentId, halUrl: hal, serverId: j.serverId || null, kind: j.kind, siteId: j.siteId || null, groupId: j.groupId || null, registeredAt: new Date().toISOString(), label: opt('label', hostname()), caFile: caFile && existsSync(caFile) ? caFile : null }); console.log(`등록 완료: agent ${j.agentId} (${j.kind}${j.serverId ? `, 서버 ${j.serverId}` : ''}). 비밀은 ${cfg.dataDir} 의 보관소에 저장했습니다. 운영자 승인 뒤 배치가 가능합니다`); } else if (cmd === 'status') { if (!cfg.id) { console.log('등록되지 않음'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') }); try { const r = await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: {}, clockSkewMs: 0 }); console.log(`연결 정상: agent ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}, 대기 작업 ${r.pendingJobs}, 시계 차이 ${client.clockSkewMs}ms${caFile ? `, HAL CA ${caFile}(${caState.via || '미적용'})` : ''}`); } catch (e) { console.error(`HAL 연결 실패: ${e.status ? e.message : friendlyFetchError(e, cfg.halUrl).message}`); process.exit(1); } } else if (cmd === 'run') { if (!cfg.id) { console.error('등록되지 않았습니다 — register 먼저'); process.exit(1); } const client = createClient({ halUrl: cfg.halUrl, agentId: cfg.id, secret: cfg.vault().get('agent-secret') }); const runner = createRunner({ kinds: KINDS, roots: cfg.roots, sys, client, dataDir: cfg.dataDir, log }); let stopping = false; let running = 0; let lastFacts = null; let restartUnit = null; /** 기존 Webmaker 사이트: 운영에 필요한 식별자·상태·도메인·배포 용량만 facts에 싣고 본문·프롬프트·연락처·비밀은 제외한다. */ function inventoryFacts(f) { return configuredWebmakerInventory(f); } /** 2 MiB 요청 한도와 무관하게 전체 인벤토리를 512 KiB 이하 배치로 순차 전송한다. */ async function syncWebmakerInventory(inv) { if (!inv) return; const batches = webmakerInventoryBatches(inv); let offset = 0; for (const body of batches) { const r = await client.request('POST', '/agent/v1/inventory/webmaker', body); offset = Number(r.nextOffset); } if (offset !== inv.sites.length) throw new Error(`Webmaker 인벤토리 전송 수 불일치(${offset}/${inv.sites.length})`); } /** 모듈 상태(heartbeat): facts 의 유닛이 살아 있는지 + current/REVISION.json(없으면 package.json) 버전 */ async function moduleStatus(f) { const out = {}; for (const [m, unit] of Object.entries(f.units || {})) { const appDir = (f.appDirs || {})[m] || join(cfg.roots.srv, m); let version = null; try { version = JSON.parse(readFileSync(join(appDir, 'current', 'REVISION.json'), 'utf8')).version; } catch (_) {} if (!version) { try { version = JSON.parse(readFileSync(join(appDir, 'current', 'package.json'), 'utf8')).version; } catch (_) {} } /* 도입 모듈은 REVISION.json 의 Suite 버전(상류 package.json 버전은 목표와 다르다 — 실제 환경 결함 수정) */ const active = await sys.isActive(unit); out[m] = { version, active, health: active ? 'ok' : 'down' }; } out.agent = { version: VERSION, active: true, health: 'ok' }; out.hawkeye = { version: VERSION, active: true, health: 'ok' }; /* [0.4.1] 0.3.17 에서 바뀐 이름(observability → hawkeye)으로 보고 — 옛 이름 보고가 hawkeye 행을 stale 로 두던 결함 */ if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; } async function heartbeat() { try { if (!lastFacts || Date.now() - lastFacts.at > 10 * 60000) lastFacts = { at: Date.now(), f: inventoryFacts(facts(sys)), inventorySynced: false }; const managed = existsSync(cfg.roots.sites) ? readdirSync(cfg.roots.sites).filter((x) => existsSync(join(cfg.roots.sites, x, 'current'))) : []; const legacy = lastFacts.f.webmakerInventory?.sites?.map((x) => x.id) || []; const sites = new Set([...managed, ...legacy]).size; if (lastFacts.f.webmakerInventory && !lastFacts.inventorySynced) { try { await syncWebmakerInventory(lastFacts.f.webmakerInventory); lastFacts.inventorySynced = true; } catch (e) { log(`[agent] Webmaker 인벤토리 전송 실패: ${e.message}`); } } const heartbeatFacts = { ...lastFacts.f }; delete heartbeatFacts.webmakerInventory; await client.request('POST', '/agent/v1/heartbeat', { version: VERSION, metrics: sample(cfg.roots), modules: await moduleStatus(lastFacts.f), clockSkewMs: client.clockSkewMs, facts: heartbeatFacts, sitesHosted: sites, jobs: { running } }); } catch (e) { log(`[agent] heartbeat 실패: ${e.message}`); } } async function execute(job) { running++; try { const out = await runner.execute(job); if (out?.result?.restartAfterReport) restartUnit = out.result.restartAfterReport; } finally { running--; } if (restartUnit) { const u = restartUnit; restartUnit = null; log(`[agent] 자기 갱신 — ${u} 재시작`); setTimeout(() => sys.systemctl('restart', u, { optional: true }), 1500); } } // 재시작 뒤: 보고 못 한 결과 전송, 미완료 작업 재개 await runner.flushPending(); try { const u = await client.request('GET', '/agent/v1/jobs/unfinished'); for (const j of u.jobs || []) { log(`[agent] 미완료 작업 재개 ${j.id} ${j.kind}`); execute(j); } } catch (e) { log(`[agent] 미완료 작업 조회 실패: ${e.message}`); } await heartbeat(); const hb = setInterval(heartbeat, cfg.heartbeatMs); process.on('SIGTERM', () => { stopping = true; clearInterval(hb); }); log(`[agent] Ariweb Node Agent v${VERSION} ${cfg.id} (${cfg.kind}) → ${cfg.halUrl}${caFile ? ` (HAL CA ${caFile}: ${caState.applied ? caState.via : '미적용'})` : ''}`); while (!stopping) { try { const r = await client.request('GET', '/agent/v1/jobs/next?wait=25', undefined, { timeoutMs: 40000 }); if (r.job) execute(r.job); } catch (e) { log(`[agent] 작업 조회 실패: ${e.message}`); await new Promise((s) => setTimeout(s, 5000)); } } process.exit(0); } else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }