generated from ttipo/project-base
feat(docker): 메일 엔진 전용 DNS 리졸버(unbound)와 Spamhaus 조회 키(DQS)
엔진의 DNS 조회(DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버의 리졸버로 푼다. Spamhaus 는 공용 DNS 를 거친 조회를 거부하고, DNSSEC 을 검증해야 엔진의 DANE 이 켜지기 때문이다. - mail-resolver: unbound 1.26.2(digest 고정 · 비-root · cap_drop ALL). mail 과만 닿는 internal 네트워크 mail-dns 와 바깥 길 mail-dns-egress. 사설 주소의 답 · 질의를 막는다. - setup: 바꾸기 전에 리졸버 네트워크 · 주소 · DNSSEC 을 확인한다(UDP 는 루트 SOA, TCP 는 루트 DNSKEY — UDP DNSKEY 는 루트 키 교체 기간에 잘려 오탐이 난다). 엔진 DNS 는 운영 Custom, 개발 System. Spamhaus 키가 있으면 시험 항목을 확인한 뒤 DQS 로 켜고 없으면 두 항목을 끈다. 키는 모든 출력에서 가린다. - mail 은 리졸버를 기다리되(required: false) 리졸버 오류 하나로 메일 전체가 서지 않게 한다. - README: 리졸버와 스팸 차단 목록, 운영 배포 차례, 리졸버 장애 때(리로드 거절 · restart 비상 길), DNSSEC 이 깨진 도메인의 예외. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+732
-20
@@ -17,8 +17,11 @@
|
||||
* 네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 하기 때문이다. 없으면 아무것도 바꾸지 않고 실패한다.
|
||||
*
|
||||
* 하는 일 (순서가 중요하다)
|
||||
* 0. 사전 확인 앱 네트워크 서브넷 · 앱 네트워크에 붙은 컨테이너 · 백엔드 주소 — 하나라도 어긋나면
|
||||
* 아무것도 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 늦다)
|
||||
* 0. 사전 확인 앱 네트워크 서브넷 · 앱 네트워크에 붙은 컨테이너 · 백엔드 주소, 메일 전용 DNS 리졸버(mail-resolver — 네트워크 · 주소 ·
|
||||
* 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증하는지) — 하나라도 어긋나면 아무것도 바꾸지 않고 중단한다
|
||||
* (신뢰 대역을 바꾼 뒤에 알면 늦다 · 닿지 않는 리졸버에 엔진의 DNS 를 맡기면 바깥 메일이 멈춘다).
|
||||
* 리졸버의 답 확인만은 개발에서 [주의] 뒤 이어 간다(개발 메일은 Mailpit — checkResolver).
|
||||
* Spamhaus 키(MAIL_SPAMHAUS_DQS_KEY)가 있으면 그 키로 시험 항목이 나오는지도 본다
|
||||
* 1. 모드 감지 bootstrap(첫 기동) / bootstrap 완료·재시작 대기 / 정상
|
||||
* 2. bootstrap 서버 호스트명·기본 도메인·DKIM 키 → 컨테이너 재시작
|
||||
* ⚠️ Bootstrap/set 은 원자적이지 않다. 두 번 호출하면 도메인 중복으로 거부되면서도
|
||||
@@ -46,6 +49,8 @@
|
||||
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
|
||||
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
|
||||
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
|
||||
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
|
||||
* Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
|
||||
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
|
||||
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
|
||||
@@ -352,6 +357,97 @@ const PILOT_SENDER_AUTH_FIELDS = ['spfEhloVerify', 'spfFromVerify', 'reverseIpVe
|
||||
const IP_THROTTLE = { description: 'Sender IP throttle', key: ['remoteIp'] };
|
||||
const IP_THROTTLE_DEFAULT_MATCH = { match: {}, else: 'true' };
|
||||
|
||||
/*
|
||||
* 메일 엔진 전용 DNS 리졸버 (docker-compose.yml 의 mail-resolver — unbound · mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
* 엔진의 DNS 조회(DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다 — Spamhaus 는 공용 · ISP DNS 를
|
||||
* 거친 조회를 거부한다(127.255.255.254). 엔진 쪽 설정은 x:DnsResolver 싱글톤 하나다 — 0.16.27 의 @type 은 System · Custom · Cloudflare · Quad9 · Google,
|
||||
* Custom 은 servers { "<n>": { protocol udp|tcp|tls, address(IP 만), port } } 와 공통 칸(attempts · timeout · enableEdns …). @type 만 바꾸는 부분 갱신을 받고
|
||||
* 공통 칸은 그대로 둔다 (2026-10-30 개발 엔진에서 읽고 바꿔 확인 — 주소가 IP 가 아니면 invalidPatch). 바뀐 값은 ReloadSettings 뒤에 반영된다
|
||||
* (리로드가 엔진 설정 전체를 다시 만든다 — 소스 common/src/cache/reload.rs).
|
||||
* 운영 Custom — 리졸버 주소의 UDP + TCP. 엔진은 TCP 연결이 있어야 그 리졸버로 DNSSEC 을 믿는다(없으면 DANE 을 끈다 — 소스
|
||||
* common/src/config/smtp/resolver.rs 의 ensure_dnssec). 도커 DNS 를 거치지 않으므로 도커 내부 이름(backend · db …)은 엔진에 풀리지 않는다 —
|
||||
* 바깥 도메인의 MX 가 내부 이름을 가리켜도 닿지 않는다(사설 주소로 풀리는 바깥 이름은 리졸버가 버린다 — unbound.conf 의 private-address)
|
||||
* 개발 System — 컨테이너의 resolv.conf(도커 DNS 127.0.0.11). Mailpit 발송 경로(mailpit) 같은 도커 내부 이름은 도커 DNS 가 풀고, 바깥 이름은 compose 의
|
||||
* dns(= 이 리졸버)로 넘긴다 — DNSSEC 자료(RRSIG)도 UDP · TCP 그대로 넘긴다 (2026-10-30 Docker 28.5 에서 확인)
|
||||
*/
|
||||
const DEFAULT_RESOLVER_SUBNET = '172.16.73.0/24';
|
||||
const DEFAULT_RESOLVER_IP = '172.16.73.254';
|
||||
/** 리졸버 네트워크의 접두 길이 — /29 면 도커 게이트웨이 · 리졸버 · mail 이 들어간다. 넓게 잡을 까닭이 없다 (앱 네트워크와 같은 상한) */
|
||||
const RESOLVER_SUBNET_PREFIX_RANGE = { widest: 24, narrowest: 29 };
|
||||
const RESOLVER_PORT = 53;
|
||||
/** 리졸버 컨테이너 · 네트워크 이름의 뒷부분 — `${PROJECT_NAME}-<서비스>` · `${PROJECT_NAME}_<네트워크>` (docker-compose.yml) */
|
||||
const RESOLVER_SERVICE = 'mail-resolver';
|
||||
const RESOLVER_NETWORK = 'mail-dns';
|
||||
const RESOLVER_EGRESS_NETWORK = 'mail-dns-egress';
|
||||
/**
|
||||
* 리졸버 확인(메일 컨테이너 안에서 묻기) 한 번의 기다림(초) — unbound 는 답하지 않는 상대에게 몇 번 다시 물은 뒤 SERVFAIL 을 준다
|
||||
* (DQS 의 틀린 키는 5초 남짓 뒤 SERVFAIL — 2026-10-30)
|
||||
*/
|
||||
const RESOLVER_PROBE_TIMEOUT_SECONDS = 12;
|
||||
/**
|
||||
* 리졸버 확인이 DNSSEC 자료를 청할 때 알리는 UDP 응답 크기(EDNS) — unbound.conf 의 edns-buffer-size 와 같은 1232(DNS Flag Day 2020 권장값).
|
||||
* 이보다 큰 UDP 응답은 잘린다(TC) — 그래서 UDP 로는 크기가 작은 질문만 한다 (RESOLVER_DNSSEC_PROBES)
|
||||
*/
|
||||
const RESOLVER_PROBE_EDNS_SIZE = 1232;
|
||||
/**
|
||||
* 리졸버가 DNSSEC 을 검증하는지 메일 컨테이너에서 묻는 질문 — 루트(.)에 DNSSEC 자료(DO)와 함께. 검증한 답(ad)이고 서명(RRSIG)이 와야 한다.
|
||||
* UDP 루트 SOA — 389B 안팎(2026-10-30). 루트 DNSKEY(1,139B)는 키 교체(ZSK 미리 올리기 · KSK 폐기) 동안 1,400B 를 넘어 UDP 로는 잘린다
|
||||
* (TC — 답 0 · 서명 0). 엔진은 그때 TCP 로 다시 묻는다 — UDP 로 DNSKEY 를 물으면 멀쩡한 리졸버를 막게 된다
|
||||
* TCP 루트 DNSKEY — 엔진의 기동 확인과 같은 질문(소스 common/src/config/smtp/resolver.rs 의 ensure_dnssec — TCP 연결이 있고 루트 DNSKEY 가
|
||||
* 검증되어야 DANE 을 켠다). TCP 는 크기 한도가 없어 키 교체와 무관하다
|
||||
*/
|
||||
const RESOLVER_DNSSEC_PROBES = [
|
||||
{ protocol: 'udp', type: 'SOA' },
|
||||
{ protocol: 'tcp', type: 'DNSKEY' },
|
||||
];
|
||||
/** 엔진 기동 로그에서 찾는 경고 — 리졸버로 DNSSEC 을 믿지 못해 DANE 을 껐다 (소스 common/src/config/smtp/mod.rs, registry.build-warning) */
|
||||
const DNSSEC_UNAVAILABLE_LOG = 'cannot validate DNSSEC';
|
||||
/** 엔진 기동 로그의 첫 줄(info) — 기동 로그 범위에 이것이 없으면 그 기동의 로그가 남아 있지 않다(로그 회전 등). 경고가 없다고 말할 수 없다 */
|
||||
const ENGINE_STARTUP_LOG = '(server.startup)';
|
||||
/** 기동 로그를 볼 범위 — 컨테이너가 뜬 뒤 이만큼 (설정을 만들 때 바로 남는다) */
|
||||
const STARTUP_LOG_WINDOW_MS = 120_000;
|
||||
|
||||
/*
|
||||
* Spamhaus — 스팸 필터의 DNSBL 항목 둘(엔진 기본값 — 이름으로 찾는다). 공개 미러(zen.spamhaus.org · dbl.spamhaus.org)는 소규모 · 비상업 용도라
|
||||
* 이 서버는 Spamhaus 의 "Blocklists via DNS Query"(옛 Data Query Service — DQS) 키로 묻거나 두 항목을 끈다 (mail/README.md).
|
||||
* DQS 조회 이름은 <거꾸로 쓴 IP 또는 도메인>.<키>.<zen|dbl>.dq.spamhaus.net 이고 응답 코드는 공개 미러와 같다 — tag 식은 그대로 둔다
|
||||
* (Spamhaus 문서 "Migration from Spamhaus Legacy Public Mirrors to Blocklists via DNS Query" · "Testing", 2026-10-30 확인).
|
||||
* 키가 있으면 메일 컨테이너에서 리졸버로 시험 항목이 나오는지 먼저 본 뒤에만 켠다 — 안 나오면 끄고 실패로 알린다(닫힌 쪽).
|
||||
* 키가 없으면 끈다(운영 [주의] · 개발 [참고] — 개발도 운영처럼 공개 미러를 쓰지 않는다). 끌 때는 조회 이름 식도 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게.
|
||||
* ⚠️ 키는 엔진 설정(zone 식)에 그대로 들어간다 — 관리 화면 · 관리 API(x:SpamDnsblServer/get) · 메일 볼륨 백업으로 보인다. setup 은 키를 출력하지 않는다(식 · 옛 식도 —
|
||||
* 엔진 응답 · 오류 글에 실려 와도 출력 한 곳에서 가린다, hiddenInOutput).
|
||||
* 엔진은 DNSBL 조회 이름(키 포함)을 debug 수준에서만 남긴다 — 답(spam.dnsbl)도, 실패(spam.dnsbl-error — Hostname 에 조회 이름 전체 · CausedBy 에 리졸버 오류)도
|
||||
* 따로 정한 수준이 없어 기본값 debug 다. 그래서 키가 멈춰 조회가 모두 실패해도 setup 이 둔 info 로그에는 남지 않는다
|
||||
* (0.16.27 소스 crates/trc/src/event/enums_impl.rs 의 EventType::level 마지막 갈래 `_ => Level::Debug` · crates/spam-filter/src/modules/dnsbl.rs, 2026-10-30 확인).
|
||||
*/
|
||||
const SPAMHAUS_DQS_KEY_ENV = 'MAIL_SPAMHAUS_DQS_KEY';
|
||||
/** Spamhaus 가 주는 키 — 26자 영문 · 숫자(소문자로 맞춘다). 엔진의 식에 그대로 넣으므로 그 밖의 글자는 받지 않는다 */
|
||||
const SPAMHAUS_DQS_KEY_PATTERN = /^[a-z0-9]{26}$/;
|
||||
/**
|
||||
* 엔진 기본값(0.16.27)의 두 항목 — subject 는 식에서 조회할 값(IP 는 거꾸로 쓴 꼴), test 는 늘 등록된 시험 항목과 그 응답
|
||||
* (2.0.0.127 → 127.0.0.2(SBL) · 127.0.0.4 · 127.0.0.10, dbltest.com → 127.0.1.2 — 2026-10-30 이 리졸버로 공개 미러에서 확인)
|
||||
*/
|
||||
const SPAMHAUS_DNSBL = [
|
||||
{
|
||||
name: 'STWT_RBL_SPAMHAUS_IP',
|
||||
label: 'Spamhaus ZEN(IP)',
|
||||
short: 'ZEN',
|
||||
subject: 'ip_reverse',
|
||||
publicZone: 'zen.spamhaus.org',
|
||||
dqsZone: 'zen.dq.spamhaus.net',
|
||||
test: { name: '2.0.0.127', expect: '127.0.0.2' },
|
||||
},
|
||||
{
|
||||
name: 'STWT_DBL_SPAMHAUS_DOMAIN',
|
||||
label: 'Spamhaus DBL(도메인)',
|
||||
short: 'DBL',
|
||||
subject: 'value',
|
||||
publicZone: 'dbl.spamhaus.org',
|
||||
dqsZone: 'dbl.dq.spamhaus.net',
|
||||
test: { name: 'dbltest.com', expect: '127.0.1.2' },
|
||||
},
|
||||
];
|
||||
|
||||
/** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */
|
||||
const PLACEHOLDER_PATTERN = /change[-_]?me/i;
|
||||
|
||||
@@ -386,8 +482,24 @@ class RestartRequired extends Error {
|
||||
/** 끝까지 진행한 뒤 실패로 보고할 문제 (예: 백엔드 주소가 신뢰 대역 밖) */
|
||||
const problems = [];
|
||||
|
||||
const log = (message) => console.log(message);
|
||||
const warn = (message) => console.warn(`[주의] ${message}`);
|
||||
/**
|
||||
* 출력에서 가릴 비밀 — [찾을 식, 대신 찍을 글]. loadConfig 가 Spamhaus 키를 넣는다.
|
||||
* setup 이 만든 글에는 키를 넣지 않지만, 엔진 응답(오류 본문 · 거절 사유의 description)에 실려 올 수 있어 출력 한 곳에서 한 번 더 가린다 —
|
||||
* log · warn · main 의 오류 출력이 모두 redact 를 거친다
|
||||
*/
|
||||
const hiddenInOutput = [];
|
||||
|
||||
/** 이 값을 출력에서 placeholder 로 바꿔 찍는다 (대소문자 무시 — 엔진에는 소문자로 들어간다) */
|
||||
function hideInOutput(secret, placeholder) {
|
||||
hiddenInOutput.push([new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), placeholder]);
|
||||
}
|
||||
|
||||
function redact(text) {
|
||||
return hiddenInOutput.reduce((out, [pattern, placeholder]) => out.replace(pattern, placeholder), String(text));
|
||||
}
|
||||
|
||||
const log = (message) => console.log(redact(message));
|
||||
const warn = (message) => console.warn(redact(`[주의] ${message}`));
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
// ============================================================
|
||||
@@ -664,6 +776,32 @@ function loadConfig(argv) {
|
||||
}
|
||||
}
|
||||
|
||||
// 메일 전용 DNS 리졸버 — compose 의 `${MAIL_RESOLVER_*:-…}` 와 같은 기본값 (preflight 의 checkResolver · ensureEngineDns)
|
||||
const resolverSubnet = readEnv('MAIL_RESOLVER_SUBNET') ?? DEFAULT_RESOLVER_SUBNET;
|
||||
const resolverIp = readEnv('MAIL_RESOLVER_IP') ?? DEFAULT_RESOLVER_IP;
|
||||
errors.push(...describeResolverProblems(resolverSubnet, resolverIp, appNetworkSubnet));
|
||||
|
||||
// Spamhaus 조회 키 (선택) — 있으면 Spamhaus 두 항목을 DQS 로 묻고, 없으면 끈다 (ensureSpamhausDnsbl). 값은 어디에도 출력하지 않는다
|
||||
const spamhausKeyRaw = readEnv(SPAMHAUS_DQS_KEY_ENV);
|
||||
let spamhausKey = null;
|
||||
if (spamhausKeyRaw) {
|
||||
if (SPAMHAUS_DQS_KEY_PATTERN.test(spamhausKeyRaw.toLowerCase())) {
|
||||
spamhausKey = spamhausKeyRaw.toLowerCase();
|
||||
// 엔진 응답 · 오류 글에 실려 와도 출력에는 이름만 (redact)
|
||||
hideInOutput(spamhausKey, `<${SPAMHAUS_DQS_KEY_ENV}>`);
|
||||
} else {
|
||||
errors.push(`${SPAMHAUS_DQS_KEY_ENV} 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다 (지금 ${spamhausKeyRaw.length}자 — 값은 출력하지 않는다).`);
|
||||
}
|
||||
// 운영은 env 파일에만 — Spamhaus 를 켜고 끄는 것은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 하고, 셸에만 있으면 다음 실행이 끈다
|
||||
// (파일에 같은 키가 있으면서 값이 다른 것은 위 findShellOverrides 가 이미 막았다 — 여기는 파일에 없는 경우)
|
||||
if (isProduction && fromFiles.get(SPAMHAUS_DQS_KEY_ENV)?.trim() !== spamhausKeyRaw) {
|
||||
errors.push(
|
||||
`운영의 ${SPAMHAUS_DQS_KEY_ENV} 는 셸이 아니라 .env.production 에 둡니다 — 셸에만 두면 그 실행만 켜지고 다음 실행이 끈다. ` +
|
||||
`파일에 적고 셸에서 지운 뒤 다시 실행하세요 (unset ${SPAMHAUS_DQS_KEY_ENV}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
|
||||
const secrets = {
|
||||
MAIL_ADMIN_PASSWORD: adminPassword,
|
||||
@@ -790,6 +928,15 @@ function loadConfig(argv) {
|
||||
appNetworkSubnet,
|
||||
networkName: `${projectName}_app-network`,
|
||||
trustedNetworks,
|
||||
// 메일 전용 DNS 리졸버 (docker-compose.yml 의 mail-resolver · 네트워크 mail-dns · mail-dns-egress)
|
||||
resolver: {
|
||||
subnet: resolverSubnet,
|
||||
ip: resolverIp,
|
||||
container: `${projectName}-${RESOLVER_SERVICE}`,
|
||||
network: `${projectName}_${RESOLVER_NETWORK}`,
|
||||
egressNetwork: `${projectName}_${RESOLVER_EGRESS_NETWORK}`,
|
||||
},
|
||||
spamhausKey,
|
||||
attachmentTotalMb,
|
||||
uploadQuotaFloor,
|
||||
devUserPassword,
|
||||
@@ -877,6 +1024,43 @@ function describeSubnetProblem(text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 전용 리졸버의 네트워크(MAIL_RESOLVER_SUBNET) · 주소(MAIL_RESOLVER_IP)로 쓸 수 없는 이유 — 쓸 수 있으면 빈 배열.
|
||||
* 사설 대역 · 네트워크 주소 · /24 ~ /29 (RESOLVER_SUBNET_PREFIX_RANGE) · 앱 네트워크와 겹치지 않음. 주소는 그 안이고 도커가 가져가는 자리
|
||||
* (네트워크 주소 · 게이트웨이 = 첫 주소 · 브로드캐스트)가 아니어야 한다 — compose 도 서브넷 밖 주소는 거부하지만 이유를 먼저 알린다.
|
||||
*/
|
||||
function describeResolverProblems(subnet, ip, appNetworkSubnet) {
|
||||
const cidr = parseCidr(subnet);
|
||||
if (cidr === null || !subnet.includes('/')) {
|
||||
return [`MAIL_RESOLVER_SUBNET(${subnet}) — IPv4 CIDR 이어야 합니다 (예: ${DEFAULT_RESOLVER_SUBNET}).`];
|
||||
}
|
||||
const issues = [];
|
||||
const { widest, narrowest } = RESOLVER_SUBNET_PREFIX_RANGE;
|
||||
if (cidr.prefix < widest || cidr.prefix > narrowest) {
|
||||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 접두 길이는 /${widest} ~ /${narrowest} 여야 합니다 (mail 과 리졸버 둘만 붙는다).`);
|
||||
}
|
||||
const [start, end] = cidrRange(cidr);
|
||||
if (start !== cidr.base) issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 네트워크 주소로 적어야 합니다 (예: ${formatIpv4(start)}/${cidr.prefix}).`);
|
||||
if (!PRIVATE_IPV4_RANGES.some((range) => cidrWithin(subnet, range))) {
|
||||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 사설 대역(${PRIVATE_IPV4_RANGES.join(', ')}) 안이어야 합니다 — 공인 대역이면 그 대역의 실제 주소로 가는 길을 이 네트워크가 가린다.`);
|
||||
}
|
||||
if (parseCidr(appNetworkSubnet) !== null && cidrOverlaps(subnet, appNetworkSubnet)) {
|
||||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) 이 APP_NETWORK_SUBNET(${appNetworkSubnet}) 과 겹칩니다 — 다른 사설 대역으로 바꿉니다.`);
|
||||
}
|
||||
const address = parseIpv4(ip);
|
||||
if (address === null || formatIpv4(address) !== ip) {
|
||||
issues.push(`MAIL_RESOLVER_IP(${ip}) — IPv4 주소여야 합니다 (앞자리 0 없이, 예: ${DEFAULT_RESOLVER_IP}).`);
|
||||
} else if (address < start || address > end) {
|
||||
issues.push(`MAIL_RESOLVER_IP(${ip}) 가 MAIL_RESOLVER_SUBNET(${subnet}) 밖입니다.`);
|
||||
} else if (address <= start + 1 || address === end) {
|
||||
issues.push(
|
||||
`MAIL_RESOLVER_IP(${ip}) — 네트워크 주소 · 도커 게이트웨이(첫 주소) · 브로드캐스트(끝 주소)는 쓸 수 없습니다. ` +
|
||||
`서브넷의 끝 쪽(예: ${formatIpv4(end - 1)})을 씁니다 — 도커는 mail 에 앞쪽부터 차례로 주소를 준다.`,
|
||||
);
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
function formatIpv4(value) {
|
||||
const parts = [];
|
||||
let rest = value;
|
||||
@@ -924,6 +1108,169 @@ function networkContainerNames(name) {
|
||||
.sort();
|
||||
}
|
||||
|
||||
/** 도커 네트워크의 { subnets, internal, containers } — 네트워크가 없으면 null */
|
||||
function networkDetails(name) {
|
||||
const result = runDocker([
|
||||
'network',
|
||||
'inspect',
|
||||
'--format',
|
||||
'{"ipam":{{json .IPAM.Config}},"internal":{{json .Internal}},"containers":{{json .Containers}}}',
|
||||
name,
|
||||
]);
|
||||
if (result.status !== 0) return null;
|
||||
const info = JSON.parse(result.stdout);
|
||||
return {
|
||||
subnets: (info.ipam ?? []).map((config) => config.Subnet).filter(Boolean),
|
||||
internal: info.internal === true,
|
||||
containers: Object.values(info.containers ?? {})
|
||||
.map((container) => container.Name)
|
||||
.filter(Boolean)
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 컨테이너의 { running, startedAt, dns, networks: { 네트워크 이름: IP } } — 컨테이너가 없으면 null.
|
||||
* 필요한 칸만 읽는다(환경변수 같은 비밀이 든 칸은 가져오지 않는다)
|
||||
*/
|
||||
function containerDetails(name) {
|
||||
const result = runDocker([
|
||||
'inspect',
|
||||
'--format',
|
||||
'{"running":{{json .State.Running}},"startedAt":{{json .State.StartedAt}},"dns":{{json .HostConfig.Dns}},"networks":{{json .NetworkSettings.Networks}}}',
|
||||
name,
|
||||
]);
|
||||
if (result.status !== 0) return null;
|
||||
const info = JSON.parse(result.stdout);
|
||||
return {
|
||||
running: info.running === true,
|
||||
startedAt: info.startedAt ?? null,
|
||||
dns: info.dns ?? [],
|
||||
networks: Object.fromEntries(Object.entries(info.networks ?? {}).map(([network, settings]) => [network, settings.IPAddress])),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 컨테이너 안에서 DNS 를 한 번 묻는 작은 클라이언트 — 이미지에 dig 이 없어 perl 로 묻는다(Debian 의 perl-base 만: IO::Socket::INET · IO::Select).
|
||||
* 프로그램은 표준 입력으로 들어가고(perl -), 묻는 값은 그 뒤 __DATA__ 줄들로 받는다 — 조회 이름(Spamhaus 키가 들 수 있다)이 docker exec 의
|
||||
* 명령줄(도커 이벤트 · ps)에 남지 않게. 결과에는 이름을 담지 않는다: rcode · ad · tc · answers · rrsig · a <주소> 또는 error <까닭>.
|
||||
* 묻는 값(한 줄에 하나): 서버 IP · 포트 · udp|tcp · A|SOA|DNSKEY · DO(DNSSEC 자료 요청 0|1) · 이름 · 기다림(초) · EDNS 크기(DO 일 때 알리는 UDP 응답 크기)
|
||||
*/
|
||||
const DNS_PROBE_SCRIPT = String.raw`
|
||||
use strict;
|
||||
use IO::Socket::INET;
|
||||
use IO::Select;
|
||||
my @in = <DATA>;
|
||||
chomp @in;
|
||||
my ($server, $port, $proto, $type, $do, $name, $wait, $edns) = @in;
|
||||
my %types = (A => 1, SOA => 6, DNSKEY => 48);
|
||||
sub out { print join("\n", @_), "\n"; exit 0 }
|
||||
out('error input') unless defined $edns && $edns =~ /^\d+$/ && $edns >= 512 && $edns <= 65535 && $types{$type} && ($proto eq 'udp' || $proto eq 'tcp');
|
||||
my $id = int(rand(65536));
|
||||
my $query = pack('n6', $id, 0x0100, 1, 0, 0, $do ? 1 : 0);
|
||||
for my $label (grep { length } split /\./, $name) {
|
||||
out('error input') if length($label) > 63;
|
||||
$query .= pack('C', length $label) . $label;
|
||||
}
|
||||
$query .= pack('Cnn', 0, $types{$type}, 1);
|
||||
$query .= pack('CnnCCnn', 0, 41, $edns, 0, 0, 0x8000, 0) if $do;
|
||||
my $sock = IO::Socket::INET->new(PeerAddr => $server, PeerPort => $port, Proto => $proto, Timeout => $wait) or out('error connect');
|
||||
my $select = IO::Select->new($sock);
|
||||
my $reply = '';
|
||||
if ($proto eq 'tcp') {
|
||||
syswrite($sock, pack('n', length $query) . $query) or out('error send');
|
||||
my $need;
|
||||
while ($select->can_read($wait)) {
|
||||
my $chunk = '';
|
||||
my $read = sysread($sock, $chunk, 65535);
|
||||
last unless $read;
|
||||
$reply .= $chunk;
|
||||
$need = unpack('n', $reply) if !defined $need && length($reply) >= 2;
|
||||
last if defined $need && length($reply) >= $need + 2;
|
||||
}
|
||||
out('error timeout') unless defined $need && length($reply) >= $need + 2;
|
||||
$reply = substr($reply, 2, $need);
|
||||
} else {
|
||||
defined(send($sock, $query, 0)) or out('error send');
|
||||
out('error timeout') unless $select->can_read($wait);
|
||||
defined(recv($sock, $reply, 65535, 0)) or out('error recv');
|
||||
}
|
||||
out('error short') if length($reply) < 12;
|
||||
my ($rid, $flags, $qdcount, $ancount) = unpack('n4', $reply);
|
||||
out('error id') if $rid != $id;
|
||||
my $offset = 12;
|
||||
sub skip_name {
|
||||
for (1 .. 128) {
|
||||
return 0 if $offset >= length $reply;
|
||||
my $len = unpack('C', substr($reply, $offset, 1));
|
||||
if (($len & 0xC0) == 0xC0) { $offset += 2; return 1 }
|
||||
$offset += 1 + $len;
|
||||
return 1 if $len == 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
for (1 .. $qdcount) { skip_name() or out('error parse'); $offset += 4 }
|
||||
my @addresses;
|
||||
my $signatures = 0;
|
||||
for (1 .. $ancount) {
|
||||
skip_name() or out('error parse');
|
||||
out('error parse') if $offset + 10 > length $reply;
|
||||
my ($rtype, $rclass, $ttl, $rdlength) = unpack('nnNn', substr($reply, $offset, 10));
|
||||
$offset += 10;
|
||||
out('error parse') if $offset + $rdlength > length $reply;
|
||||
push @addresses, join('.', unpack('C4', substr($reply, $offset, 4))) if $rtype == 1 && $rdlength == 4;
|
||||
$signatures++ if $rtype == 46;
|
||||
$offset += $rdlength;
|
||||
}
|
||||
out('rcode ' . ($flags & 0x0F), 'ad ' . (($flags >> 5) & 1), 'tc ' . (($flags >> 9) & 1), "answers $ancount", "rrsig $signatures", map { "a $_" } @addresses);
|
||||
`;
|
||||
|
||||
/**
|
||||
* 메일 컨테이너 안에서 DNS 를 한 번 묻는다 (DNS_PROBE_SCRIPT) — 엔진과 같은 자리(메일 컨테이너의 네트워크)에서 같은 주소 · 포트로.
|
||||
* 돌려주는 값: { rcode, ad, tc, answers, rrsig, addresses } · { error } · { error, toolMissing, exitCode } (메일 이미지에 perl 이 없다) ·
|
||||
* docker 를 부르지 못하면 null
|
||||
*/
|
||||
function dnsQueryInsideMail(cfg, { server, port = RESOLVER_PORT, protocol, type, dnssec = false, name }) {
|
||||
const values = [server, port, protocol, type, dnssec ? 1 : 0, name, RESOLVER_PROBE_TIMEOUT_SECONDS, RESOLVER_PROBE_EDNS_SIZE];
|
||||
const result = spawnSync('docker', ['exec', '-i', cfg.container, 'perl', '-'], {
|
||||
input: `${DNS_PROBE_SCRIPT}\n__DATA__\n${values.join('\n')}\n`,
|
||||
encoding: 'utf8',
|
||||
timeout: (RESOLVER_PROBE_TIMEOUT_SECONDS + 15) * 1000,
|
||||
});
|
||||
if (result.error) return null;
|
||||
const answer = { addresses: [] };
|
||||
for (const line of (result.stdout ?? '').split(/\r?\n/)) {
|
||||
const [key, value] = line.trim().split(' ');
|
||||
if (key === 'a') answer.addresses.push(value);
|
||||
else if (key === 'error') answer.error = value;
|
||||
else if (['rcode', 'ad', 'tc', 'answers', 'rrsig'].includes(key)) answer[key] = Number(value);
|
||||
}
|
||||
// 표준 오류는 옮기지 않는다 — 출력이 없으면 종료 코드만. docker exec 는 명령을 찾지 못하면 127 · 실행하지 못하면 126 이다 —
|
||||
// 메일 이미지에 perl 이 없다(이미지를 바꿨다): 리졸버의 답이 아니라 확인 도구의 문제라 따로 알린다
|
||||
if (answer.error === undefined && answer.rcode === undefined) {
|
||||
answer.exitCode = result.status;
|
||||
answer.toolMissing = result.status === 126 || result.status === 127;
|
||||
answer.error = answer.toolMissing ? 'perl' : `종료 코드 ${result.status}`;
|
||||
}
|
||||
return answer;
|
||||
}
|
||||
|
||||
/** DNS 응답 코드 이름 (RFC 1035 · 2136) */
|
||||
const DNS_RCODES = { 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 4: 'NOTIMP', 5: 'REFUSED' };
|
||||
|
||||
/** dnsQueryInsideMail 의 결과를 사람이 읽는 글로 — 이름은 담지 않는다 */
|
||||
function describeDnsAnswer(answer) {
|
||||
if (!answer) return 'docker exec 실패';
|
||||
if (answer.toolMissing) return `메일 컨테이너에 perl 이 없음(docker exec 종료 코드 ${answer.exitCode})`;
|
||||
if (answer.error) return `${answer.error === 'timeout' ? '답 없음(시간 초과)' : answer.error === 'connect' ? '연결 실패' : `오류 ${answer.error}`}`;
|
||||
const parts = [DNS_RCODES[answer.rcode] ?? `rcode ${answer.rcode}`];
|
||||
// 잘린 UDP 응답 — 답 · 서명이 비어 온다(받는 쪽이 TCP 로 다시 물어야 한다)
|
||||
if (answer.tc) parts.push(`잘림(TC) · 답 ${answer.answers}`);
|
||||
if (answer.addresses.length > 0) parts.push(answer.addresses.join(' · '));
|
||||
if (answer.rrsig > 0 || answer.ad) parts.push(`ad ${answer.ad} · 서명 ${answer.rrsig}`);
|
||||
return parts.join(' — ');
|
||||
}
|
||||
|
||||
/**
|
||||
* mail 컨테이너에 Bootstrap/set 이 쓴 설정 파일이 있는가 — true·false, docker 로 확인할 수 없으면 null.
|
||||
* (이미지에 test 가 있다. 컨테이너가 없거나 exec 가 실패하면 null)
|
||||
@@ -1674,25 +2021,185 @@ function checkBackendNetwork(cfg, issues, subnetMatches) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 리졸버가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주는가 — RESOLVER_DNSSEC_PROBES(UDP 루트 SOA · TCP 루트 DNSKEY)를 차례로 묻는다.
|
||||
* 엔진과 같은 자리(메일 컨테이너의 네트워크) · 같은 주소다. 답마다 rcode 0 · ad 1 · 서명 1 개 이상이어야 한다.
|
||||
* 돌려주는 값: { answers: [{ protocol, type, answer }], toolMissing(메일 이미지에 perl 이 없다 — 답을 볼 수 없었다), failed: answers 가운데 어긋난 것 }
|
||||
*/
|
||||
function probeResolverDnssec(cfg) {
|
||||
const answers = RESOLVER_DNSSEC_PROBES.map(({ protocol, type }) => ({
|
||||
protocol,
|
||||
type,
|
||||
answer: dnsQueryInsideMail(cfg, { server: cfg.resolver.ip, protocol, type, dnssec: true, name: '.' }),
|
||||
}));
|
||||
return {
|
||||
answers,
|
||||
toolMissing: answers.find(({ answer }) => answer?.toolMissing)?.answer ?? null,
|
||||
failed: answers.filter(({ answer }) => !(answer?.rcode === 0 && answer.ad === 1 && answer.rrsig > 0)),
|
||||
};
|
||||
}
|
||||
|
||||
/** probeResolverDnssec 의 답들을 한 줄로 — "UDP 루트 SOA NOERROR — ad 1 · 서명 1, TCP 루트 DNSKEY …" */
|
||||
function describeResolverProbes(answers) {
|
||||
return answers.map(({ protocol, type, answer }) => `${protocol.toUpperCase()} 루트 ${type} ${describeDnsAnswer(answer)}`).join(', ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 전용 DNS 리졸버 — 엔진의 DNS 를 맡기기 전에 (RESOLVER_SERVICE · DEFAULT_RESOLVER_IP 머리말).
|
||||
* 리졸버가 없거나 · 주소가 env 와 다르면 issues 에 넣는다(아무것도 바꾸지 않고 멈춘다).
|
||||
* 메일 엔진만 닿는가(두 네트워크의 구성원 · 리졸버가 붙은 네트워크)는 운영은 멈추고 개발은 [주의] — 앱 네트워크 구성원 확인과 같다.
|
||||
* 메일 엔진에서 답하고 DNSSEC 을 검증하는가(probeResolverDnssec — UDP · TCP, 엔진은 TCP 가 되어야 DNSSEC 을 믿는다)도 운영은 멈추고 개발은 [주의]다 —
|
||||
* 개발 메일은 Mailpit(도커 DNS 가 푸는 이름)으로 가고 엔진 DNS 는 System 이라, 바깥 53 이 막힌 망에서도 개발 setup 은 끝까지 간다.
|
||||
* 돌려주는 값: 엔진의 DNS 를 맡겨도 되는가
|
||||
*/
|
||||
function checkResolver(cfg, issues) {
|
||||
const { resolver } = cfg;
|
||||
const before = issues.length;
|
||||
const upHint = 'docker compose … up -d 로 mail-resolver 를 띄우고 mail 을 다시 만든다(compose 가 함께 한다)';
|
||||
const strayIssue = (message) => (cfg.isProduction ? issues.push(message) : warn(message));
|
||||
|
||||
const network = networkDetails(resolver.network);
|
||||
if (!network) {
|
||||
issues.push(`리졸버 네트워크 ${resolver.network} 가 없습니다 — ${upHint}. PROJECT_NAME 이 compose 와 같은 env 파일인지도 확인하세요.`);
|
||||
return false;
|
||||
}
|
||||
if (!network.subnets.includes(resolver.subnet)) {
|
||||
issues.push(
|
||||
`리졸버 네트워크 ${resolver.network} 의 서브넷(${network.subnets.join(', ') || '없음'})이 MAIL_RESOLVER_SUBNET(${resolver.subnet}) 과 다릅니다 — ` +
|
||||
'docker compose … down 후 up -d 로 네트워크를 다시 만든다.',
|
||||
);
|
||||
}
|
||||
if (!network.internal) {
|
||||
issues.push(`리졸버 네트워크 ${resolver.network} 가 internal 이 아닙니다 — 바깥 길은 ${resolver.egressNetwork} 하나여야 한다. docker compose … down 후 up -d 로 다시 만든다.`);
|
||||
}
|
||||
const strayMembers = network.containers.filter((name) => name !== cfg.container && name !== resolver.container);
|
||||
if (strayMembers.length > 0) {
|
||||
strayIssue(
|
||||
`리졸버 네트워크 ${resolver.network} 에 mail · ${RESOLVER_SERVICE} 밖의 컨테이너가 붙어 있습니다: ${strayMembers.join(', ')} — 그 컨테이너도 리졸버에 닿고, ` +
|
||||
`리졸버가 그 컨테이너에 닿는다. docker network disconnect ${resolver.network} <컨테이너> 로 떼거나 compose 의 networks 를 고친다.`,
|
||||
);
|
||||
}
|
||||
const egress = networkDetails(resolver.egressNetwork);
|
||||
if (!egress) {
|
||||
issues.push(`리졸버의 바깥 길 ${resolver.egressNetwork} 가 없습니다 — ${upHint}.`);
|
||||
} else {
|
||||
if (egress.internal) issues.push(`리졸버의 바깥 길 ${resolver.egressNetwork} 가 internal 이라 리졸버가 바깥 DNS 서버에 묻지 못합니다 — down 후 up -d.`);
|
||||
const strayEgress = egress.containers.filter((name) => name !== resolver.container);
|
||||
if (strayEgress.length > 0) {
|
||||
strayIssue(`리졸버의 바깥 길 ${resolver.egressNetwork} 에 다른 컨테이너가 붙어 있습니다: ${strayEgress.join(', ')} — 그 컨테이너가 리졸버에 닿는다. 떼어 낸다.`);
|
||||
}
|
||||
}
|
||||
|
||||
const unbound = containerDetails(resolver.container);
|
||||
if (!unbound) {
|
||||
issues.push(`${resolver.container} 컨테이너가 없습니다 — ${upHint}.`);
|
||||
return false;
|
||||
}
|
||||
if (!unbound.running) {
|
||||
issues.push(`${resolver.container} 가 떠 있지 않습니다 — docker logs ${resolver.container} 로 까닭을 본 뒤 docker compose … up -d 로 띄운다.`);
|
||||
return false;
|
||||
}
|
||||
if (unbound.networks[resolver.network] !== resolver.ip) {
|
||||
issues.push(
|
||||
`${resolver.container} 의 ${resolver.network} 주소(${unbound.networks[resolver.network] || '없음'})가 MAIL_RESOLVER_IP(${resolver.ip}) 와 다릅니다 — ` +
|
||||
'docker compose … up -d --force-recreate mail-resolver mail 로 다시 만든다.',
|
||||
);
|
||||
}
|
||||
const extraNetworks = Object.keys(unbound.networks).filter((name) => name !== resolver.network && name !== resolver.egressNetwork);
|
||||
if (extraNetworks.length > 0) {
|
||||
strayIssue(
|
||||
`${resolver.container} 가 ${extraNetworks.join(', ')} 에도 붙어 있습니다 — 리졸버는 ${resolver.network} · ${resolver.egressNetwork} 에만 둔다` +
|
||||
(extraNetworks.includes(cfg.networkName) ? ' (앱 네트워크는 메일 엔진의 신뢰 대역이다)' : '') +
|
||||
'. compose 의 networks 를 고친다.',
|
||||
);
|
||||
}
|
||||
// 메일 컨테이너가 없으면 앞의 checkBackendNetwork 가 이미 알렸다
|
||||
const mail = containerDetails(cfg.container);
|
||||
if (!mail) return false;
|
||||
if (!mail.networks[resolver.network]) issues.push(`${cfg.container} 가 리졸버 네트워크 ${resolver.network} 에 없습니다 — ${upHint}.`);
|
||||
if (!(mail.dns.length === 1 && mail.dns[0] === resolver.ip)) {
|
||||
issues.push(`${cfg.container} 의 dns(${mail.dns.join(', ') || '없음'})가 MAIL_RESOLVER_IP(${resolver.ip}) 하나가 아닙니다 — ${upHint}.`);
|
||||
}
|
||||
if (issues.length > before) return false;
|
||||
|
||||
// 엔진과 같은 자리 · 같은 주소로 — 운영은 어긋나면 멈추고(닫힌 쪽), 개발은 [주의] 뒤 이어 간다(이 함수 머리말)
|
||||
const probeFailed = (message, devNote = '') => {
|
||||
if (cfg.isProduction) {
|
||||
issues.push(message);
|
||||
} else {
|
||||
warn(`${message} 개발이라 멈추지 않고 이어 갑니다(운영이면 여기서 멈춘다) — 개발 메일은 Mailpit 으로 가고 엔진 DNS 는 System(도커 DNS)이다${devNote}.`);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const probe = probeResolverDnssec(cfg);
|
||||
if (probe.toolMissing) {
|
||||
return probeFailed(
|
||||
`메일 컨테이너(${cfg.container})에서 리졸버를 확인하지 못했습니다 — ${describeDnsAnswer(probe.toolMissing)}. ` +
|
||||
'리졸버의 답이 아니라 확인 도구의 문제다: setup 은 메일 이미지의 perl 로 묻는다(이미지에 dig 이 없다). ' +
|
||||
'MAIL_IMAGE 를 바꿨다면 그 이미지에 perl 이 있는지 보고, 없으면 setup 의 리졸버 확인(DNS_PROBE_SCRIPT)을 그 이미지에 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").',
|
||||
);
|
||||
}
|
||||
if (probe.failed.length > 0) {
|
||||
return probeFailed(
|
||||
`리졸버 ${resolver.ip}:${RESOLVER_PORT} 가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주지 않습니다 (${describeResolverProbes(probe.failed)}). ` +
|
||||
`리졸버 로그(docker logs ${resolver.container}) · 이 서버에서 바깥으로 나가는 53(UDP · TCP) · 서버 시계를 확인하세요 (mail/README.md "DNS 리졸버와 스팸 차단 목록").`,
|
||||
' — 바깥 이름(SPF · DKIM · DNSBL)과 DANE 은 리졸버가 바깥에 닿을 때까지 되지 않고, 바깥에 전혀 닿지 않으면 뒤의 설정 리로드도 엔진이 거절한다' +
|
||||
'(스팸 필터의 Pyzor 주소를 같은 리졸버로 푼다 — RELOAD_LOOKUP_FAILED)',
|
||||
);
|
||||
}
|
||||
log(
|
||||
`[확인] 메일 전용 리졸버 ${resolver.ip}:${RESOLVER_PORT} — 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증한다(${describeResolverProbes(probe.answers)}) · ` +
|
||||
`${resolver.network}(internal) 구성원 ${network.containers.join(', ')} · 바깥 길 ${resolver.egressNetwork}`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spamhaus 키가 있으면 두 항목의 시험 항목을 DQS 로 물어 본다 (메일 컨테이너 → 리졸버, 키는 표준 입력으로만 — DNS_PROBE_SCRIPT).
|
||||
* 돌려주는 값: Map(항목 이름 → { ok, detail }) — 키가 없으면 null
|
||||
*/
|
||||
function checkSpamhausKey(cfg) {
|
||||
if (!cfg.spamhausKey) return null;
|
||||
const results = new Map();
|
||||
for (const entry of SPAMHAUS_DNSBL) {
|
||||
const answer = dnsQueryInsideMail(cfg, {
|
||||
server: cfg.resolver.ip,
|
||||
protocol: 'udp',
|
||||
type: 'A',
|
||||
name: `${entry.test.name}.${cfg.spamhausKey}.${entry.dqsZone}`,
|
||||
});
|
||||
const ok = answer?.rcode === 0 && answer.addresses.includes(entry.test.expect);
|
||||
results.set(entry.name, { ok, detail: describeDnsAnswer(answer) });
|
||||
if (ok) {
|
||||
log(`[확인] Spamhaus 키(${SPAMHAUS_DQS_KEY_ENV}) — ${entry.label} 시험 항목 ${entry.test.name} → ${answer.addresses.join(' · ')} (DQS ${entry.dqsZone}, 메일 컨테이너 → 리졸버)`);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 엔진 설정을 바꾸기 전의 확인 — 어긋나면 아무것도 바꾸지 않고 중단한다.
|
||||
* (신뢰 대역을 바꾼 뒤에야 알면 전 직원의 메일이 멈추고, 백엔드 주소가 자동 차단 예외에서 빠져 차단될 수 있다)
|
||||
* (신뢰 대역을 바꾼 뒤에야 알면 전 직원의 메일이 멈추고, 백엔드 주소가 자동 차단 예외에서 빠져 차단될 수 있다.
|
||||
* 메일 전용 리졸버가 없거나 닿지 않는데 엔진의 DNS 를 맡기면 바깥 메일을 주고받지 못한다)
|
||||
* 돌려주는 값: { spamhaus } — Spamhaus 키 확인 결과(checkSpamhausKey), 확인하지 못했으면 null
|
||||
*/
|
||||
function preflight(cfg) {
|
||||
if (!cfg.docker) {
|
||||
// 운영은 main 이 이미 막았다 — 여기는 개발뿐이다
|
||||
warn('docker 를 쓸 수 없어 앱 네트워크 확인(서브넷·구성원·백엔드 주소)을 건너뜁니다 (개발 환경만 허용).');
|
||||
return;
|
||||
warn('docker 를 쓸 수 없어 앱 네트워크 확인(서브넷·구성원·백엔드 주소)과 메일 전용 리졸버 확인을 건너뜁니다 (개발 환경만 허용).');
|
||||
return { spamhaus: null };
|
||||
}
|
||||
const issues = [];
|
||||
const subnetMatches = checkNetworkSubnet(cfg, issues);
|
||||
checkAppNetworkMembers(cfg, issues);
|
||||
checkBackendNetwork(cfg, issues, subnetMatches);
|
||||
checkResolver(cfg, issues);
|
||||
if (issues.length > 0) {
|
||||
throw new SetupError('사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.', {
|
||||
detail: issues.map((message) => ` - ${message}`).join('\n'),
|
||||
});
|
||||
}
|
||||
return { spamhaus: checkSpamhausKey(cfg) };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -2260,9 +2767,203 @@ async function ensureUploadQuota(client, cfg) {
|
||||
log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`);
|
||||
}
|
||||
|
||||
/** 엔진 DNS(x:DnsResolver)를 사람이 읽는 글로 — @type 과 Custom 의 서버 */
|
||||
function describeEngineDns(value) {
|
||||
const servers = Object.values(value?.servers ?? {}).map((server) => `${server.address}:${server.port}/${server.protocol}`);
|
||||
return `${value?.['@type'] ?? '없음'}${servers.length > 0 ? ` (${servers.join(' · ')})` : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom 의 서버들을 견줄 꼴로 — 서버마다 "protocol address:port" 만 뽑아 정렬한 목록.
|
||||
* 엔진이 읽어 줄 때 붙일 수 있는 다른 칸 · 키 이름 · 차례는 보지 않는다 — 통째로 견주면 그런 칸 하나로 매번 [변경] 이 되고,
|
||||
* 그러면 checkEngineDnssec 이 기동 로그의 DNSSEC 경고를 늘 [참고] 로 낮춰 가린다(2026-10-30 리뷰 L3). 엔진도 주소마다 연결을 모아 쓴다(resolver.rs)
|
||||
*/
|
||||
function engineDnsServerList(servers) {
|
||||
return Object.values(servers ?? {})
|
||||
.map((server) => `${String(server?.protocol ?? '').toLowerCase()} ${server?.address}:${server?.port}`)
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진의 DNS(x:DnsResolver 싱글톤)를 메일 전용 리졸버로 맞춘다 — 저장만 한다(main 의 리로드로 반영). 바꿨으면 true.
|
||||
* 운영은 Custom(리졸버 주소의 UDP + TCP — 도커 DNS 를 거치지 않는다), 개발은 System(도커 DNS → compose 의 dns = 리졸버 — Mailpit 같은 내부 이름 때문).
|
||||
* 견주는 것은 @type 과 서버마다의 protocol · address · port 뿐이다(engineDnsServerList) — 공통 칸(attempts · timeout · enableEdns …)은 엔진 값을
|
||||
* 그대로 둔다(@type 만 바꿔도 남는다 — DEFAULT_RESOLVER_SUBNET 머리말).
|
||||
* preflight 의 checkResolver 가 리졸버를 확인한 뒤에만 여기에 온다(docker 가 없는 개발은 확인 없이 System — 리졸버 주소를 엔진에 넣지 않는다).
|
||||
*/
|
||||
async function ensureEngineDns(client, cfg) {
|
||||
const current = (await call(client, 'x:DnsResolver/get', { ids: ['singleton'] })).list[0];
|
||||
const { ip } = cfg.resolver;
|
||||
const desired = cfg.isProduction
|
||||
? {
|
||||
'@type': 'Custom',
|
||||
servers: {
|
||||
0: { protocol: 'udp', address: ip, port: RESOLVER_PORT },
|
||||
1: { protocol: 'tcp', address: ip, port: RESOLVER_PORT },
|
||||
},
|
||||
}
|
||||
: { '@type': 'System' };
|
||||
const label = cfg.isProduction
|
||||
? `엔진 DNS — 메일 전용 리졸버 ${ip}:${RESOLVER_PORT} 에 바로 (Custom · UDP + TCP — DNSSEC · 도커 내부 이름은 풀지 않는다)`
|
||||
: `엔진 DNS — System (도커 DNS 가 내부 이름(mailpit)은 풀고 바깥 이름은 메일 전용 리졸버 ${ip} 에 묻는다)`;
|
||||
const same =
|
||||
current?.['@type'] === desired['@type'] &&
|
||||
(!desired.servers || stableJson(engineDnsServerList(current?.servers)) === stableJson(engineDnsServerList(desired.servers)));
|
||||
if (same) {
|
||||
log(`[유지] ${label}`);
|
||||
return false;
|
||||
}
|
||||
await setObjects(client, 'DnsResolver', { update: { singleton: desired } });
|
||||
log(`[변경] ${label} · 전 ${describeEngineDns(current)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Spamhaus 항목의 조회 이름 식 — key 가 있으면 DQS(<값>.<키>.<영역>), 없으면 공개 미러 */
|
||||
function spamhausZone(entry, key = null) {
|
||||
return { match: {}, else: key ? `${entry.subject} + '.${key}.${entry.dqsZone}'` : `${entry.subject} + '.${entry.publicZone}'` };
|
||||
}
|
||||
|
||||
/** 지금 조회 이름 식이 어떤 꼴인지 — 키를 드러내지 않는다 (식 · 옛 식을 그대로 출력하지 않는 까닭: DQS 키가 들어 있다) */
|
||||
function describeSpamhausZone(entry, zone, key) {
|
||||
if (stableJson(zone) === stableJson(spamhausZone(entry))) return `공개 미러 ${entry.publicZone}`;
|
||||
if (key && stableJson(zone) === stableJson(spamhausZone(entry, key))) return `DQS ${entry.dqsZone}`;
|
||||
const other = typeof zone?.else === 'string' && Object.keys(zone?.match ?? {}).length === 0 ? zone.else : '';
|
||||
if (other.startsWith(`${entry.subject} + '.`) && other.endsWith(`.${entry.dqsZone}'`)) return `DQS ${entry.dqsZone}(다른 키)`;
|
||||
return '다른 식(키가 들 수 있어 출력하지 않는다)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 스팸 필터의 Spamhaus 두 항목(SPAMHAUS_DNSBL)을 키에 맞춘다 — 저장만 한다(main 의 리로드로 반영).
|
||||
* 키가 있고 preflight 의 시험(checkSpamhausKey)을 통과한 항목만 DQS 로 켠다. 그 밖(키 없음 · 시험 실패 · 확인 못 함)은 끄고 조회 이름 식을
|
||||
* 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게, 공개 미러로 묻지 않게. tag 식은 그대로 둔다(응답 코드가 같다).
|
||||
* 항목은 이름으로 찾는다(id 는 설치마다 다르다). 없으면 만들지 않고 [주의].
|
||||
* 돌려주는 값: 요약에 쓸 글
|
||||
*/
|
||||
async function ensureSpamhausDnsbl(client, cfg, verified) {
|
||||
const key = cfg.spamhausKey;
|
||||
const servers = await listAll(client, 'SpamDnsblServer', ['name', 'zone', 'enable']);
|
||||
const update = {};
|
||||
const changes = [];
|
||||
const states = [];
|
||||
for (const entry of SPAMHAUS_DNSBL) {
|
||||
const server = servers.find((item) => item.name === entry.name);
|
||||
if (!server) {
|
||||
warn(
|
||||
`스팸 차단 목록 ${entry.label}(${entry.name})을 찾지 못해 맞추지 않았습니다 — 지웠다면 그대로 두고, 이름을 바꿨다면 그 항목의 조회 이름 · 켜짐을 ` +
|
||||
'관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").',
|
||||
);
|
||||
states.push(`${entry.short} 없음`);
|
||||
continue;
|
||||
}
|
||||
const check = verified?.get(entry.name);
|
||||
const useDqs = Boolean(key && check?.ok);
|
||||
const desired = { zone: spamhausZone(entry, useDqs ? key : null), enable: useDqs };
|
||||
const target = useDqs ? `DQS 로 묻는다 (${entry.dqsZone} — 키 ${SPAMHAUS_DQS_KEY_ENV})` : `꺼짐 (공개 미러 ${entry.publicZone} 로 묻지 않는다)`;
|
||||
states.push(`${entry.short} ${useDqs ? 'DQS' : '꺼짐'}`);
|
||||
if (stableJson(server.zone) === stableJson(desired.zone) && server.enable === desired.enable) {
|
||||
log(`[유지] 스팸 차단 목록 ${entry.label} — ${target}`);
|
||||
} else {
|
||||
update[server.id] = desired;
|
||||
changes.push(`[변경] 스팸 차단 목록 ${entry.label} — ${target} · 전 ${server.enable ? '켜짐' : '꺼짐'} · ${describeSpamhausZone(entry, server.zone, key)}`);
|
||||
}
|
||||
if (key && !useDqs) {
|
||||
problems.push(
|
||||
`Spamhaus 키(${SPAMHAUS_DQS_KEY_ENV})로 ${entry.label} 의 시험 항목(${entry.test.name} → ${entry.test.expect})을 받지 못해 그 항목을 꺼 둡니다 — ` +
|
||||
`${check ? check.detail : 'docker 를 쓸 수 없어 확인하지 못함'}. 키(26자) · 계정에서 그 서비스(IP · 도메인)가 켜져 있는지 Spamhaus 계정 화면에서 본 뒤 다시 실행하세요.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (Object.keys(update).length > 0) {
|
||||
await setObjects(client, 'SpamDnsblServer', { update });
|
||||
for (const line of changes) log(line);
|
||||
}
|
||||
if (!key) {
|
||||
const message =
|
||||
`${SPAMHAUS_DQS_KEY_ENV} 가 없어 Spamhaus 두 항목(${SPAMHAUS_DNSBL.map((entry) => entry.name).join(' · ')})을 꺼 둔다 — 공개 미러는 소규모 · 비상업 용도다. ` +
|
||||
'Spamhaus 의 조회 키(무료 계정은 Fair Use — 소규모 · 비상업, 대상인지는 Spamhaus 가 정한다 · 아니면 유료)를 받으면 env 파일에 넣고 다시 실행한다 ' +
|
||||
'(나머지 차단 목록은 그대로 — mail/README.md "DNS 리졸버와 스팸 차단 목록").';
|
||||
if (cfg.isProduction) warn(message);
|
||||
else log(`[참고] ${message} 개발도 운영처럼 키가 없으면 끈다.`);
|
||||
}
|
||||
return states.join(' · ');
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 기동할 때 리졸버로 DNSSEC 을 믿었는가 — 이번 기동의 로그에 DnsResolver 경고(DNSSEC_UNAVAILABLE_LOG)가 있으면 DANE 이 꺼진 채다.
|
||||
* 리로드도 같은 확인을 하지만 그 경고는 로그에 남지 않고(ReloadSettings 응답에도 없다 — 소스 jmap/src/registry/mapping/action.rs) 기동 때만 남는다.
|
||||
* 그래서 이번 실행에서 엔진 DNS 를 바꿨다면 지금 로그는 바꾸기 전 설정의 것이다 — 다음 기동부터 맞다.
|
||||
* 경고가 없다고 말하려면 그 기동의 로그가 남아 있어야 한다 — 기동 첫 줄(ENGINE_STARTUP_LOG)이 범위에 없으면(로그 회전 등) "확인하지 못함"이다.
|
||||
*/
|
||||
function checkEngineDnssec(cfg, engineDnsChanged) {
|
||||
if (!cfg.docker) return;
|
||||
const startedAt = containerDetails(cfg.container)?.startedAt;
|
||||
const started = Date.parse(startedAt?.replace(/(\.\d{3})\d+/, '$1') ?? '');
|
||||
if (!Number.isFinite(started)) {
|
||||
warn(`${cfg.container} 의 기동 시각을 몰라 엔진의 DNSSEC 확인(기동 로그)을 보지 못했습니다.`);
|
||||
return;
|
||||
}
|
||||
const result = runDocker([
|
||||
'logs',
|
||||
'--since',
|
||||
new Date(started - 1000).toISOString(),
|
||||
'--until',
|
||||
new Date(started + STARTUP_LOG_WINDOW_MS).toISOString(),
|
||||
cfg.container,
|
||||
]);
|
||||
if (result.status !== 0) {
|
||||
warn(`${cfg.container} 의 기동 로그를 읽지 못해 엔진의 DNSSEC 확인을 보지 못했습니다 (종료 코드 ${result.status}).`);
|
||||
return;
|
||||
}
|
||||
const when = `이번 기동 ${startedAt.replace(/\.\d+/, '')}`;
|
||||
const restart = '다음 mail 기동(docker compose … restart mail) 뒤 setup 을 다시 돌려 본다';
|
||||
const output = `${result.stdout}${result.stderr}`;
|
||||
if (output.includes(DNSSEC_UNAVAILABLE_LOG)) {
|
||||
if (engineDnsChanged) {
|
||||
log(`[참고] 엔진 기동 로그(${when})에 DNSSEC 경고가 있다 — 바꾸기 전 DNS 로 뜬 기동이다. 리로드로 새 리졸버를 이미 쓴다. 기동 때의 확인은 ${restart}`);
|
||||
} else {
|
||||
warn(
|
||||
`엔진이 ${when} 때 리졸버로 DNSSEC 을 검증하지 못했다고 남겼습니다("${DNSSEC_UNAVAILABLE_LOG}" — DANE 꺼짐). 그때 리졸버가 아직 답하지 않았을 수 있다 ` +
|
||||
`(재부팅처럼 mail 이 먼저 뜬 경우). 이번 실행의 리로드가 같은 확인을 다시 했지만 그 결과는 로그에 남지 않는다 — ${restart}. ` +
|
||||
`계속되면 리졸버 로그(docker logs ${cfg.resolver.container}) · 바깥 53(TCP)을 본다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 그 기동의 로그가 없으면 경고가 없다고 말할 수 없다 — 회전(json-file 의 max-size · max-file 등)으로 지워졌거나 로그 수준을 warn 위로 올렸다
|
||||
if (!output.includes(ENGINE_STARTUP_LOG)) {
|
||||
warn(
|
||||
`${cfg.container} 의 ${when} 로그가 남아 있지 않아(기동 첫 줄 "${ENGINE_STARTUP_LOG}" 이 없다 — 로그 회전 등) 엔진이 그때 DNSSEC 을 검증했는지 ` +
|
||||
`확인하지 못했습니다. 메일 · 리졸버는 위 사전 확인대로다 — 기동 때의 확인은 ${restart}(몇 초 끊긴다).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
log(
|
||||
`[확인] 엔진 기동 로그(${when})에 DNSSEC 경고가 없다 — 리졸버로 DNSSEC 을 검증한다(DANE 켜짐)` +
|
||||
(engineDnsChanged ? ` · 엔진 DNS 를 이번에 바꿨다 — 새 설정의 기동 확인은 ${restart}` : ''),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 엔진이 설정을 다시 만들며 이름을 풀지 못해 리로드 전체를 거절했을 때의 설명 문구 — 기본 스팸 필터의 Pyzor(public.pyzor.org)는 설정을 만들 때
|
||||
* 메일 컨테이너의 시스템 DNS(= 메일 전용 리졸버)로 주소를 푼다. 실패하면 기동은 그 항목만 빼고 뜨지만 리로드는 통째로 거절된다
|
||||
* (0.16.27 소스 crates/common/src/config/mailstore/spamfilter.rs 의 PyzorConfig::parse · crates/common/src/cache/reload.rs — 2026-10-30 개발에서 리졸버의 바깥 길을 끊어 확인)
|
||||
*/
|
||||
const RELOAD_LOOKUP_FAILED = /failed to lookup address information|Invalid address/;
|
||||
|
||||
/** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */
|
||||
async function reloadSettings(client) {
|
||||
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } });
|
||||
try {
|
||||
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } });
|
||||
} catch (error) {
|
||||
if (error instanceof SetupError && RELOAD_LOOKUP_FAILED.test(error.detail ?? '')) {
|
||||
error.hint =
|
||||
'엔진이 설정을 다시 만들며 이름을 풀지 못해 리로드를 통째로 거절했다 — 스팸 필터의 Pyzor 주소(public.pyzor.org)를 메일 컨테이너의 DNS ' +
|
||||
`(= 메일 전용 리졸버)로 푼다. 리졸버가 바깥에 닿는지 본다: docker exec ${client.cfg.resolver.container} drill public.pyzor.org @127.0.0.1 A. ` +
|
||||
'저장한 설정은 남아 있다 — 리졸버를 고친 뒤 다시 실행하거나, 급하면 docker compose … restart mail(기동은 그 항목만 빼고 뜬다) ' +
|
||||
'(mail/README.md "리졸버 장애 때")';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** 리로드·캐시 무효화가 퍼질 때까지 확인을 몇 번 다시 해 본다 */
|
||||
@@ -3838,7 +4539,8 @@ async function main() {
|
||||
}
|
||||
|
||||
await waitForHttp(cfg, STARTUP_TIMEOUT_MS);
|
||||
preflight(cfg);
|
||||
// 앱 네트워크 · 메일 전용 리졸버 확인(아무것도 바꾸기 전 — 어긋나면 멈춘다)과 Spamhaus 키 시험
|
||||
const dnsCheck = preflight(cfg);
|
||||
const admin = await ensureAdmin(cfg);
|
||||
const domain = await findDomain(admin, cfg);
|
||||
|
||||
@@ -3889,6 +4591,9 @@ async function main() {
|
||||
// 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다)
|
||||
await ensurePilotInboundTrust(admin, pilot);
|
||||
await ensureUploadQuota(admin, cfg);
|
||||
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
|
||||
const engineDnsChanged = await ensureEngineDns(admin, cfg);
|
||||
const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus);
|
||||
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
|
||||
if (!cfg.isProduction) await ensureDevRelay(admin, keepPilotRoute);
|
||||
await reloadSettings(admin);
|
||||
@@ -3906,6 +4611,7 @@ async function main() {
|
||||
await checkHttpEndpointPolicy(cfg);
|
||||
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
|
||||
if (pilot) await checkPilotSenderGuard(cfg);
|
||||
checkEngineDnssec(cfg, engineDnsChanged);
|
||||
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
|
||||
|
||||
const devUsers = [];
|
||||
@@ -3944,6 +4650,10 @@ async function main() {
|
||||
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`);
|
||||
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
|
||||
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
|
||||
log(
|
||||
` DNS 메일 전용 리졸버 ${cfg.resolver.ip} (${cfg.resolver.network}) — 엔진 ${cfg.isProduction ? 'Custom(리졸버에 바로)' : 'System(도커 DNS 를 거쳐)'} · ` +
|
||||
`Spamhaus ${spamhausState}`,
|
||||
);
|
||||
log(
|
||||
' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가' +
|
||||
(systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''),
|
||||
@@ -4005,25 +4715,27 @@ async function main() {
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// 오류 글도 log · warn 과 같이 비밀을 가린다 — 엔진의 오류 본문 · 거절 사유가 그대로 실려 온다 (hiddenInOutput)
|
||||
const say = (message) => console.error(redact(message));
|
||||
if (error instanceof RestartRequired) {
|
||||
console.error(`\n[재시작 필요] ${error.message}`);
|
||||
console.error(' 이 환경에서는 docker 를 쓸 수 없습니다. 호스트에서 아래를 실행한 뒤 같은 명령으로 다시 실행하세요.');
|
||||
console.error(` docker restart ${error.container}`);
|
||||
say(`\n[재시작 필요] ${error.message}`);
|
||||
say(' 이 환경에서는 docker 를 쓸 수 없습니다. 호스트에서 아래를 실행한 뒤 같은 명령으로 다시 실행하세요.');
|
||||
say(` docker restart ${error.container}`);
|
||||
process.exit(EXIT_FOLLOW_UP);
|
||||
}
|
||||
if (error instanceof FollowUpRequired) {
|
||||
console.error(`\n[후속 조치] ${error.message}`);
|
||||
for (const step of error.steps) console.error(` ${step}`);
|
||||
say(`\n[후속 조치] ${error.message}`);
|
||||
for (const step of error.steps) say(` ${step}`);
|
||||
process.exit(EXIT_FOLLOW_UP);
|
||||
}
|
||||
console.error(`\n[실패] ${error.message}`);
|
||||
if (error.detail) console.error(error.detail);
|
||||
if (error.hint) console.error(` → ${error.hint}`);
|
||||
say(`\n[실패] ${error.message}`);
|
||||
if (error.detail) say(error.detail);
|
||||
if (error.hint) say(` → ${error.hint}`);
|
||||
// 멈추기 전에 쌓인 확인 실패도 함께 — 끝까지 가지 못해도 앞의 [실패] 가 사라지지 않게 (2차 리뷰 N1)
|
||||
if (!error.problemsIncluded && problems.length > 0) {
|
||||
console.error(' 그 전에 이 실행에서 확인에 실패한 항목:');
|
||||
for (const message of problems) console.error(` - ${message}`);
|
||||
say(' 그 전에 이 실행에서 확인에 실패한 항목:');
|
||||
for (const message of problems) say(` - ${message}`);
|
||||
}
|
||||
if (!(error instanceof SetupError)) console.error(error.stack);
|
||||
if (!(error instanceof SetupError)) say(error.stack);
|
||||
process.exit(EXIT_ERROR);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user