/** * agent/halca.mjs — [0.4.1] HAL 공개 주소가 사설 CA·자체 서명 인증서일 때의 Agent 쪽 처리(개발 지시 v0.4.1 요구 1). * * applyHalCa(caFile) 설치 스크립트가 받아 둔 신뢰 앵커 PEM(/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 로 지정하세요. (예: curl -k --pinnedpubkey '' ${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() }; }