HAL 0.4.1: 서버 등록 TLS 수정·"이미 등록된 서버" 안내, HAL 배포가 HAL 서버의 Node Agent·HawkEye 를 함께 설치해 부트스트랩을 닫음
- hal/tls.mjs: 공개 주소 TLS 신뢰 확인(시스템 CA → 실패면 체인·공개키 핀), 신뢰 앵커(체인 자체 서명 / <data>/tls/hal-ca.pem), 10분 캐시, 핀 설치 명령(curl -k --pinnedpubkey … --hal-pin). 공개 경로 GET /agent/v1/hal-ca.pem · /agent/v1/tls, CLI tls-probe
- deploy/agent-install.sh: --hal-pin(핀으로 CA 받기)·--ca·--insecure, curl --cacert / Node NODE_EXTRA_CA_CERTS / register --ca / 유닛 Environment, 연결 실패 원인·조치 한 줄, 이미 등록된 서버 안내
- agent/halca.mjs + agent/index.mjs·config.mjs: caFile 런타임 적용(tls.setDefaultCACertificates), fetch 실패를 읽을 수 있는 한 줄로(종료 1), 이미 등록(종료 2, --force), HAL 거부(종료 3), heartbeat observability → hawkeye
- core.registerAgent: 같은 서버의 두 번째 토큰 409 server_already_registered / 쓴 토큰 409 token_used("이미 등록된 서버"), 거부 토큰 미소비, 재등록 허용(replace: 기존 Agent 해지, 감사 agent.replace), 마이그레이션 hal_0011_token_replace, API/CLI --replace --json(재인증), ops.js 재등록 확인·핀 안내
- deploy/hal-host-agent.sh(server-create --profile hal → server-token --json → agent-install loopback → server-approve), bootstrap-hal.sh 6/7 단계(--no-agent), CLI server-create·server-approve, SERVER_PROFILES.hal
- core.deploymentStatus/selfHealth.deployment, 배너 "부트스트랩 완료 · 기능 단위 서버 배포 대기: …" / "진행 중", 배포 릴리스 화면 문구, heartbeat canonicalModuleName(0.3.17 잔여 결함)
- 개발 지시 v0.4.1(Oz)·VERSION_HISTORY 0.4.1·README·docs/HAL-AGENT §6·RUNBOOK §11·AI 스킬, 도입 모듈 변경 없음(Webmaker 1.9.0·AriMail 1.1.0)
- test/hal-041.test.mjs RED → GREEN 7/7(실제 자체 서명 TLS 픽스처·별도 프로세스 CLI), 기존 배너·프로필·버전 테스트 갱신, 전체 32파일 통과, 로컬 E2E 보고서 2026-11-06
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
0f5e01a4cb
commit
2b31543a86
+8
-3
@@ -29,6 +29,7 @@ import * as mailsvc from './mailsvc.mjs';
|
||||
import { MODULES, DEPLOY_ORDER, DEPLOYABLE_MODULES, MODULE_GUIDES, MODULE_LINKS, SERVER_PROFILES, descriptor, canonicalModuleName } from './modules/index.mjs';
|
||||
import { registerUi } from './ui.mjs'; // [ops UI 추가] 운영자 콘솔 화면(§25~§34)
|
||||
import { registerAiRoutes } from './ai/routes.mjs';
|
||||
import { ensureTlsInfo, caBundle } from './tls.mjs'; // [0.4.1] 사설 CA·핀 설치 명령
|
||||
import * as settingsStore from './settings/index.mjs'; // 0.4.0 통합 설정 관제
|
||||
import { MANAGED_MODULES, publicCatalog, catalogFor } from './settings/catalog.mjs'; // 0.3.0 AI 채팅룸 운영자 API(연결·방·제안·도구)
|
||||
|
||||
@@ -47,6 +48,9 @@ export function buildApp(d) {
|
||||
app.get('/api/status', () => publicStatus(d));
|
||||
app.get('/status', (ctx) => ctx.html(statusPage(d)));
|
||||
app.get('/agent/v1/install.sh', (ctx) => { const f = join(ROOT, 'deploy', 'agent-install.sh'); return ctx.text(existsSync(f) ? readFileSync(f, 'utf8') : 'echo "install script missing"; exit 1'); });
|
||||
// [0.4.1] 사설 CA·자체 서명 인증서 운영: 설치 스크립트가 핀(--pinnedpubkey)으로 받아 이후 curl·Node 의 CA 로 쓰는 신뢰 앵커 PEM(공개 정보, 비밀 없음). 시스템 CA 로 신뢰되는 주소면 404.
|
||||
app.get('/agent/v1/hal-ca.pem', wrap(async (ctx) => { const info = await ensureTlsInfo(d); const pem = caBundle(info); if (!pem) throw new HttpError(404, 'no_private_ca', info && info.trusted ? 'HAL 공개 주소는 시스템 CA 로 신뢰됩니다 — 별도 CA 가 필요 없습니다' : 'HAL 공개 주소의 신뢰 앵커를 구하지 못했습니다(운영자가 <data>/tls/hal-ca.pem 을 두면 제공합니다)'); ctx.outHeaders['Cache-Control'] = 'no-store'; return ctx.text(pem); }));
|
||||
app.get('/agent/v1/tls', wrap(async (ctx) => { const info = await ensureTlsInfo(d, { force: ctx.query.refresh === '1' }); return { ok: true, tls: { url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caAvailable: !!caBundle(info), caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, probedAt: info.probedAt, error: info.error || null } }; }));
|
||||
|
||||
// ---------------- Agent 프로토콜
|
||||
app.post('/agent/v1/register', wrap(async (ctx) => { const b = await ctx.body(); const r = core.registerAgent(d, { token: b.token, facts: b.facts || {}, version: b.version || '' }); return { ok: true, ...r, halUrl: d.config.publicUrl, serverTime: nowIso() }; }));
|
||||
@@ -61,7 +65,7 @@ export function buildApp(d) {
|
||||
function verifyStreaming(ctx) { const { verifyRequest } = d.serviceauth; return verifyRequest({ method: ctx.method, path: ctx.path + (ctx.url.search || ''), headers: ctx.headers, rawBody: '' }, (keyId) => keyId.startsWith('agent:') ? d.vault.get(`service:${keyId}`) : null); }
|
||||
/** 등록 전 Agent 코드 내려받기: 아직 서명을 만들 수 없으므로 유효한(미사용·미만료) 등록 토큰을 헤더로 확인한다. 토큰은 소비하지 않는다(등록 때 1회 소비). */
|
||||
app.get('/agent/v1/bootstrap-artifact', wrap((ctx) => { const tok = String(ctx.headers['x-registration-token'] || ''); const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(tok)); if (!t || t.used_at || Date.parse(t.expires_at) < Date.now()) throw new HttpError(401, 'bad_token', '등록 토큰이 올바르지 않거나 만료되었습니다'); const version = ctx.query.version || core.bootstrapState(d).release_version; const a = core.artifactPath(d, version, ctx.query.module || 'agent'); if (!a) throw new HttpError(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.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact'].includes(ctx.path)) await agentAuth(ctx); });
|
||||
app.use(async (ctx) => { if (ctx.path.startsWith('/agent/v1/') && !['/agent/v1/register', '/agent/v1/install.sh', '/agent/v1/bootstrap-artifact', '/agent/v1/hal-ca.pem', '/agent/v1/tls'].includes(ctx.path)) await agentAuth(ctx); });
|
||||
app.post('/agent/v1/heartbeat', wrap(async (ctx) => core.heartbeat(d, ctx.agent, await ctx.body())));
|
||||
app.post('/agent/v1/inventory/webmaker', wrap(async (ctx) => core.webmakerInventoryBatch(d, ctx.agent, await ctx.body())));
|
||||
app.get('/agent/v1/jobs/next', wrap(async (ctx) => { const wait = Math.min(25, Number(ctx.query.wait || 0)); const until = Date.now() + wait * 1000; while (true) { const j = core.nextAgentJob(d, ctx.agent.id); if (j || Date.now() >= until || d.stopping) return { ok: true, job: j || null, serverTime: nowIso() }; await new Promise((r) => setTimeout(r, 500)); } }));
|
||||
@@ -132,7 +136,8 @@ export function buildApp(d) {
|
||||
app.get('/ops/api/servers/:id/metrics', (ctx) => ({ ok: true, samples: core.metrics(d, ctx.params.id, { since: ctx.query.since, until: ctx.query.until, limit: Number(ctx.query.limit || 5000) }) }));
|
||||
app.patch('/ops/api/servers/:id', wrap(async (ctx) => { const b = await ctx.body(); if (b.status === 'isolated' || b.role) requireRecent(ctx); if (b.slots !== undefined) { if (b.slots && typeof b.slots === 'object') { b.slots_json = toJson(Object.fromEntries(Object.entries(b.slots).filter(([k, v]) => ['sites', 'mailServices', 'backups', 'webmaker'].includes(k) && Number.isFinite(Number(v))).map(([k, v]) => [k, Number(v)]))); } delete b.slots; } // [0.3.17] 슬롯
|
||||
return { ok: true, server: core.publicServer(d, core.updateServer(d, ctx.params.id, b, ctx.operator.id)) }; }));
|
||||
app.post('/ops/api/servers/:id/token', wrap((ctx) => ({ ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id }) })));
|
||||
// [0.4.1] replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지 — 고위험이라 재인증). 설치 명령은 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
|
||||
app.post('/ops/api/servers/:id/token', wrap(async (ctx) => { const b = await ctx.body(); if (b.replace) requireRecent(ctx); await ensureTlsInfo(d); return { ok: true, ...core.issueRegistrationToken(d, { kind: 'managed', serverId: ctx.params.id, by: ctx.operator.id, replace: !!b.replace }) }; }));
|
||||
app.post('/ops/api/servers/:id/approve', wrap((ctx) => ({ ok: true, server: core.publicServer(d, core.approveServer(d, ctx.params.id, ctx.operator.id)) })));
|
||||
app.post('/ops/api/servers/:id/maintenance', wrap(async (ctx) => { const b = await ctx.body(); const s = core.updateServer(d, ctx.params.id, b.end ? { status: 'ready', accepts_new: b.acceptsNew === false ? 0 : 1, maintenance_note: null } : { status: 'maintenance', accepts_new: 0, maintenance_note: b.note || '' }, ctx.operator.id); return { ok: true, server: core.publicServer(d, s) }; }));
|
||||
app.post('/ops/api/servers/:id/delete', wrap(async (ctx) => { requireRecent(ctx); const b = await ctx.body(); const s = core.getServer(d, ctx.params.id); if (!s) throw new HttpError(404, 'server_not_found', '서버가 없습니다'); threeStep(b, [s.id]); const hosted = (s.capacity && s.capacity.sitesHosted) || 0; if (hosted > 0 && !b.force) throw new HttpError(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 }; }));
|
||||
@@ -219,7 +224,7 @@ export function releasesData(d) {
|
||||
const releases = core.listReleases(d); const approved = releases.filter((r) => r.status === 'approved'); const latest = {};
|
||||
for (const m of DEPLOYABLE_MODULES) { const r = approved.find((x) => MODULES[m].layout === 'agent-feature' ? true : !!x.artifacts[m]); latest[m] = r ? r.version : null; }
|
||||
const servers = Object.fromEntries(core.listServers(d).map((s) => [s.id, { name: s.name, role: s.role, status: s.status, profile: s.profile }]));
|
||||
return { ok: true, releases, running: core.selfHealth(d).bootstrap, 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') };
|
||||
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') };
|
||||
}
|
||||
/** 운영 현황(§26.1) */
|
||||
function overview(d) {
|
||||
|
||||
Reference in New Issue
Block a user