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:
2026-10-30 19:01:12 +09:00
parent 4047bd30e7
commit 9466a85daa
8 changed files with 1180 additions and 47 deletions
+732 -20
View File
@@ -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);
});