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
+3 -1
View File
@@ -28,7 +28,9 @@ export function loadAgentConfig(env = process.env) {
// [0.4.0] HAL 통합 설정(module.configure 가 쓰는 <dataDir>/hal-managed.json)이 있으면 그 값이 우선한다(범위 5초~10분)
heartbeatMs: (() => { const m = Number(managed.heartbeatMs); return Number.isFinite(m) && m >= 5000 && m <= 600000 ? m : Number(env.ARIWEB_AGENT_HEARTBEAT_MS || 30000); })(),
halManaged: { hash: managedDoc.hash || null, keys: Object.keys(managed) },
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label']) if (cfg[k] !== undefined) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
// [0.4.1] caFile: HAL 공개 주소가 사설 CA·자체 서명 인증서일 때 설치 스크립트가 받아 둔 신뢰 앵커 PEM 경로(agent/index.mjs 가 기동 때 TLS 기본 CA 에 더한다)
caFile: env.ARIWEB_HAL_CA || saved.caFile || null,
save(patch) { Object.assign(cfg, patch); const out = {}; for (const k of ['id', 'halUrl', 'serverId', 'kind', 'siteId', 'groupId', 'registeredAt', 'label', 'caFile']) if (cfg[k] !== undefined && cfg[k] !== null) out[k] = cfg[k]; const tmp = file + '.tmp'; writeFileSync(tmp, JSON.stringify(out, null, 1), { mode: 0o600 }); renameSync(tmp, file); },
vault: () => openVault(dataDir),
};
return cfg;
+30
View File
@@ -0,0 +1,30 @@
/**
* agent/halca.mjs — [0.4.1] HAL 공개 주소가 사설 CA·자체 서명 인증서일 때의 Agent 쪽 처리(개발 지시 v0.4.1 요구 1).
*
* applyHalCa(caFile) 설치 스크립트가 받아 둔 신뢰 앵커 PEM(<dataDir>/hal-ca.pem)을 이 프로세스의 TLS 기본 CA 에 더한다.
* 유닛이 NODE_EXTRA_CA_CERTS 로 이미 넣었으면 그대로 둔다. Node 22.19+/24 의 tls.setDefaultCACertificates 를 쓰고, 없으면 이유를 돌려준다.
* friendlyFetchError(e, hal) fetch 의 TypeError('fetch failed') 를 운영자가 읽을 수 있는 한 줄로 바꾼다 — 스택 대신 원인(인증서·연결)과 조치.
* 비밀 없음: CA 는 공개 인증서다.
*/
import tls from 'node:tls';
import { existsSync, readFileSync } from 'node:fs';
/** 인증서 검증 실패로 분류하는 OpenSSL/Node 오류 코드 */
export const TLS_TRUST_CODES = new Set(['SELF_SIGNED_CERT_IN_CHAIN', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_UNTRUSTED', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_GET_ISSUER_CERT', 'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'CERT_SIGNATURE_FAILURE', 'ERR_TLS_CERT_ALTNAME_INVALID', 'CERT_REVOKED']);
/** PEM 번들을 인증서 단위로 나눈다 */
export function splitPem(text) { return String(text || '').split(/(?=-----BEGIN CERTIFICATE-----)/).map((s) => s.trim()).filter((s) => s.startsWith('-----BEGIN CERTIFICATE-----')); }
export function applyHalCa(caFile, { env = process.env, tlsImpl = tls } = {}) {
if (!caFile) return { applied: false, reason: 'no_ca' };
if (!existsSync(caFile)) return { applied: false, reason: 'missing', error: `HAL CA 파일이 없습니다: ${caFile}` };
if (env.NODE_EXTRA_CA_CERTS && env.NODE_EXTRA_CA_CERTS === caFile) return { applied: true, via: 'env' };
const pems = splitPem(readFileSync(caFile, 'utf8')); if (!pems.length) return { applied: false, reason: 'empty', error: `HAL CA 파일에 인증서가 없습니다: ${caFile}` };
if (typeof tlsImpl.setDefaultCACertificates !== 'function' || typeof tlsImpl.getCACertificates !== 'function') return { applied: false, reason: 'unsupported', error: 'tls.setDefaultCACertificates 가 없는 Node 입니다(22.19+ 필요) — 유닛의 Environment=NODE_EXTRA_CA_CERTS 로 대신 넣으세요' };
tlsImpl.setDefaultCACertificates([...tlsImpl.getCACertificates('default'), ...pems]); return { applied: true, via: 'runtime', count: pems.length };
}
/** fetch 실패를 조치가 보이는 한 줄 메시지로. { code, message, tls } */
export function friendlyFetchError(e, hal) {
const code = (e && (e.cause?.code || e.code)) || null; const base = String(hal || '').replace(/\/$/, '');
if (code && TLS_TRUST_CODES.has(code)) return { code, tls: true, message: `HAL(${base}) 인증서를 확인할 수 없습니다(${code}) — HAL 운영자 콘솔이 만든 설치 명령(--hal-pin 포함)을 그대로 쓰거나, HAL 의 CA 파일을 받아 --ca <PEM 파일> 로 지정하세요. (예: curl -k --pinnedpubkey '<HAL 이 알려 준 핀>' ${base}/agent/v1/hal-ca.pem -o hal-ca.pem)` };
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'UND_ERR_CONNECT_TIMEOUT') return { code, tls: false, message: `HAL(${base}) 에 연결할 수 없습니다(${code}) — 주소·DNS·방화벽을 확인하세요` };
return { code, tls: false, message: `HAL(${base}) 요청 실패: ${code || ''} ${e && e.cause && e.cause.message ? e.cause.message : e && e.message ? e.message : ''}`.trim() };
}
+15 -7
View File
@@ -13,25 +13,33 @@ 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 } from 'node:path';
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 <pem> > 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', '');
const r = await fetch(hal.replace(/\/$/, '') + '/agent/v1/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, facts: f, version: VERSION }) }); const j = await r.json().catch(() => ({}));
if (!r.ok || !j.ok) { console.error(`등록 실패: ${j.error || r.status} ${j.message || ''}`); process.exit(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()) });
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`); } catch (e) { console.error(`HAL 연결 실패: ${e.message}`); process.exit(1); }
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') });
@@ -48,7 +56,7 @@ if (cmd === 'register') {
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.observability = { version: VERSION, active: true, health: 'ok' }; if (existsSync(cfg.roots.backups)) out.backup = { version: VERSION, active: true, health: 'ok' }; return out; }
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'))) : [];
@@ -63,7 +71,7 @@ if (cmd === 'register') {
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}`);
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); }