Files

31 lines
3.5 KiB
JavaScript
Raw Permalink Normal View History

/**
* 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() };
}