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
+43
-12
@@ -11,7 +11,8 @@ import { notify } from '../lib/notify.mjs';
|
||||
import { redact, safeError } from '../lib/redact.mjs';
|
||||
import { serviceFetch } from '../lib/serviceauth.mjs';
|
||||
import { JobError } from '../lib/jobs.mjs';
|
||||
import { descriptor } from './modules/index.mjs';
|
||||
import { descriptor, canonicalModuleName, MODULES } from './modules/index.mjs';
|
||||
import { installCommand as tlsInstallCommand } from './tls.mjs'; // [0.4.1] 사설 CA 핀 설치 명령
|
||||
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { syncFromHeartbeat } from './mailsvc.mjs';
|
||||
@@ -47,10 +48,24 @@ export function maybeCompleteBootstrap(d, agentId) {
|
||||
if (bootstrapState(d).hal_running_version) { const r = setBootstrap(d, { completed_at: nowIso(), manual_install_locked: 1, first_install: 0 }); audit(d.store, { actorType: 'system', action: 'bootstrap.complete', subjectType: 'agent', subjectId: agentId, immutable: true }); return r; }
|
||||
return bootstrapState(d);
|
||||
}
|
||||
/** HAL 자체 건강(§32.2 4단계) */
|
||||
/**
|
||||
* [0.4.1] 기능 단위 서버 배포 현황(요구 2): 부트스트랩(HAL + HAL 서버의 Node Agent·HawkEye)이 끝난 뒤 아직 배치되지 않은 기능 모듈(포털·Webmaker·AriMail·백업)을 센다.
|
||||
* 화면 배너·/api/health 가 "부트스트랩 미완료" 대신 "배포 대기" 로 보이는 근거. 배치 = 어떤 서버든 module_targets 에 실제 버전이 보고된 행이 있는 것.
|
||||
*/
|
||||
export const FEATURE_MODULES = ['portal', 'webmaker', 'arimail', 'backup'];
|
||||
export function deploymentStatus(d) {
|
||||
const rows = d.store.all('SELECT module, server_id, actual_version, target_version, health, active FROM module_targets');
|
||||
const servers = d.store.all('SELECT id, name, role, status, agent_id FROM servers');
|
||||
const byModule = (m) => rows.filter((r) => r.module === m && (r.actual_version || r.target_version));
|
||||
const items = FEATURE_MODULES.map((m) => { const rs = byModule(m); const deployed = rs.length > 0; const roles = (MODULES[m] && MODULES[m].roles) || []; const candidates = servers.filter((s) => s.agent_id && s.status === 'ready' && roles.includes(s.role)).length; return { module: m, label: MODULES[m] ? MODULES[m].label : m, status: deployed ? 'deployed' : 'pending', servers: rs.map((r) => r.server_id), candidates }; });
|
||||
const agents = servers.filter((s) => s.agent_id).length;
|
||||
return { pending: items.filter((i) => i.status === 'pending'), deployed: items.filter((i) => i.status === 'deployed'), agents, servers: servers.length };
|
||||
}
|
||||
/** HAL 자체 건강(§32.2 4단계). [0.4.1] deployment: 기능 단위 서버 배포 현황(pending 모듈 이름 목록) */
|
||||
export function selfHealth(d) {
|
||||
const b = bootstrapState(d); let dbOk = true; try { d.store.get('SELECT 1 AS x'); } catch (_) { dbOk = false; }
|
||||
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, instanceId: d.instanceId };
|
||||
let deployment = null; try { const ds = deploymentStatus(d); deployment = { pending: ds.pending.map((i) => i.module), deployed: ds.deployed.map((i) => i.module), agents: ds.agents }; } catch (_) { deployment = null; }
|
||||
return { ok: dbOk && !!d.jobsRunning, version: d.version, uptimeSec: Math.round(process.uptime()), db: dbOk, jobs: !!d.jobsRunning, bootstrap: { complete: bootstrapComplete(d), firstInstall: !!b.first_install, releaseVersion: b.release_version, mainCommit: b.main_commit, artifactSha256: b.artifact_sha256, halRunningVersion: b.hal_running_version, firstAgent: b.first_agent_id, manualInstallLocked: !!b.manual_install_locked }, deployment, instanceId: d.instanceId };
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 서버·Agent
|
||||
@@ -107,13 +122,19 @@ export function defaultTargets(d, module) {
|
||||
}
|
||||
/** [0.3.17] 버전 비교(숫자 점 구분). a >= b 이면 true. 비교 불가(없음)면 false. */
|
||||
export function versionGte(a, b) { if (!a || !b) return false; const pa = String(a).split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b).split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const x = pa[i] || 0; const y = pb[i] || 0; if (x !== y) return x > y; } return true; }
|
||||
/** 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다. */
|
||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000 }) {
|
||||
if (kind === 'managed' && serverId && !getServer(d, serverId)) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
/**
|
||||
* 일회용 등록 토큰(10분). 토큰 원문은 이 반환값에서 한 번만 보인다.
|
||||
* [0.4.1] replace: 이미 Agent 가 등록된 서버에 새 Agent 를 등록(기존 Agent 해지)하도록 허용. 없으면 등록 때 server_already_registered 로 거부된다(요구 1 — 이미 등록된 서버 메시지).
|
||||
* [0.4.1] 설치 명령은 HAL 공개 주소의 TLS 신뢰 상태(d.tlsInfo, hal/tls.mjs)에 따라 핀(--pinnedpubkey / --hal-pin)을 붙인다(사설 CA·자체 서명 인증서).
|
||||
*/
|
||||
export function issueRegistrationToken(d, { kind = 'managed', serverId = null, groupId = null, siteId = null, label = '', by = null, ttlMs = 10 * 60 * 1000, replace = false }) {
|
||||
const srv = kind === 'managed' && serverId ? getServer(d, serverId) : null; if (kind === 'managed' && serverId && !srv) throw new JobError('server_not_found', '서버가 없습니다');
|
||||
const token = randomToken(32); const id = newId('rt_'); const expiresAt = new Date(Date.now() + ttlMs).toISOString();
|
||||
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at) VALUES(?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label } });
|
||||
return { id, token, expiresAt, installCommand: `curl -fsSL ${d.config.publicUrl}/agent/v1/install.sh | bash -s -- --hal ${d.config.publicUrl} --token ${token}${kind === 'external' ? ` --external --site ${siteId}` : ''}` };
|
||||
d.store.run('INSERT INTO registration_tokens(id, token_hash, kind, server_id, group_id, site_id, label, created_by, created_at, expires_at, replace_existing) VALUES(?,?,?,?,?,?,?,?,?,?,?)', id, sha256(token), kind, serverId, groupId, siteId, label, by, nowIso(), expiresAt, replace ? 1 : 0);
|
||||
audit(d.store, { actorType: by ? 'operator' : 'system', actorId: by, action: 'agent.token_issue', subjectType: kind === 'external' ? 'site' : 'server', subjectId: siteId || serverId, detail: { kind, label, replace: !!replace } });
|
||||
const existing = srv && srv.agent_id ? getAgent(d, srv.agent_id) : null;
|
||||
const cmd = tlsInstallCommand(d.config.publicUrl, token, { kind, siteId, tlsInfo: d.tlsInfo || null });
|
||||
return { id, token, expiresAt, installCommand: cmd.installCommand, tls: cmd.tls, replace: !!replace, existingAgent: existing && existing.status === 'active' ? { id: existing.id, registeredAt: existing.registered_at, label: existing.label } : null };
|
||||
}
|
||||
/**
|
||||
* Agent 등록(토큰 1회 사용). 비밀은 보관소에 두고 응답에서 한 번만 돌려준다.
|
||||
@@ -122,13 +143,21 @@ export function issueRegistrationToken(d, { kind = 'managed', serverId = null, g
|
||||
export function registerAgent(d, { token, facts = {}, version = '' }) {
|
||||
const t = d.store.get('SELECT * FROM registration_tokens WHERE token_hash=?', sha256(String(token || '')));
|
||||
if (!t) throw new JobError('bad_token', '등록 토큰이 올바르지 않습니다');
|
||||
if (t.used_at) throw new JobError('token_used', '이미 사용한 등록 토큰입니다 — 새 토큰을 발급받으세요');
|
||||
// [0.4.1] 이미 쓴 토큰: 그 토큰으로 등록된 Agent 가 살아 있으면 "이미 등록된 서버" 라고 알린다(같은 명령을 두 번 실행한 경우)
|
||||
if (t.used_at) { const prev = t.used_by_agent ? getAgent(d, t.used_by_agent) : null; const ps = prev && prev.server_id ? getServer(d, prev.server_id) : null; throw new JobError('token_used', prev && prev.status === 'active' ? `이미 등록된 서버입니다 — 이 토큰은 ${t.used_at} 에 Agent ${prev.id}${ps ? `(서버 ${ps.name})` : ''} 등록에 사용되었습니다. 다시 설치할 필요가 없으며, 새로 등록하려면 운영자 콘솔에서 "재등록 허용" 토큰을 발급받으세요` : `이미 사용한 등록 토큰입니다(${t.used_at}) — 새 토큰을 발급받으세요`, { status: 409 }); }
|
||||
if (Date.parse(t.expires_at) < Date.now()) throw new JobError('token_expired', '등록 토큰이 만료되었습니다 — 새 토큰을 발급받으세요');
|
||||
// [0.4.1] 이미 Agent 가 있는 서버(토큰에 묶인 서버, 또는 같은 호스트 이름의 서버): 토큰에 재등록 허용이 없으면 거부 — 조용히 두 번째 Agent 를 만들지 않는다
|
||||
const bound = t.kind === 'managed' && t.server_id ? getServer(d, t.server_id) : null;
|
||||
const sameHost = t.kind === 'managed' && !t.server_id && facts.hostname ? d.store.get('SELECT * FROM servers WHERE name=? AND agent_id IS NOT NULL ORDER BY created_at LIMIT 1', String(facts.hostname)) : null;
|
||||
const target = bound || sameHost; const existing = target && target.agent_id ? getAgent(d, target.agent_id) : null;
|
||||
if (existing && existing.status === 'active' && !t.replace_existing) throw new JobError('server_already_registered', `이미 등록된 서버입니다(서버 ${target.name}, Agent ${existing.id}, 등록 ${existing.registered_at}) — 다시 설치할 필요가 없습니다. 새 Agent 로 바꾸려면 운영자 콘솔 › 서버에서 "재등록 허용" 토큰을 발급받으세요`, { status: 409 });
|
||||
const agentId = newId('ag_'); const secret = randomToken(32); const now = nowIso();
|
||||
return d.store.tx(() => {
|
||||
const used = d.store.run('UPDATE registration_tokens SET used_at=?, used_by_agent=? WHERE id=? AND used_at IS NULL', now, agentId, t.id).changes;
|
||||
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다');
|
||||
if (!used) throw new JobError('token_used', '이미 사용한 등록 토큰입니다', { status: 409 });
|
||||
let serverId = t.server_id;
|
||||
if (!serverId && sameHost) serverId = sameHost.id; // 같은 호스트 이름의 서버가 있고 재등록 허용이면 그 서버를 쓴다
|
||||
if (existing && existing.status === 'active') { revokeAgent(d, existing.id, `replace:${agentId}`); audit(d.store, { actorType: 'system', action: 'agent.replace', subjectType: 'server', subjectId: serverId, detail: { previousAgent: existing.id, newAgent: agentId } }); }
|
||||
if (t.kind === 'managed' && !serverId) { serverId = createServer(d, { name: facts.hostname || agentId, role: facts.role || 'web', location: facts.location || '' }).id; }
|
||||
d.store.run('INSERT INTO agents(id, server_id, kind, secret_hash, status, label, group_id, site_id, version, registered_at, last_seen_at, facts_json) VALUES(?,?,?,?,\'active\',?,?,?,?,?,?,?)', agentId, serverId, t.kind, sha256(secret), t.label || facts.hostname || '', t.group_id, t.site_id, version, now, now, toJson(redact(facts)));
|
||||
d.vault.set(`service:agent:${agentId}`, secret, { kind: 'agent-secret' });
|
||||
@@ -189,7 +218,9 @@ export function heartbeat(d, agent, body) {
|
||||
const serverRow = d.store.get('SELECT facts_json FROM servers WHERE id=?', agent.server_id); const serverFacts = mergeFacts(serverRow?.facts_json); d.store.run('UPDATE servers SET last_seen_at=?, clock_skew_ms=?, agent_version=?, metrics_json=?, capacity_json=?, facts_json=COALESCE(?, facts_json), status=CASE WHEN status=\'offline\' THEN \'ready\' ELSE status END, updated_at=? WHERE id=?', now, skew, body.version || null, toJson(m), toJson(capacity), serverFacts, now, agent.server_id);
|
||||
d.store.run('INSERT OR REPLACE INTO metrics_samples(server_id, at, cpu, mem_used, mem_total, disk_used, disk_total, inode_used, inode_total, io_read, io_write, net_in, net_out, clock_skew_ms, load1) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
agent.server_id, now, m.cpu ?? null, m.mem?.used ?? null, m.mem?.total ?? null, m.disk?.used ?? null, m.disk?.total ?? null, m.inode?.used ?? null, m.inode?.total ?? null, m.io?.read ?? null, m.io?.write ?? null, m.net?.in ?? null, m.net?.out ?? null, skew, m.load1 ?? null);
|
||||
for (const [name, mod] of Object.entries(body.modules || {})) {
|
||||
for (const [rawName, mod] of Object.entries(body.modules || {})) {
|
||||
const name = canonicalModuleName(rawName); // [0.4.1] 옛 이름(observability)으로 보고하는 0.4.0 이하 Agent 도 hawkeye 행으로 모은다(0.3.17 잔여 결함)
|
||||
if (name !== rawName) d.store.run('DELETE FROM module_targets WHERE module=? AND server_id=?', rawName, agent.server_id);
|
||||
d.store.run('INSERT INTO module_targets(module, server_id, actual_version, health, active, last_check_at, updated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(module, server_id) DO UPDATE SET actual_version=excluded.actual_version, health=excluded.health, active=excluded.active, last_check_at=excluded.last_check_at, updated_at=excluded.updated_at',
|
||||
name, agent.server_id, mod.version || null, mod.health || 'unknown', mod.active ? 1 : 0, now, now);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user