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:
zzabisagent-lab
2026-11-06 08:40:09 +00:00
co-authored by Claude Fable 5.1
parent 0f5e01a4cb
commit 2b31543a86
34 changed files with 4639 additions and 72 deletions
+2 -1
View File
@@ -14,7 +14,8 @@
## 1. HAL 자체 건강 (`hal_health`)
- `ok` : DB 와 작업 엔진이 모두 살아 있을 때만 true. false 면 일반 배포 작업이 거부된다(플랫폼 복구 화면).
- `version` : 실행 중인 HAL 코드 버전(package.json). `bootstrap.halRunningVersion` 과 같아야 정상.
- `bootstrap.complete` : HAL 설치 + 첫 Node Agent 등록·승인이 끝났는지. false 면 포털 API·일반 모듈 배포가 열리지 않는다.
- `bootstrap.complete` : HAL 설치 + HAL 서버의 Node Agent 등록·승인이 끝났는지(0.4.1 부터 bootstrap-hal.sh 가 함께 설치). false 면 포털 API·일반 모듈 배포가 열리지 않는다.
- `deployment.pending` : [0.4.1] 아직 어떤 서버에도 배치되지 않은 기능 모듈(portal·webmaker·arimail·backup) — "기능 단위 서버 배포 대기". `deployment.agents` 는 Agent 가 있는 서버 수.
- `bootstrap.releaseVersion / mainCommit / artifactSha256` : 부트스트랩 때 선택한 릴리스. `manualInstallLocked` true 면 수동 설치 잠김(HAL 전용 운영).
- `instanceId` : "하나의 HAL" 임대 소유자. 재시작하면 바뀐다.
+8 -3
View File
@@ -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) {
+43 -12
View File
@@ -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);
}
+23 -3
View File
@@ -30,8 +30,9 @@ import * as mailsvc from './mailsvc.mjs';
import { purgeSessions } from '../lib/session.mjs';
import { audit } from '../lib/audit.mjs';
import { describe } from '../lib/jobs.mjs';
import { descriptor } from './modules/index.mjs';
import { descriptor, profileOf } from './modules/index.mjs';
import { randomToken } from '../lib/crypto.mjs';
import { ensureTlsInfo, probePublicTls } from './tls.mjs'; // [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀)
import { writeFileSync, readFileSync, copyFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename } from 'node:path';
@@ -62,9 +63,26 @@ if (cmd === 'setup-token') {
core.setBootstrap(d, { release_version: opt('version'), main_commit: opt('commit'), artifact_sha256: opt('sha256') }); console.log('부트스트랩 릴리스 정보를 기록했습니다');
} else if (cmd === 'server-token') {
// 운영자 콘솔(네트워크 관문·운영자 세션)이 닿지 않는 부트스트랩·연동 단계에서 HAL 데이터 소유자가 발급한다. 토큰은 1회 표시, 감사 기록(actor cli:<by>).
// [0.4.1] --replace: 이미 Agent 가 있는 서버의 재등록 허용(기존 Agent 해지). --json: 스크립트(부트스트랩)가 읽는 한 줄 JSON. 설치 명령은 공개 주소 TLS 신뢰 상태를 먼저 확인해 핀을 붙인다.
const sid = opt('server'); if (!sid || !core.getServer(d, sid)) { console.error('--server <srv_id> 가 필요합니다(없는 서버)'); process.exit(1); }
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}` });
console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}`);
await ensureTlsInfo(d);
const t = core.issueRegistrationToken(d, { kind: 'managed', serverId: sid, by: `cli:${opt('by', 'cli')}`, replace: !!opt('replace') });
if (opt('json')) console.log(JSON.stringify({ token: t.token, expiresAt: t.expiresAt, installCommand: t.installCommand, tls: t.tls, replace: t.replace, existingAgent: t.existingAgent }));
else console.log(`등록 토큰(1회 표시, ${t.expiresAt} 까지, 공유 금지): ${t.token}\n설치: ${t.installCommand}${t.tls && t.tls.trusted === false ? `\n(HAL 공개 주소의 인증서가 시스템 CA 로 신뢰되지 않아(${t.tls.reason}) 핀을 붙였습니다 — 설치 스크립트가 핀으로 HAL 의 CA 를 받아 씁니다)` : ''}${t.existingAgent && !t.replace ? `\n주의: 이 서버에는 이미 Agent ${t.existingAgent.id} 가 있습니다 — 이 토큰으로는 등록이 거부됩니다(--replace 로 재등록 허용)` : ''}`);
} else if (cmd === 'server-create') {
// [0.4.1] 부트스트랩이 HAL 서버 자신을 관리 서버(control)로 만들 때 쓴다(운영자 콘솔이 아직 없는 단계). 출력은 한 줄 JSON.
const name = opt('name'); const role = opt('role', 'control'); if (!name || name === true) { console.error('--name <이름> 이 필요합니다'); process.exit(1); }
const by = `cli:${opt('by', 'cli')}`; let s;
try { s = core.createServer(d, { name, role, location: opt('location', '') === true ? '' : opt('location', ''), by }); } catch (e) { console.error(`서버 생성 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
const profile = opt('profile'); if (profile && profile !== true) { const prof = profileOf(profile); if (!prof) { console.error(`알 수 없는 프로필: ${profile}`); process.exit(1); } core.updateServer(d, s.id, { profile: prof.id, role: prof.role, slots_json: JSON.stringify(prof.slots || {}) }, by); s = core.getServer(d, s.id); }
console.log(JSON.stringify({ id: s.id, name: s.name, role: s.role, status: s.status, profile: s.profile || null }));
} else if (cmd === 'server-approve') {
// [0.4.1] 부트스트랩이 HAL 서버의 첫 Agent 를 승인해 부트스트랩을 닫는다(사람 결정은 bootstrap-hal.sh 실행 자체). 감사 actor cli:<by>.
const sid = opt('server'); if (!sid || sid === true) { console.error('--server <srv_id> 가 필요합니다'); process.exit(1); }
try { const s = core.approveServer(d, sid, `cli:${opt('by', 'cli')}`); const h = core.selfHealth(d); console.log(JSON.stringify({ id: s.id, name: s.name, status: s.status, agentId: s.agent_id, bootstrapComplete: h.bootstrap.complete, pendingModules: h.deployment ? h.deployment.pending : [] })); } catch (e) { console.error(`승인 실패: ${e.code || ''} ${e.message}`); process.exit(1); }
} else if (cmd === 'tls-probe') {
// [0.4.1] 공개 주소 TLS 신뢰 상태 readback(핀·신뢰 앵커 유무, 비밀 없음)
const info = await probePublicTls(opt('url') && opt('url') !== true ? opt('url') : cfg.publicUrl, { dataDir: cfg.dataDir }); console.log(JSON.stringify({ url: info.url, trusted: info.trusted, reason: info.reason || null, pin: info.pin || null, caCount: (info.ca || []).length, caSource: info.caSource || null, subject: info.subject || null, issuer: info.issuer || null, validTo: info.validTo || null, error: info.error || null }, null, 1));
} else if (cmd === 'mail-import') {
const file = opt('discovery'); if (!file) { console.error('--discovery <json> 이 필요합니다'); process.exit(1); }
const doc = JSON.parse(readFileSync(file, 'utf8')); const v = mailsvc.validateDiscovery(doc); if (!v.ok) { console.error(`discovery 검증 실패:\n - ${v.errors.join('\n - ')}`); process.exit(1); }
@@ -109,6 +127,8 @@ if (cmd === 'setup-token') {
every(3600000, () => { core.purgeMetrics(d); purgeSessions(d.store); });
every(60000, () => { const c = dr.getConfig(d); if (!c.snapshot_interval_minutes) return; const last = d.store.get('SELECT created_at FROM dr_snapshots WHERE verify_state=\'ok\' ORDER BY created_at DESC LIMIT 1'); if (!last || Date.now() - Date.parse(last.created_at) >= c.snapshot_interval_minutes * 60000) dr.snapshot(d, { kind: 'scheduled' }); });
await app.listen(cfg.port, cfg.host);
// [0.4.1] 공개 주소 TLS 신뢰 확인(설치 명령 핀·CA 배포 근거) — 기동 직후 1회, 이후 10분마다(인증서 갱신 반영)
ensureTlsInfo(d).then((t) => log(`[hal] 공개 주소 TLS: ${t.trusted === true ? '신뢰됨' : t.trusted === false ? `시스템 CA 로 신뢰되지 않음(${t.reason}) — 설치 명령에 핀을 붙입니다` : `확인 불가(${t.error || t.reason})`}`)); every(600000, () => ensureTlsInfo(d, { force: true }));
log(`[hal] Ariweb HAL v${VERSION} — http://${cfg.host}:${cfg.port} (공개 ${cfg.publicUrl}, 데이터 ${cfg.dataDir}, 부트스트랩 ${core.bootstrapComplete(d) ? '완료' : '진행 중'})`);
process.on('SIGTERM', () => { d.stopping = true; d.jobs.stop(); setTimeout(() => process.exit(0), 500); });
} else { console.error(`알 수 없는 명령: ${cmd}`); process.exit(1); }
+3 -1
View File
@@ -53,7 +53,7 @@ export function descriptor(name) { const d = MODULES[canonicalModuleName(name)];
* [0.3.17] 모듈 안내(릴리스 화면·AI 스킬): 무엇을 하는지, 어떤 역할 서버에, 무엇이 먼저 있어야 하는지(requires), 무엇을 제공하는지(provides), 설치 방법.
*/
export const MODULE_GUIDES = {
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: '서버 등록 → 일회용 등록 정보로 Agent 설치(bootstrap) → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
agent: { label: 'Node Agent', description: '각 관리 서버에서 HAL 의 작업(설치·갱신·건강 확인·백업·지표)을 수행하는 상주 프로세스. 다른 모든 모듈은 Agent 를 통해 배포된다.', roles: MODULES.agent.roles, requires: ['hal'], provides: ['module.install', 'heartbeat', 'metrics', 'backup'], install: 'HAL 서버의 Agent 는 부트스트랩(bootstrap-hal.sh)이 함께 설치·승인한다(0.4.1). 다른 서버는 서버 등록 → 일회용 등록 정보(HAL 인증서가 사설 CA 면 핀이 붙은 명령)로 Agent 설치 → 승인. 갱신은 이 화면의 배포(자기 갱신: 설치 뒤 자동 재시작).', singleton: false },
portal: { label: '고객 포털', description: '고객 가입·상품·사이트·메일·도메인·백업·결제를 하나로 잇는 사용자 접근 화면과 API. HAL 과 서비스 키로 연결되어 작업을 HAL 에 맡긴다.', roles: MODULES.portal.roles, requires: ['agent', 'hal 연동 키(link-portal)'], provides: ['고객 화면 my.ariweb.net', '/internal/ops 운영 중계'], install: '포털 서버 프로필로 통합 배포하거나 이 화면에서 portal 배포. 포털은 **하나만** 운영한다 — 이미 있으면 설치 대신 "포털 이전"을 쓴다(새 서버 설치 → 데이터·DNS 전환 확인 → 옛 서버 비활성).', singleton: true },
webmaker: { label: 'Webmaker', description: 'AI 사이트 제작 도구(도입 모듈). 제작 화면·직접 편집·배포 요청을 제공하고, 사용자 사이트는 웹호스팅 서버로 배포된다.', roles: MODULES.webmaker.roles, requires: ['agent', 'portal(SSO·배포 연동)', 'webhosting 서버 1대 이상(배포 대상)'], provides: ['사이트 제작·편집', '배포 요청'], install: 'Webmaker 서버 프로필(이 서버에서는 Webmaker 만 동작)로 통합 배포하거나 web 역할 서버에 배포.', singleton: false },
arimail: { label: 'AriMail', description: '메일 서비스(도입 모듈): 웹메일 접속, POP·IMAP·SMTP, 도메인별 메일함. HAL 관리형 메일 서비스 등록부와 연결된다.', roles: MODULES.arimail.roles, requires: ['agent', 'mail 역할 서버', 'DNS(MX·SPF·DKIM)'], provides: ['webmail', 'POP/IMAP/SMTP'], install: 'AriMail 서버 프로필로 통합 배포하거나 mail 역할 서버에 배포. 기존 AriMail 인스턴스는 mail-import 로 가져온다.', singleton: false },
@@ -79,5 +79,7 @@ export const SERVER_PROFILES = {
arimail: { id: 'arimail', label: 'AriMail 서버', role: 'mail', modules: ['agent', 'arimail', 'hawkeye'], description: '사용자에게 메일 서비스(웹메일·POP·IMAP·SMTP)를 제공합니다.', slots: { mailServices: 20 } },
backup: { id: 'backup', label: '백업 서버', role: 'backup', modules: ['agent', 'backup', 'hawkeye'], description: '사용자 서비스의 백업을 저장·검증합니다. 운영 서버와 분리를 권장합니다.', slots: { backups: 500 } },
portal: { id: 'portal', label: '포털 서버', role: 'control', modules: ['agent', 'portal', 'hawkeye'], description: '사용자 접근을 위한 포털. 하나만 운영하며, 이미 있으면 설치 대신 이전(relocate)합니다.', slots: {}, singleton: true },
// [0.4.1] HAL 서버 자신: 부트스트랩(bootstrap-hal.sh)이 HAL 과 함께 Node Agent·HawkEye 를 설치·등록·승인해 부트스트랩을 닫는다. Web·Mail·Backup·Portal 은 포함하지 않는다(요구 2).
hal: { id: 'hal', label: 'HAL 서버', role: 'control', modules: ['agent', 'hawkeye'], description: 'HAL(제어 평면)이 있는 서버. 부트스트랩 때 Node Agent 와 HawkEye 가 함께 설치되어 HAL 서버 자체의 지표·건강이 운영 현황에 보입니다. 기능 모듈(포털·Webmaker·AriMail·백업)은 역할별 서버에 따로 배포합니다.', slots: {} },
};
export const profileOf = (id) => SERVER_PROFILES[id] || null;
+2
View File
@@ -140,4 +140,6 @@ DELETE FROM modules WHERE name='observability';
CREATE TABLE IF NOT EXISTS managed_settings (scope TEXT NOT NULL, module TEXT NOT NULL, key TEXT NOT NULL, value_json TEXT, is_secret INTEGER NOT NULL DEFAULT 0, updated_by TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(scope, module, key));
CREATE TABLE IF NOT EXISTS managed_settings_state (server_id TEXT NOT NULL, module TEXT NOT NULL, applied_hash TEXT, applied_at TEXT, status TEXT NOT NULL DEFAULT 'unknown', error TEXT, job_id TEXT, updated_at TEXT NOT NULL, PRIMARY KEY(server_id, module));
` },
// [0.4.1] 등록 토큰의 재등록 허용 표시: 이미 Agent 가 있는 서버에 새 Agent 를 등록할 때(기존 Agent 해지) 운영자가 명시해야 한다 — 없으면 server_already_registered 로 거부
{ id: 'hal_0011_token_replace', sql: `ALTER TABLE registration_tokens ADD COLUMN replace_existing INTEGER NOT NULL DEFAULT 0;` },
];
+79
View File
@@ -0,0 +1,79 @@
/**
* hal/tls.mjs — [0.4.1] HAL 공개 주소(publicUrl)의 TLS 신뢰 상태 확인과 사설 CA 배포.
*
* 왜 필요한가(개발 지시 v0.4.1 요구 1): 사설 CA·자체 서명 인증서로 HAL 을 운영하면(예: hal.local.test) 새 서버의 curl·Node 가 HAL 인증서를 믿지 못해
* 설치 스크립트 내려받기(curl 60)와 등록(fetch failed: SELF_SIGNED_CERT_IN_CHAIN)이 모두 실패했다. 이 모듈은 HAL 이 스스로 자기 공개 주소에 접속해
* (1) 시스템 CA 로 신뢰되는지, (2) 아니면 서버 공개키 핀(curl --pinnedpubkey 형식 `sha256//<base64>`)과 신뢰 앵커(체인 안의 자체 서명 인증서) PEM 을 구한다.
* 운영자가 만든 설치 명령은 핀을 붙여 내려받고(`curl -k --pinnedpubkey`), 설치 스크립트는 핀으로 `/agent/v1/hal-ca.pem` 을 받아 그 뒤 모든 curl·Node 호출에 CA 로 쓴다.
*
* 비밀 없음: 인증서·공개키·핀은 모두 공개 정보다. 개인키는 읽지 않는다.
* 결과 모양: { url, scheme, host, port, probedAt, trusted: true|false|null, reason, pin, ca: [PEM…], caSource: 'file'|'chain'|null, subject, issuer, validTo, error }
* trusted null = 접속 자체가 안 됨(DNS·포트) — 설치 명령은 신뢰로 가정하지 않고 핀 없이 만들되 경고를 함께 돌려준다.
* 운영자 지정 CA: <dataDir>/tls/hal-ca.pem 이 있으면 체인에서 뽑은 것보다 우선한다(루트를 체인에 싣지 않는 사설 CA 구성용).
*/
import tls from 'node:tls';
import { X509Certificate, createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/** curl --pinnedpubkey 와 같은 핀: SubjectPublicKeyInfo(DER) 의 sha256 을 base64 로 */
export function spkiPin(x509) { return 'sha256//' + createHash('sha256').update(x509.publicKey.export({ type: 'spki', format: 'der' })).digest('base64'); }
/** 체인에서 신뢰 앵커로 쓸 수 있는 것: 자체 서명(issuer == subject) 인증서. 자체 서명 leaf 도 포함된다. */
export function trustAnchorsFromChain(chainPems) { return chainPems.filter((pem) => { try { const x = new X509Certificate(pem); return x.issuer === x.subject; } catch (_) { return false; } }); }
/** 운영자 지정 CA 파일 경로 */
export const caFilePath = (dataDir) => join(dataDir, 'tls', 'hal-ca.pem');
/**
* 공개 주소에 접속해 신뢰 상태를 확인한다. http:// 는 TLS 가 없으니 trusted=true(핀 없음).
* 두 번 접속한다: ① 기본 CA 로 검증(authorized), ② 검증 끄고 체인·핀 수집(검증 실패일 때만).
*/
export async function probePublicTls(publicUrl, { timeoutMs = 5000, dataDir = null, connect = tls.connect } = {}) {
let u; try { u = new URL(publicUrl); } catch (_) { return { url: publicUrl, scheme: null, trusted: null, reason: 'bad_url', pin: null, ca: [], caSource: null, probedAt: new Date().toISOString(), error: '공개 주소가 올바르지 않습니다' }; }
const base = { url: publicUrl, scheme: u.protocol.replace(':', ''), host: u.hostname, port: Number(u.port || (u.protocol === 'https:' ? 443 : 80)), probedAt: new Date().toISOString(), pin: null, ca: [], caSource: null, subject: null, issuer: null, validTo: null, error: null };
if (u.protocol !== 'https:') return { ...base, trusted: true, reason: 'plain_http' };
const fileCa = dataDir && existsSync(caFilePath(dataDir)) ? readFileSync(caFilePath(dataDir), 'utf8') : null;
const once = (rejectUnauthorized) => new Promise((resolve) => {
let done = false; const fin = (v) => { if (!done) { done = true; try { sock.destroy(); } catch (_) {} resolve(v); } };
const sock = connect({ host: base.host, port: base.port, servername: base.host, rejectUnauthorized, ...(fileCa && rejectUnauthorized ? { ca: fileCa } : {}) }, () => {
const chain = []; let c = sock.getPeerCertificate(true); const seen = new Set();
while (c && c.raw && !seen.has(c.fingerprint256)) { seen.add(c.fingerprint256); chain.push(new X509Certificate(c.raw)); c = c.issuerCertificate; }
fin({ ok: true, authorized: sock.authorized, authorizationError: sock.authorizationError ? String(sock.authorizationError.code || sock.authorizationError) : null, chain });
});
sock.setTimeout(timeoutMs, () => fin({ ok: false, error: 'timeout' })); sock.on('error', (e) => fin({ ok: false, error: e.code || e.message }));
});
const r = await once(true);
if (!r.ok) {
// 검증 때문에 끊긴 게 아니라 접속 자체가 안 된 경우(DNS·포트). 핸드셰이크 거부(인증서 오류)는 rejectUnauthorized=true 에서도 콜백이 아니라 error 로 올 수 있어 ② 로 확인한다.
const r2 = await once(false); if (!r2.ok) return { ...base, trusted: null, reason: r.error, error: `HAL 공개 주소에 접속할 수 없습니다(${r.error})` };
return describe(base, r2, false, r.error, fileCa);
}
if (r.authorized) return describe(base, r, true, null, fileCa);
return describe(base, r, false, r.authorizationError, fileCa);
}
function describe(base, r, trusted, reason, fileCa) {
const leaf = r.chain[0]; const pems = r.chain.map((x) => x.toString());
const anchors = fileCa ? [fileCa.trim() + '\n'] : trustAnchorsFromChain(pems);
return { ...base, trusted, reason: trusted ? (fileCa ? 'trusted_by_file_ca' : 'system_ca') : reason, pin: leaf ? spkiPin(leaf) : null, ca: trusted && !fileCa ? [] : anchors, caSource: fileCa ? 'file' : anchors.length ? 'chain' : null, subject: leaf?.subject || null, issuer: leaf?.issuer || null, validTo: leaf?.validTo || null };
}
/** CA 번들 텍스트(설치 스크립트·Agent 가 --cacert / NODE_EXTRA_CA_CERTS 로 쓴다). 없으면 null */
export function caBundle(info) { return info && info.ca && info.ca.length ? info.ca.join('') : null; }
/**
* deps 에 캐시된 확인 결과(d.tlsInfo). 10분 지나면 다시 확인. 실패해도 예외 없이 trusted=null 결과를 둔다.
* 운영자 API(토큰 발급)·CLI(server-token)·기동 때 부른다. 테스트는 d.tlsInfo 를 직접 둘 수 있다.
*/
export async function ensureTlsInfo(d, { maxAgeMs = 10 * 60 * 1000, force = false } = {}) {
const cur = d.tlsInfo; if (!force && cur && Date.now() - Date.parse(cur.probedAt) < maxAgeMs) return cur;
if (d.tlsProbe) return d.tlsProbe; // 동시 호출은 같은 확인을 기다린다
d.tlsProbe = probePublicTls(d.config.publicUrl, { dataDir: d.config.dataDir }).then((info) => { d.tlsInfo = info; d.tlsProbe = null; return info; }).catch((e) => { d.tlsInfo = { url: d.config.publicUrl, trusted: null, reason: 'probe_error', error: e.message, pin: null, ca: [], probedAt: new Date().toISOString() }; d.tlsProbe = null; return d.tlsInfo; });
return d.tlsProbe;
}
/**
* 설치 명령 만들기. 신뢰되지 않는 공개 주소면 내려받기에 핀을 붙이고(-k 는 CA 검증만 끄며 핀은 그대로 강제된다) 스크립트에 --hal-pin 을 넘긴다.
* 스크립트는 핀으로 /agent/v1/hal-ca.pem 을 받아 이후 curl·Node 호출의 CA 로 쓴다. 핀은 공개 정보라 명령에 넣어도 된다.
*/
export function installCommand(publicUrl, token, { kind = 'managed', siteId = null, tlsInfo = null } = {}) {
const pinned = tlsInfo && tlsInfo.trusted === false && tlsInfo.pin;
const fetchPart = pinned ? `curl -fsSL -k --pinnedpubkey '${tlsInfo.pin}' ${publicUrl}/agent/v1/install.sh` : `curl -fsSL ${publicUrl}/agent/v1/install.sh`;
const extra = `${kind === 'external' ? ` --external --site ${siteId}` : ''}${pinned ? ` --hal-pin '${tlsInfo.pin}'` : ''}`;
return { installCommand: `${fetchPart} | bash -s -- --hal ${publicUrl} --token ${token}${extra}`, tls: tlsInfo ? { trusted: tlsInfo.trusted, reason: tlsInfo.reason || null, pin: tlsInfo.pin || null, caAvailable: !!(tlsInfo.ca && tlsInfo.ca.length) } : null };
}
+3 -1
View File
@@ -53,7 +53,9 @@ export function registerUi(app, d) {
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
// [실제 환경 결함 수정 2026-10-27] core.selfHealth().bootstrap 은 camelCase 계약(complete·halRunningVersion·firstAgent)만 낸다. 옛 snake_case 열 이름을 읽어 완료 뒤에도 늘 '미완료'로 그리던 결함.
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 미완료(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, 첫 Agent ${health.bootstrap.firstAgent ? '등록됨' : '미등록'}) — 포털 API·일반 모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
// [0.4.1] 부트스트랩은 HAL 서버의 Node Agent·HawkEye 까지 bootstrap-hal.sh 가 함께 설치해 닫는다. 완료 뒤에는 "미완료" 대신 기능 단위 서버(포털·Webmaker·AriMail·백업)의 배포 대기 안내를 보인다(개발 지시 v0.4.1 요구 2).
if (health.bootstrap && !health.bootstrap.complete) banner.push(`<div class="alert warning">! 부트스트랩 진행 중(HAL 실행 버전 ${esc(health.bootstrap.halRunningVersion || '확인 전')}, HAL 서버의 Node Agent ${health.bootstrap.firstAgent ? '등록됨 — 승인 대기' : '미설치'}) — 0.4.1 부터 bootstrap-hal.sh 가 HAL 서버의 Agent·HawkEye 를 함께 설치합니다. 기존 설치는 HAL 서버에서 <code>bash deploy/hal-host-agent.sh</code> 를 실행하세요. 포털 API·모듈 배포는 완료 뒤 열립니다(§32.2)</div>`);
else if (health.deployment && health.deployment.pending.length) banner.push(`<a class="alert info" href="/ops/releases">ⓘ 부트스트랩 완료 · 기능 단위 서버 배포 대기: ${health.deployment.pending.map((m) => esc(R.moduleLabel(m))).join(', ')} — 배포 릴리스 화면의 "역할별 통합 배포(새 서버)" 로 서버를 추가해 배포하세요</a>`);
if (ctx.operator.mustChangePassword) banner.push(`<a class="alert warning" href="/ops/account">! 비밀번호를 바꿔야 합니다</a>`);
return ctx.html(`<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(title)} · Ariweb HAL 운영자 콘솔</title><link rel="stylesheet" href="/ops/ui/ops.css"><meta name="csrf" content="${esc(ctx.session.csrf)}"></head>
<body><a class="skip" href="#content">본문으로</a><header class="top"><button class="menu-btn" data-act="toggle-nav" aria-label="메뉴 열기">☰</button><a class="brand" href="/ops/">Ariweb HAL</a><span class="ver">v${esc(d.version)} · ${health.ok ? R.badge('ok', 'HAL 정상') : R.badge('failed', 'HAL 비정상')}</span><nav class="top-right"><span class="muted">${esc(ctx.operator.displayName || ctx.operator.login)} · ${ctx.operator.securityLevel === 'mfa' ? 'MFA' : '<span class="err">MFA 없음</span>'}</span><a href="/ops/account">계정</a><button class="btn small" data-act="ai-toggle" aria-pressed="true" aria-controls="ai-panel" title="AI 채팅룸 열기/닫기">AI</button><button class="btn small" data-act="logout">로그아웃</button></nav></header>
+6 -1
View File
@@ -98,7 +98,12 @@ const ACT = {
if (!v) return; if (!v.serverIds || !v.serverIds.length) return toast('대상 서버를 하나 이상 선택하세요'); const r = await call('POST', `/ops/api/settings/${b.m}/apply`, { serverIds: v.serverIds, restart: !!v.restart, nonce: Date.now() }); toast(`설정 적용 작업을 만들었습니다(${r.targets.length}대)`); location.href = '/ops/jobs/' + r.job.id; },
'module-reconcile': async () => { const r = await call('POST', '/ops/api/modules/reconcile', {}); location.href = '/ops/jobs/' + r.job.id; },
'server-new': async () => { const v = await ask('서버 등록 — 1. 역할·위치', [{ name: 'name', label: '서버 이름', required: true }, { name: 'role', label: '역할', type: 'select', options: ['web', 'mail', 'backup', 'control'] }, { name: 'location', label: '위치(예: 서울 IDC-1)' }]); if (!v) return; const r = await call('POST', '/ops/api/servers', v); location.href = '/ops/servers/' + r.server.id; },
'server-token': async (b) => { const r = await call('POST', `/ops/api/servers/${b.id}/token`); showOnce('일회용 등록 정보(10분·1회)', r.installCommand || r.token, '대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.'); },
// [0.4.1] 이미 Agent 가 있는 서버면 "재등록 허용"(기존 Agent 해지, 재인증)을 물어 본다. HAL 인증서가 사설 CA 면 핀이 붙은 명령과 안내가 함께 온다.
'server-token': async (b) => {
let replace = false; try { const s = await call('GET', `/ops/api/servers/${b.id}`); if (s.server && s.server.agentId) { if (!confirm(`이 서버에는 이미 Node Agent(${s.server.agentId})가 등록되어 있습니다.\n같은 서버를 다시 설치하는 경우가 아니면 토큰이 필요 없습니다.\n\n기존 Agent 를 해지하고 새 Agent 를 등록하는 "재등록 허용" 토큰을 발급할까요?`)) return; replace = true; } } catch (_) {}
const r = await call('POST', `/ops/api/servers/${b.id}/token`, replace ? { replace: true } : {});
const tlsNote = r.tls && r.tls.trusted === false ? ` HAL 인증서가 시스템 CA 로 신뢰되지 않아(${r.tls.reason || '사설 CA'}) 명령에 공개키 핀을 붙였습니다 — 그대로 실행하면 스크립트가 HAL 의 CA 를 받아 씁니다.` : '';
showOnce(replace ? '일회용 재등록 정보(10분·1회, 기존 Agent 해지)' : '일회용 등록 정보(10분·1회)', r.installCommand || r.token, `대상 서버에서 이 명령을 실행하면 Node Agent 가 설치·등록됩니다. 연결·신원·용량 확인 뒤 "승인"을 누르세요.${tlsNote}`); },
'server-approve': async (b) => { await call('POST', `/ops/api/servers/${b.id}/approve`); toast('승인했습니다 — 배치 가능'); reload(); },
'server-maint-start': async (b) => { const v = await ask('유지보수 시작', [{ name: 'note', label: '유지보수 내용(공개 상태 페이지에 "계획된 유지보수"로 표시)', required: true }], { note: '신규 배치를 중지하고 기존 서비스 영향을 확인한 뒤 필요한 서비스를 이전하세요. 종료 후 신규 배치를 재개합니다(§28.3).' }); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { note: v.note }); reload(); },
'server-maint-end': async (b) => { const v = await ask('유지보수 종료', [{ name: 'acceptsNew', label: '신규 배치 재개', type: 'checkbox', value: true }]); if (!v) return; await call('POST', `/ops/api/servers/${b.id}/maintenance`, { end: true, acceptsNew: v.acceptsNew }); reload(); },
+7 -1
View File
@@ -100,7 +100,9 @@ export function releasesView(data, changelog = {}) {
{ l: '변경 이력', f: (r) => changelog[r.version] ? `<details><summary>보기</summary><pre class="pre">${esc(changelog[r.version])}</pre></details>` : '<span class="muted">항목 없음</span>' },
{ l: '이 버전 배포', f: (r) => r.status === 'approved' ? modules.filter((m) => r.artifacts && (r.artifacts[m] || (guides[m] && /Agent 안의/.test(guides[m].description)))).map((m) => `<button class="btn small" data-act="module-deploy" data-m="${esc(m)}" data-v="${esc(r.version)}">${esc(m)}</button>`).join(' ') : '<span class="muted">승인 후</span>' },
], data.releases || [], { empty: '등록된 릴리스가 없습니다 — scripts/release.mjs 로 artifact 를 만들고 등록하세요' });
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영)' : ' · <span class="err">부트스트랩 미완료</span>'} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
// [0.4.1] 부트스트랩 완료 뒤에는 기능 단위 서버 배포 대기 목록을 보인다("부트스트랩 미완료" 문구는 진행 중일 때만)
const pendingFeat = (data.deployment && data.deployment.pending) || [];
return `<p class="muted">HAL 실행 버전: <b>${esc(running.hal_running_version || '확인 전')}</b>${running.manual_install_locked ? ' · 수동 설치 잠김(HAL 전용 운영) · 부트스트랩 완료' : ' · <span class="err">부트스트랩 진행 중(HAL 서버 Agent 등록·승인 전)</span>'}${pendingFeat.length ? ` · <b>기능 단위 서버 배포 대기: ${pendingFeat.map((m) => esc(moduleLabel(m))).join(', ')}</b>` : running.manual_install_locked ? ' · 기능 모듈 모두 배치됨' : ''} — <b>HAL 은 배포 대상이 아닙니다</b>(Suite 를 설치하기 전에 먼저 설치하는 도구이며 별도 절차로 갱신). 모듈 배포는 HAL 이 릴리스보다 오래되지 않으면 진행됩니다. 배포 순서: ${(data.deployOrder || []).map(esc).join(' → ')}.</p>
<h2>최신 릴리스 배포(모듈별)</h2><div class="cards rel-cards">${cards}</div>
<div class="actions"><button class="btn primary" data-act="provision">역할별 통합 배포(새 서버)</button> <button class="btn" data-act="module-reconcile">목표·실제 상태 맞추기(전체)</button></div>
<h2>상호 연결(어떤 모듈이 있어야 무엇이 동작하는가)</h2><ul class="small">${(data.links || []).map((l) => `<li>${esc(l)}</li>`).join('')}</ul>
@@ -229,3 +231,7 @@ export function backupView(d) { return `<h2>복원 승인(§30.3)</h2>${table([{
<h2>백업 목록</h2>${table([{ l: '백업', f: (b) => `<span class="mono">${esc(b.id)}</span><br>${esc(b.site_name || b.site_id)}` }, { l: '상태', f: (b) => jobBadge(b.status) }, { l: '검증', f: (b) => b.verify_state === 'ok' || b.verify_state === 'verified' ? badge('ok', '검증') : badge('warn', b.verify_state) }, { l: '생성·만료', f: (b) => `${fmt(b.created_at)} · ${fmt(b.expires_at)}` }, { l: '크기·위치', f: (b) => `${bytes(b.size_bytes)} <span class="muted">${esc(b.location || '')}</span>` }], d.backups || [], { empty: '백업이 없습니다' })}`; }
export function relocationView(d, tr) { return `<h2>서버 이전(§30.4)</h2>${table([{ l: '대상', f: (r) => `<b>${esc(r.subjectName || r.subject_id)}</b> <span class="muted">${esc(r.subject_type)} · ${esc(r.group_name || '')}</span><br>${esc(r.from_server_id || '?')} → ${esc(r.to_server_id || '?')}` }, { l: '종류·이유', f: (r) => `${esc(r.kind)}<br><span class="muted">${esc(r.reason || '')}</span>` }, { l: '상태', f: (r) => jobBadge(r.status) + (r.approval_state ? `<br><span class="muted">승인 ${esc(r.approval_state)}</span>` : '') }, { l: '현재 단계', f: (r) => r.job ? `${esc(r.job.currentStep ? r.job.currentStep.title : '')} ${r.job.progress || 0}%${r.job.waitReason ? `<br><span class="muted">${esc(r.job.waitReason)}</span>` : ''}` : '—' }, { l: '전환 직전 재점검', f: (r) => r.precheckChanged || (r.boundaries && r.boundaries.autoStopped) ? badge('warn', '조건 변경 — 자동 중지') : r.boundaries && r.boundaries.switchAt ? `전환 ${fmt(r.boundaries.switchAt)}` : '—' }, { l: '고객 통지', f: (r) => r.notifiedAt || (r.boundaries && r.boundaries.notifiedAt) ? fmt(r.notifiedAt || r.boundaries.notifiedAt) : '—' }, { l: '', f: (r) => ['awaiting_approval', 'requested'].includes(r.status) || r.approval_state === 'pending' ? `<button class="btn small primary" data-act="reloc-approve" data-id="${esc(r.id)}">승인</button> <button class="btn small danger" data-act="reloc-reject" data-id="${esc(r.id)}">거절</button>` : ['failed', 'paused'].includes(r.status) ? `<button class="btn small" data-act="reloc-retry" data-id="${esc(r.id)}">재시도</button> <button class="btn small" data-act="reloc-resume" data-id="${esc(r.id)}">이어서</button> <button class="btn small danger" data-act="reloc-revert" data-id="${esc(r.id)}">되돌리기</button>` : '' }], d.relocations || [], { empty: '이전 요청이 없습니다' })}<div class="actions"><button class="btn" data-act="reloc-new">플랫폼 사유 이전 만들기</button></div>
<h2>소유권 이전 심사(§22)</h2>${table([{ l: '요청', f: (t) => `<span class="mono">${esc(t.id)}</span><br>${esc(t.fromGroupName || t.from_group_id)} → ${esc(t.toGroupName || t.to_group_id || t.to_email || '')}` }, { l: '상태', f: (t) => jobBadge(t.status) }, { l: '범위', f: (t) => t.scope ? Object.entries(t.scope).map(([k, v]) => `${esc(k)}: ${Array.isArray(v) ? v.length : esc(String(v))}`).join(', ') : '—' }, { l: '만료', f: (t) => fmt(t.expires_at) }, { l: '', f: (t) => ['reviewing', 'accepted'].includes(t.status) ? `<button class="btn small primary" data-act="transfer-approve" data-id="${esc(t.id)}">승인</button> <button class="btn small danger" data-act="transfer-reject" data-id="${esc(t.id)}">거절</button>` : t.status === 'disputed' ? `<button class="btn small" data-act="transfer-resolve" data-id="${esc(t.id)}">분쟁 해결</button>` : '' }], (tr && tr.transfers) || [], { empty: '소유권 이전 요청이 없습니다' })}`; }
/** [0.4.1] 모듈 표시 이름(배너·릴리스 화면의 "기능 단위 서버 배포 대기" 목록). 브라우저에서도 쓰므로 hal/modules 를 import 하지 않고 여기 둔다. */
export const MODULE_LABELS = { hal: 'HAL', agent: 'Node Agent', portal: '고객 포털', webmaker: 'Webmaker', arimail: 'AriMail', backup: '백업', hawkeye: 'HawkEye', observability: 'HawkEye' };
export function moduleLabel(m) { return MODULE_LABELS[m] || String(m); }