- 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>
80 lines
7.7 KiB
JavaScript
80 lines
7.7 KiB
JavaScript
/**
|
|
* 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 };
|
|
}
|