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:
+244
-27
@@ -27,10 +27,12 @@
|
||||
|
||||
메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
|
||||
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
|
||||
엔진의 DNS 는 메일 전용 리졸버(`mail-resolver`)가 맡는다 — mail 과 리졸버만 붙는 `mail-dns` 로 닿는다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
|
||||
|
||||
| 구성 요소 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `<PROJECT_NAME>_mail-config`(config.json) · `<PROJECT_NAME>_mail-data`(RocksDB) |
|
||||
| `mail-resolver` | `docker-compose.yml` (profile `mail`) · 설정 `mail/resolver/unbound.conf` | 엔진 전용 재귀 DNS(unbound) — mail 만 닿는 `mail-dns`(internal)와 자기 바깥 길 `mail-dns-egress` 에만 붙는다. 상태 없음(볼륨 없음) — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl) |
|
||||
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
|
||||
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
|
||||
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
|
||||
@@ -58,7 +60,7 @@
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development
|
||||
|
||||
# 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mailpit 이 함께 뜬다
|
||||
# 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mail-resolver(메일 전용 DNS)·mailpit 이 함께 뜬다
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2) 메일 엔진 설정 (처음 한 번 — 이후에도 env 를 바꾸면 다시 실행한다)
|
||||
@@ -88,6 +90,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
0. **사전 확인 (아무것도 바꾸기 전)** — app-network 의 실제 서브넷이 `APP_NETWORK_SUBNET` 인지, 앱 네트워크에 정해진 컨테이너
|
||||
(backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 **엔진 설정을 바꾸지 않고 중단**한다
|
||||
(신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고.
|
||||
**메일 전용 리졸버**도 본다 — `mail-dns` 네트워크 · 리졸버 주소 · mail 의 `dns`, 그리고 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증하는지.
|
||||
닿지 않는 리졸버에 엔진의 DNS 를 맡기면 바깥 메일이 멈추므로 하나라도 어긋나면 중단한다(답 확인만은 개발에서 `[주의]` — 개발 메일은 Mailpit 이다). Spamhaus 키가 있으면 그 키로 시험 항목이 나오는지도 본다
|
||||
([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
1. **모드 감지** — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
|
||||
2. **bootstrap** — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
|
||||
응답의 무작위 관리자 비밀번호는 한 번만 보이므로 `data/mail/bootstrap-admin.json`(git 무시)에 잠시 보관했다가 교체 후 지운다.
|
||||
@@ -112,13 +117,17 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
8. **엔진 한도** — 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25)를 인증 없는 세션에만 건다 —
|
||||
로그인한 직원의 발송(웹메일)에는 걸지 않고 바깥에서 들어오는 메일에는 그대로. 설명·key 로 찾고, 없으면 만들지 않고 `[주의]` ([엔진 한도](#엔진-한도)).
|
||||
계정마다 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 1시간 동안 올린 양, 기본 50,000,000바이트)를 첨부 합계 한도의 20배(기본 524,288,000바이트 = 25MB × 20) 이상으로 올린다 —
|
||||
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도"). 둘 다 9 의 리로드로 반영된다
|
||||
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도").
|
||||
**엔진 DNS · Spamhaus** — 엔진의 DNS(`x:DnsResolver`)를 메일 전용 리졸버로(운영은 리졸버에 바로 `Custom`, 개발은 도커 DNS 를 거쳐 `System` — Mailpit 이름 때문),
|
||||
스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
|
||||
모두 9 의 리로드로 반영된다
|
||||
9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
|
||||
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
|
||||
200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다.
|
||||
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)).
|
||||
시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다).
|
||||
보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다
|
||||
보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다.
|
||||
엔진의 이번 기동 로그에 "DNS resolver cannot validate DNSSEC"(DANE 꺼짐) 경고가 없는지도 본다 — 리로드는 이 경고를 남기지 않아 기동 로그만 볼 수 있다
|
||||
10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11
|
||||
(7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다)
|
||||
11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
|
||||
@@ -221,12 +230,18 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr))
|
||||
- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등))
|
||||
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
|
||||
- **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
|
||||
- **메일 서버 전용 재귀 DNS** — compose 에 들어 있다(`mail-resolver` — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막힌다).
|
||||
이 서버에서 **바깥 53(UDP · TCP)으로 나갈 수 있어야 한다** — 리졸버가 루트부터 각 도메인의 DNS 서버에 직접 묻는다. 막혀 있으면 setup 이 사전 확인에서 멈춘다
|
||||
([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **Spamhaus 이용 조건** — 공개 미러는 쓰지 않는다(소규모 · 비상업 용도). Spamhaus 의 "Blocklists via DNS Query" 키를 `.env.production` 의 `MAIL_SPAMHAUS_DQS_KEY` 에 넣으면
|
||||
두 항목을 그 키로 묻고, 비워 두면 두 항목이 꺼진다. **무료 계정은 Fair Use(소규모 · 비상업) 조건이다** — 회사가 대상인지는 Spamhaus 가 판단하고, 안 되면 끈 채로 두거나 유료(30일 체험).
|
||||
**체험 키는 30일이 지나면 답이 멈춘다** — 구독하거나 키를 비우고 setup (멈춘 줄은 다음 setup 이 종료 1 로 알린다 — 주기적으로 돌리거나 계정의 만료일을 본다).
|
||||
나머지 16개 목록도 이용 조건을 확인한다 (같은 절)
|
||||
- **엔진 한도** — 받는 쪽 한도 값, 직원마다 내보내는 양의 한도, 직원마다 1시간 업로드 한도(크기 · 개수)를 정한다 ([엔진 한도](#엔진-한도))
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한).
|
||||
다른 서버의 Nginx Proxy Manager 라면 설정 · `TRUST_PROXY` 는 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`·`MAIL_RESOLVER_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- 저장소는 기본 umask(022)로 클론한다 — `mail/resolver/unbound.conf` 를 리졸버 컨테이너 사용자(101:102)가 읽어야 한다(umask 077 이면 리졸버가 뜨지 않는다)
|
||||
- **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
|
||||
rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여
|
||||
자동 차단·게이트웨이 IP 제한이 무력화된다. 배포 뒤 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 확인한다.
|
||||
@@ -272,6 +287,12 @@ node --env-file=.env.production mail/scripts/setup.mjs
|
||||
|
||||
- 3)의 `--recovery` 실행은 **사내 목록 계정**도 만든다 — 엔진 관리자는 그 권한을 내줄 수 없어 복구 관리자로만 된다 ([사내 목록 계정](#사내-목록-계정)).
|
||||
이 기능(8단계) 전부터 운영 중인 서버는 1)~5) 를 한 번 더 한다. 5)의 보통 실행은 그 계정이 있고 맞는지 읽어서 확인하고, 없으면 `[주의]` 만 낸다.
|
||||
- **DNS** — 2)의 `up` 이 메일 전용 리졸버(`mail-resolver`)를 mail 보다 먼저 띄운다. 3)에서 setup 이 리졸버를 확인한 뒤 엔진의 DNS 를 그쪽으로 바꾸고(`[변경] 엔진 DNS — … Custom`),
|
||||
4)에서 다시 만든 mail 은 그 설정으로 뜬다 — 5)의 출력에 `[확인] 메일 전용 리졸버 …` 와 `[확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다` 가 있어야 한다.
|
||||
Spamhaus 키가 있으면 `[확인] Spamhaus 키 … 시험 항목 …` 두 줄, 없으면 `[주의] MAIL_SPAMHAUS_DQS_KEY 가 없어 …` 다
|
||||
([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
|
||||
**리졸버 전에 띄운 서버**(이 기능 전 배포)는 `sudo docker compose --env-file .env.production up -d`(mail 이 다시 만들어진다 — 몇 초 끊긴다) 뒤 5)와 같은 형태로 setup 을 한 번 돌린다.
|
||||
`up` 이 `optional dependency "mail-resolver" failed to start` 경고를 내면 mail 은 떴지만 리졸버가 unhealthy · 멈춤이다 — [리졸버 장애 때](#리졸버-장애-때).
|
||||
- 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다.
|
||||
- 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면
|
||||
경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https://<MAIL_HOSTNAME>/jmap/session` → `403`.
|
||||
@@ -350,29 +371,136 @@ Gmail·네이버 등이 거부하거나 스팸 처리하지 않는다.
|
||||
|
||||
스팸 필터는 기본으로 외부 차단 목록(DNSBL) 18개를 DNS 로 조회한다 — Spamhaus(`zen` IP · `dbl` 도메인), SURBL, URIBL, Barracuda, SpamCop,
|
||||
DNSWL, Mailspike, SenderScore 등 (목록: 관리 화면 Settings › Spam Filter › DNSBL, 또는 `x:SpamDnsblServer/query`).
|
||||
이 목록들은 Stalwart 와 **별개 서비스라 각자 이용 조건이 있다.**
|
||||
이 목록들은 Stalwart 와 **별개 서비스라 각자 이용 조건이 있다.** 그리고 공용 · ISP DNS(KT `168.126.63.1` · `8.8.8.8` · `1.1.1.1` 등)를 거친 조회는
|
||||
Spamhaus 가 거부한다 — 오류 코드 `127.255.255.254` (2026-10-26 개발 환경의 KT DNS 로 늘 등록된 시험 주소를 물어 확인).
|
||||
그래서 **메일 엔진 전용 재귀 리졸버**를 compose 에 두고 엔진의 DNS 를 그쪽으로 돌린다. DNSSEC 도 검증해 기동 로그의
|
||||
"DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다.
|
||||
|
||||
1. **메일 서버 전용 재귀 DNS 를 둔다.**
|
||||
- 공용·ISP DNS(KT `168.126.63.1`, `8.8.8.8`, `1.1.1.1` 등)로 조회하면 Spamhaus 가 거부하고 오류 코드 `127.255.255.254` 를 돌려준다.
|
||||
2026-10-26 개발 환경(KT DNS)에서 항상 등록돼 있는 시험 주소를 조회해 이 오류 코드가 오는 것을 확인했다.
|
||||
- 전용 재귀 리졸버(unbound 등)를 메일 호스트나 같은 compose 네트워크에 두고, Stalwart 의 DNS 리졸버 설정을 그쪽으로 바꾼다
|
||||
(관리 화면 Settings › Network › DNS › DNS Resolver. 관리 API 로는 `x:DnsResolver` — 지금은 `@type: System`, 다른 유형의 필드 이름은 관리 화면에서 확인).
|
||||
- DNSSEC 검증도 되어 기동 로그의 "DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다.
|
||||
2. **Spamhaus 이용 조건을 맞춘다.**
|
||||
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 **무료 DQS 키**(사용량 한도 안)를 발급받아
|
||||
Spamhaus 두 항목(`STWT_RBL_SPAMHAUS_IP`, `STWT_DBL_SPAMHAUS_DOMAIN`)의 조회 주소를 DQS 주소로 바꾸거나,
|
||||
조건이 맞지 않으면 두 항목을 끈다 (`x:SpamDnsblServer/set` 으로 `enable: false` → `ReloadSettings`).
|
||||
- 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다.
|
||||
3. **조회 거부 응답의 처리를 시험한다 (미검증).**
|
||||
`127.255.255.254` 같은 "조회 거부" 응답을 "목록에 있음"으로 처리하면 정상 메일까지 스팸 점수를 받는다는 사용자 보고가 있다(Stalwart 공식 확인 아님).
|
||||
운영 전에 외부에서 시험 메일을 받아 스팸 판정 기록에 Spamhaus 항목이 잘못 붙지 않는지 확인한다.
|
||||
#### 메일 전용 리졸버 (`mail-resolver`)
|
||||
|
||||
```
|
||||
mail ──(mail-dns — internal, MAIL_RESOLVER_SUBNET)──▶ mail-resolver MAIL_RESOLVER_IP:53 ──(mail-dns-egress)──▶ 루트 · 각 도메인의 DNS 서버
|
||||
└──(app-network)──▶ 바깥 25 · 443 (메일 · ACME) — 그대로 (서버 자신의 공인 IP 로)
|
||||
```
|
||||
|
||||
| 항목 | 값 |
|
||||
| --- | --- |
|
||||
| 서비스 | `docker-compose.yml` 의 `mail-resolver` (profile `mail` — mail 과 함께 뜬다, mail 은 리졸버가 healthy 가 된 뒤에 뜬다. 리졸버가 unhealthy 거나 떠 있지 못하면 compose 가 경고만 내고 mail 을 띄운다 — `required: false`, [리졸버 장애 때](#리졸버-장애-때)). 설정 [`resolver/unbound.conf`](./resolver/unbound.conf) |
|
||||
| 이미지 | `klutchell/unbound:1.26.2` — NLnet Labs 원본 소스를 체크섬으로 확인해 빌드한 distroless 이미지(셸 없음, `drill` 포함 · 비공식). digest `sha256:50cbeb32b583…`(2026-10-30 `latest` 와 같음). **1.26.2 는 보안 릴리스**(2026-10-21 — CVE-2026-96405 이름 압축 풀기로 서비스 저하: 바깥 DNS 서버의 응답으로 닿는다)라 2주를 기다리지 않았다 ([docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정") |
|
||||
| 네트워크 | `mail-dns` — mail ↔ 리졸버 전용, **internal**(이 네트워크로는 바깥에 못 나간다), 서브넷 `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`) · 리졸버 주소 `MAIL_RESOLVER_IP`(기본 `172.16.73.254` — 서브넷의 끝 쪽. 도커는 mail 에 앞쪽부터 차례로 준다). `mail-dns-egress` — 리졸버의 바깥 조회 길, 리졸버 혼자(서브넷은 도커가 정한다). **app-network(신뢰 대역)에는 붙이지 않는다** |
|
||||
| 메일 엔진만 닿게 | 두 네트워크에 붙는 것은 mail · 리졸버뿐(setup 이 확인 — 운영은 그 밖이 있으면 중단). 호스트 포트를 열지 않는다. unbound 의 `access-control` 은 사설 대역 · 자기 자신만 받는다 — 누가 53 을 공개해도 바깥에서 쓰는 열린 리졸버가 되지 않게. **알려진 한계** — `mail-dns` 는 양쪽으로 열린 브리지라 리졸버 쪽에서도 mail 의 포트(관리 API 8080 포함)에 닿는다. 그 주소는 신뢰 대역(`APP_NETWORK_SUBNET`) 밖이라 자동 차단 예외 · 게이트웨이 로그인은 받지 못하고, 관리 API 는 관리자 자격 증명이 있어야 한다 — 리졸버가 뚫렸을 때의 일이라 HTTP 정책 식은 바꾸지 않았다 |
|
||||
| 실행 | 비-root `101:102`(이미지의 unbound 사용자 — 이미지는 root 로 떠서 내려가는 꼴이라 처음부터 이 사용자로 띄운다) · 권한(capability) 모두 뺌 · `no-new-privileges` · 메모리 128m. 비-root 가 53 에 붙는 것은 도커가 컨테이너마다 `net.ipv4.ip_unprivileged_port_start=0` 으로 두기 때문이다 |
|
||||
| DNSSEC | 검증한다 — 루트 신뢰 앵커는 이미지에 든 `root.key`(KSK-2017 · KSK-2024)이고 RFC 5011 로 스스로 고쳐 쓴다(그래서 `read_only` 로 두지 않는다). 2026-10-30 의 루트 DNSKEY 는 KSK-2024(key tag 38696)가 서명한다 |
|
||||
| 그 밖의 설정 | 사설 주소로 풀리는 바깥 이름은 버린다(`private-address` — 바깥 도메인의 MX 가 VPC · 도커 네트워크를 가리켜도 엔진이 그쪽으로 보내지 않게, **127/8 은 넣지 않는다** — DNSBL 은 127.0.0.x 로 답한다) · 리졸버 자신도 사설 주소의 DNS 서버에는 묻지 않는다(`do-not-query-address` — 같은 대역. 바깥 도메인이 이름 서버 주소(glue)를 사설 주소로 두어 리졸버가 VPC · 호스트 · 도커 네트워크의 53 에 묻게 만드는 것을 막는다, 127/8 은 기본값이 막는다) · 조회 이름은 필요한 만큼만 위로(QNAME minimisation — 루트 · .net 서버는 DQS 키가 든 이름을 보지 못한다) · 로그는 동작 정보만(`verbosity: 1` — 조회 이름을 남기지 않는다) |
|
||||
| healthcheck | `drill-hc localhost @127.0.0.1` — 데몬이 답하는지만 본다(바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게). 바깥 조회 · DNSSEC 은 setup 이 본다 |
|
||||
|
||||
**compose 서비스로 둔 까닭** (호스트에 unbound 를 까는 것과 견줌):
|
||||
|
||||
- **개발 · 운영이 같은 정의다** — 개발(Docker Desktop)에서 그대로 띄워 시험하고, 버전 고정 · CI 검사(`unbound-checkconf`) · 배포 절차(`up -d`)에 들어간다. 호스트 패키지는 저장소 밖의 상태라 서버를 다시 만들 때 빠뜨리기 쉽다.
|
||||
- **메일 엔진만 닿게 네트워크로 가를 수 있다** — 호스트에 깔면 모든 컨테이너(인터넷을 향한 frontend 포함)가 호스트 주소로 닿아, 막으려면 도커의 iptables 와 얽힌 호스트 방화벽 규칙이 필요하다. Ubuntu 의 systemd-resolved(127.0.0.53:53)와 자리도 겹친다.
|
||||
- 대가 — 이미지를 판까지 고정하고 우리가 올린다(호스트 apt 는 자동 보안 업데이트를 받는다). NLnet Labs 의 보안 릴리스 공지(<https://nlnetlabs.nl/projects/unbound/security-advisories/>)를 본다. 리졸버는 엔진 DNS 의 단일 지점이다 — 멈추면 바깥 메일의 DNS 가 모두 실패한다(보내는 메일은 대기열에서 다시 시도, 받는 메일은 검사가 약해진다). 그래서 공용 DNS 로 넘어가는 대체 길은 두지 않는다 — 넘어가면 Spamhaus 가 다시 막는다.
|
||||
|
||||
**엔진의 DNS** (`x:DnsResolver` 싱글톤 — 0.16.27 의 `@type` 은 `System` · `Custom` · `Cloudflare` · `Quad9` · `Google`, `Custom` 은 `servers { "<n>": { protocol udp|tcp|tls, address(IP), port } }`.
|
||||
`@type` 만 바꾸는 부분 갱신을 받고 공통 칸(`attempts` · `timeout` · `enableEdns` …)은 그대로 둔다 — 2026-10-30 개발 엔진에서 읽고 바꿔 확인):
|
||||
|
||||
| 환경 | setup 이 맞추는 값 | 길 | 까닭 |
|
||||
| --- | --- | --- | --- |
|
||||
| 운영 | `Custom` — `MAIL_RESOLVER_IP:53` 의 UDP + TCP | 엔진 → 리졸버 | 도커 DNS 를 거치지 않아 도커 내부 이름(backend · db …)이 엔진에 풀리지 않는다 — 바깥 도메인의 MX 가 내부 이름을 가리켜도 닿지 않는다. 엔진은 TCP 연결이 있어야 그 리졸버로 DNSSEC 을 믿는다(소스 `common/src/config/smtp/resolver.rs` 의 `ensure_dnssec`) |
|
||||
| 개발 | `System` | 엔진 → 도커 DNS(127.0.0.11) → 리졸버 | 개발의 외부 발송 경로가 도커 내부 이름 `mailpit` 이다 — `Custom` 이면 그 이름을 풀지 못해 바깥 주소 앞 메일이 `delivery.ip-lookup-failed` 로 **영구 실패(반송)** 했다(2026-10-30 개발). 도커 DNS 는 바깥 이름을 compose 의 `dns`(= 리졸버)로 넘기고 DNSSEC 자료(RRSIG)도 UDP · TCP 그대로 넘긴다 |
|
||||
|
||||
- mail 컨테이너의 `dns` 는 두 환경 모두 이 리졸버다 — 컨테이너 안의 이름 풀기(ACME · 웹 포털 내려받기 · setup 의 `getent` · `curl`)도 리졸버로 간다.
|
||||
- 바뀐 값은 setup 의 `ReloadSettings` 로 반영된다(리로드가 엔진 설정 전체를 다시 만든다). 다만 DNSSEC 확인의 경고는 **기동 때만** 로그에 남는다(리로드 응답 · 로그에는 없다) —
|
||||
setup 은 이번 기동의 로그에서 그 경고를 찾는다(`[확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다`). 엔진 DNS 를 그 실행에서 바꿨다면 다음 기동부터 새 설정의 확인이다.
|
||||
- **서버를 다시 켜면** 도커가 compose 의 차례(리졸버 먼저)를 지키지 않아 mail 이 먼저 뜰 수 있다 — 그러면 그 기동은 DNSSEC 을 믿지 못한 채(DANE 꺼짐) ·
|
||||
스팸 필터의 Pyzor 없이(주소를 풀지 못해 그 항목만 빼고 뜬다 — [리졸버 장애 때](#리졸버-장애-때) 3.) 뜨고,
|
||||
바깥 DNS 는 리졸버가 뜬 뒤부터 된다. 재부팅 뒤에는 `sudo docker compose --env-file .env.production restart mail` 뒤 setup 으로 `[확인]` 을 본다
|
||||
(개발에서 리졸버를 멈춘 채 mail 을 띄워 이 경고가 남고 setup 이 `[주의]` 로 알리는 것을 확인했다 — 2026-10-30).
|
||||
|
||||
**setup 이 하는 일** (DNS):
|
||||
|
||||
1. **사전 확인 — 바꾸기 전에** (어긋나면 아무것도 바꾸지 않고 종료 1): `mail-dns` 의 서브넷이 `MAIL_RESOLVER_SUBNET` 이고 internal 인지 · 구성원이 mail · 리졸버뿐인지 ·
|
||||
`mail-dns-egress` 에 리졸버만 붙었는지(구성원은 운영만 중단, 개발은 `[주의]`) · 리졸버가 떠 있고 주소가 `MAIL_RESOLVER_IP` 인지 · mail 이 `mail-dns` 에 있고 `dns` 가 그 주소인지,
|
||||
그리고 **메일 컨테이너 안에서** 그 주소로 DNSSEC 자료와 함께(DO) 물어 검증된 답(ad)과 서명(RRSIG)이 오는지 — 엔진과 같은 자리 · 같은 주소다.
|
||||
**UDP 는 루트 SOA**(389B 안팎), **TCP 는 루트 DNSKEY**(엔진의 기동 확인과 같은 질문 — 소스 `ensure_dnssec`)를 묻는다.
|
||||
UDP 로 루트 DNSKEY 를 묻지 않는 까닭: 지금 1,139B 인데 루트 키 교체(ZSK 미리 올리기 · KSK-2017 폐기) 동안에는 1,400B 를 넘어 EDNS 1232 의 UDP 로는
|
||||
잘린다(TC — 답 0 · 서명 0). 엔진은 그때 TCP 로 다시 묻으므로 멀쩡한 리졸버인데 setup 만 멈추게 된다(2026-10-30 리뷰 — EDNS 1100 으로 잘리는 조건을 만들어 확인).
|
||||
이 답 확인은 **운영만 멈추고 개발은 `[주의]` 뒤 이어 간다** — 바깥 53 이 막힌 개발 망에서도 개발 메일(Mailpit — 도커 DNS 가 푸는 이름)은 되기 때문이다.
|
||||
다만 리졸버가 바깥에 전혀 닿지 않으면 뒤의 설정 리로드를 엔진이 거절해(Pyzor 주소를 같은 리졸버로 푼다 — [리졸버 장애 때](#리졸버-장애-때) 3.) 개발 setup 도 거기서 종료 1 이다
|
||||
(2026-10-30 개발에서 리졸버의 바깥 길을 끊어 확인 — 저장한 설정은 남고 `restart mail` 때 적용된다). 개발 PC 도 리졸버가 바깥 53 에 닿아야 setup 이 끝난다.
|
||||
(이미지에 `dig` 이 없어 이미지의 perl 로 묻는다 — Debian 기본 `perl-base` 만 쓴다. 이미지에 perl 이 없으면(`docker exec` 종료 126 · 127) 리졸버 문제가 아니라 확인 도구 문제로 따로 알린다.)
|
||||
2. **엔진 DNS** — 위 표의 값으로(`[유지]` · `[변경] … · 전 System`). 견주는 것은 `@type` 과 서버마다의 protocol · address · port 뿐이다 —
|
||||
엔진은 `Custom` 의 servers 를 넣은 그대로 읽어 준다(2026-10-30 개발 엔진에 잠깐 저장해 확인), 엔진이 나중에 칸을 더 붙여 읽어 줘도 매번 `[변경]` 이 되지 않게.
|
||||
3. **Spamhaus 두 항목** — 아래.
|
||||
4. **반영 확인** — 리로드 뒤 이번 기동 로그에 DNSSEC 경고가 없는지. 그 기동의 로그가 남아 있지 않으면(기동 첫 줄 `server.startup` 이 없다 — 로그 회전 등)
|
||||
"경고 없음"이 아니라 `[주의] … 확인하지 못했습니다` 다(확인하려면 `restart mail` 뒤 setup — 몇 초 끊긴다).
|
||||
|
||||
`MAIL_RESOLVER_SUBNET` · `MAIL_RESOLVER_IP` 는 setup 도 검사한다 — 사설 대역 · 네트워크 주소 · `/24`~`/29` · 앱 네트워크와 겹치지 않음 · 주소는 그 안이고 네트워크 주소 · 첫 주소(도커 게이트웨이) · 끝 주소가 아님.
|
||||
바꿀 때는 둘을 함께 고치고 `docker compose … down` 후 `up -d`(네트워크를 다시 만든다) → setup.
|
||||
|
||||
#### Spamhaus — 조회 키(`MAIL_SPAMHAUS_DQS_KEY`)
|
||||
|
||||
- **공개 미러(`zen.spamhaus.org` · `dbl.spamhaus.org`)는 쓰지 않는다** — Spamhaus 의 Fair Use(소규모 · 비상업) 용도다.
|
||||
- 키는 Spamhaus 의 **"Blocklists via DNS Query"**(옛 Data Query Service — DQS) 계정에서 받는다. **무료 계정은 Spamhaus 의 Fair Use(소규모 · 비상업) 조건**이다 —
|
||||
회사가 대상인지는 Spamhaus 가 판단하고(신청 페이지: "If you are a non-commercial entity or individual with low query volumes, you may qualify"),
|
||||
안 되면 두 항목을 끈 채로 두거나 유료(상업용 30일 체험 뒤 구독)로 간다.
|
||||
[무료 계정 신청](https://www.spamhaus.com/free-trial/sign-up-for-a-free-data-query-service-account/) ·
|
||||
[공개 미러에서 옮기기](https://docs.spamhaus.com/datasets/docs/source/70-access-methods/data-query-service/015-migrating-to-dqs.html) ·
|
||||
[시험](https://docs.spamhaus.com/datasets/docs/source/70-access-methods/data-query-service/060-dqs-testing.html)
|
||||
- 키는 **서버의 `.env.production` 에만** 둔다(`MAIL_SPAMHAUS_DQS_KEY=` — 26자 영문 · 숫자). 파일에 없거나 파일과 다른 값이 셸에 있으면 setup 이 멈춘다(셸 값으로는 그 실행만 켜지고 다음 실행이 끈다). 개발은 비워 둔다.
|
||||
- **체험(30일) 키는 기간이 끝나면 답이 멈춘다** — 그 전에 구독하거나, 키를 비우고 setup 을 돌려 두 항목을 끈다. 계정이 끝나거나 키가 멈추면
|
||||
엔진의 Spamhaus 조회는 실패하고 표시(tag)가 붙지 않는다 — 메일은 그대로 받는다(닫히지 않고 열린 쪽 · fail-open). 조회마다 몇 초 늦어질 수 있다
|
||||
(틀린 키는 SERVFAIL 5초 남짓이거나 답이 없었다 — 아래 표). 엔진 로그에는 남지 않는다(조회 실패 `spam.dnsbl-error` 도 debug — 아래 "키가 남는 곳").
|
||||
**setup 은 다음에 돌 때에야** 시험 항목을 받지 못해 두 항목을 끄고 종료 1 로 알린다 — 그래서 운영은 setup 을 주기적으로 돌려 종료 0 인지 보거나 Spamhaus 계정의 만료일을 본다.
|
||||
|
||||
| 상태 | setup 이 하는 일 |
|
||||
| --- | --- |
|
||||
| 키 있음 · 시험 통과 | 그 항목을 **DQS 로 켠다** — 조회 이름 식 `ip_reverse + '.<키>.zen.dq.spamhaus.net'` · `value + '.<키>.dbl.dq.spamhaus.net'`. 응답 코드가 공개 미러와 같아 tag 식(점수)은 그대로 |
|
||||
| 키 있음 · 시험 실패 | 그 항목을 끄고 실패로 보고(종료 1) — 시험은 메일 컨테이너에서 리졸버로 늘 등록된 시험 항목을 묻는다: `2.0.0.127` → `127.0.0.2`(ZEN, `127.0.0.4` · `127.0.0.10` 도 온다) · `dbltest.com` → `127.0.1.2`(DBL). 형식만 맞는 가짜 키는 SERVFAIL(5초 남짓)이거나 12초 안에 답이 없었다(2026-10-30) |
|
||||
| 키 없음 | 두 항목을 끈다 — 운영 `[주의]`, 개발 `[참고]`(개발도 운영처럼 끈다: 공개 미러의 비상업 조건은 회사 개발 PC 에도 같고, 키는 운영 서버에만 둔다) |
|
||||
| 끌 때 | 조회 이름 식도 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게. 지금 식이 무엇이었는지는 키 없이 알린다(`전 켜짐 · 공개 미러 …` · `DQS …(다른 키)`) |
|
||||
|
||||
- **키가 남는 곳** — setup 은 키를 출력하지 않는다(식 · 옛 식도). 엔진의 오류 응답 · 거절 사유에 실려 와도 setup 의 출력(`[변경]` · `[주의]` · `[실패]` 줄 모두)은
|
||||
키를 `<MAIL_SPAMHAUS_DQS_KEY>` 로 바꿔 찍는다. 시험 조회 이름은 표준 입력으로만 넘긴다(`docker exec` 명령줄 · 도커 이벤트에 남지 않게).
|
||||
⚠️ 그러나 **엔진 설정(두 항목의 조회 이름 식)에 키가 그대로 저장된다** — 관리 화면 · 관리 API(`x:SpamDnsblServer/get`) · 메일 볼륨 백업으로 보인다.
|
||||
조회 자체에도 키가 실린다(평문 DNS — DQS 의 방식이다. 위 QNAME minimisation 으로 Spamhaus 앞 단계는 보지 못한다).
|
||||
엔진은 DNSBL 조회 이름을 debug 수준에서만 남긴다 — 답(`spam.dnsbl`)도, 실패(`spam.dnsbl-error` — 조회 이름 전체와 리졸버 오류를 싣는다)도 따로 정한 수준이 없어
|
||||
기본값 debug 다(0.16.27 소스 `crates/trc/src/event/enums_impl.rs` 의 `EventType::level`). 그래서 키가 멈춰 조회가 모두 실패해도 setup 이 둔 info 로그에는 쏟아지지 않는다.
|
||||
로그 수준을 debug 로 올리면 키가 로그에 남는다.
|
||||
- 바꾸거나 비우면 setup 을 다시 실행한다. 항목을 지웠거나 이름을 바꿨으면 setup 은 만들지 않고 `[주의]` 만 낸다.
|
||||
- **나머지 16개 목록** — 운영 전에 각 서비스의 이용 조건(상업적 사용 · 조회량 · 사전 등록)을 확인하고 맞지 않는 것은 관리 화면에서 끈다(setup 은 Spamhaus 두 항목만 맡는다).
|
||||
- **조회 거부 응답의 점수** — 0.16.27 기본값에서 Spamhaus 의 거부 · 오류 응답 tag(`RBL_SPAMHAUS_BLOCKED_OPENRESOLVER` · `RBL_SPAMHAUS_BLOCKED` · `DBL_BLOCKED_OPENRESOLVER` · `DBL_BLOCKED`, 그리고 `RECEIVED_…`)는
|
||||
**0점**이다(개발 엔진의 `x:SpamTag`, 2026-10-30) — 거부 응답이 정상 메일의 점수를 올리지는 않는다(점수가 붙는다는 사용자 보고는 이 판에서는 아니다). 이 리졸버로는 그 응답이 오지 않는다.
|
||||
|
||||
```bash
|
||||
# 리졸버가 Spamhaus 에 막혀 있는지 확인 — 127.0.0.2 는 항상 등록된 시험 주소다.
|
||||
# 정상이면 127.0.0.x 가 돌아오고, 127.255.255.254 면 그 리졸버는 막혀 있다.
|
||||
sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <리졸버 IP>
|
||||
# 리졸버가 Spamhaus 에 막혀 있지 않은지 — 127.0.0.2 는 늘 등록된 시험 주소다 (공개 미러 한 번 — 연결 확인용)
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill 2.0.0.127.zen.spamhaus.org @127.0.0.1 A
|
||||
# 127.0.0.x 가 오면 정상, 127.255.255.254 면 막혀 있다(공용 · 열린 리졸버로 보인다)
|
||||
# DNSSEC — dnssec.works 는 flags 에 ad, fail01.dnssec.works 는 SERVFAIL
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill -D dnssec.works @127.0.0.1 A
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill fail01.dnssec.works @127.0.0.1 A
|
||||
# 엔진 기동 로그의 DNSSEC 경고 — 나오지 않아야 한다
|
||||
sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC'
|
||||
# DQS 키는 명령줄에 적지 않는다 — setup 의 "[확인] Spamhaus 키 … 시험 항목 …" 줄로 본다
|
||||
```
|
||||
|
||||
확인 (2026-10-30, 개발 — Docker Desktop 28.5 · KT 망, 엔진 0.16.27):
|
||||
|
||||
- 리졸버로 `2.0.0.127.zen.spamhaus.org` → `127.0.0.10` · `127.0.0.4` · `127.0.0.2`(UDP · TCP), `dbltest.com.dbl.spamhaus.org` → `127.0.1.2` — 같은 PC 의 KT DNS 로는 `127.255.255.254` 였다.
|
||||
`dnssec.works` 는 ad, `fail01.dnssec.works` 는 SERVFAIL. 도커 DNS 를 거쳐도(`dns` 지정) 루트 DNSKEY 의 ad · RRSIG 가 UDP · TCP 그대로 왔다.
|
||||
- 엔진 기동 로그 — 리졸버 전(2026-10-26 기동, KT DNS)에는 `DnsResolver … cannot validate DNSSEC` 경고가 있었고, 리졸버 뒤 `System`(개발) · `Custom`(운영 꼴을 개발에서 잠시) 기동 모두 없었다.
|
||||
- 메일 — 465 인증 제출로 바깥 주소(Mailpit 에 도착) · 사내 주소(받은편지함), 25 인증 없는 수신(`someone@gmail.com` → doyun.lee)의 `spf=softfail`(google 의 SPF 를 실제로 따라감) · `dmarc=fail … policy.dmarc=none` 이 그대로 나왔다.
|
||||
`Custom` 으로 바꾼 동안은 Mailpit 경로가 위 표처럼 영구 실패했고, setup 으로 `System` 에 되돌린 뒤 다시 Mailpit 에 왔다.
|
||||
- setup — 처음 `[변경]` 둘(Spamhaus 두 항목 끔), 다시 돌리면 바뀐 줄 0 · 종료 0. 형식만 맞는 가짜 키 → 두 항목 꺼진 채 종료 1 · 출력에 키 없음. 리졸버를 멈추면 사전 확인에서 종료 1(바뀐 것 없음).
|
||||
`mail-dns` 에 다른 컨테이너를 붙이면 개발은 `[주의]` · 종료 0. **실제 Spamhaus 키로는 아직 시험하지 않았다** — 키를 받은 뒤 운영에서 `[확인] Spamhaus 키 …` 두 줄을 본다.
|
||||
- 리뷰 반영(같은 날) — 루트 DNSKEY(DO)는 1,139B 라 메일 컨테이너에서 EDNS 1100 으로 물으면 UDP 가 잘렸다(TC · 답 0 · 서명 0 — 예전 사전 확인이면 멈췄을 자리).
|
||||
같은 조건에서 지금 확인(UDP 루트 SOA · TCP 루트 DNSKEY)은 둘 다 ad · 서명 1 로 통과했다. compose 의 `required: false` — 리졸버 healthcheck 실패(unhealthy) ·
|
||||
설정 오류로 바로 끝남(`exited (1)`) · 운영 재시작 정책(`unless-stopped`)으로 재시작을 되풀이함 모두 `optional dependency "mail-resolver" failed to start` 경고 뒤
|
||||
mail 이 떴다(5~9초). 바꾸기 전 설정(`required` 없음)은 `dependency failed to start: container … is unhealthy` 로 끝나고 mail 이 `Created` 에 남았다(옛 mail 은 지워진 채).
|
||||
`up -d --no-deps mail` 은 리졸버를 기다리지 않는다. 엔진의 `x:DnsResolver` 를 잠깐 `Custom` 으로 저장해 읽으면 servers 가 넣은 그대로였다(되돌림).
|
||||
`domain-insecure` 한 줄은 그 도메인만 검증을 끈다(`fail01.dnssec.works` SERVFAIL → NOERROR, `dnssec.works` 는 그대로 ad, `fail02.dnssec.works` 는 그대로 SERVFAIL — 시험용 리졸버 컨테이너).
|
||||
리졸버의 바깥 길을 끊으면(캐시도 비움) 개발 setup 은 사전 확인을 `[주의]` 로 넘기고 리로드에서 엔진의 거절(Pyzor 주소)로 종료 1 이었다 — 바뀐 것 0.
|
||||
그 상태에서 엔진 DNS `Quad9` 저장 → 리로드 거절 → `restart mail` 은 DNSSEC 경고 없이 떴다(비상 길 — [리졸버 장애 때](#리졸버-장애-때)). 모두 되돌린 뒤 setup 은 `[확인]` · 바뀐 줄 0.
|
||||
|
||||
참고: [Spamhaus — 공용 DNS 로 무료 목록 쓰기](https://www.spamhaus.com/resource-center/successfully-accessing-spamhauss-free-block-lists-using-a-public-dns/),
|
||||
[Spamhaus — KT 경유 조회 안내](https://submit.spamhaus.org/resources/query-the-legacy-dnsbls-via-korea-telecom/),
|
||||
[Stalwart DNSBL 문서](https://www.stalw.art/docs/spamfilter/dnsbl)
|
||||
@@ -953,9 +1081,9 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
|
||||
| 할 일 | 값 |
|
||||
| --- | --- |
|
||||
| 서버 B 만들기 | 서버 A 와 **같은 VPC** 의 Public 서브넷, 공인 IP 하나, 메모리 8GB (11단계의 ClamAV 까지 같은 서버에 올린다). 다른 VPC 면 Peering 이 있어야 한다 — 이 절은 같은 VPC 만 다룬다 |
|
||||
| VPC 대역 | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) · `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 두 값을 다른 사설 /24 로 바꾸고 mail setup 을 다시 돌린다 |
|
||||
| VPC 대역 | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) · `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) · `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`)과 겹치지 않게 — 겹치면 서버 B 가 그 대역의 VPC 주소(서버 A 등)로 갈 길을 도커 브리지로 보낸다. 겹치면 그 값을 다른 사설 /24 로 바꾸고(리졸버는 `MAIL_RESOLVER_IP` 도 함께) mail setup 을 다시 돌린다. 리졸버의 바깥 길 `mail-dns-egress` 는 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다 — VPC 가 그 대역이면 겹치지 않는지 본다 |
|
||||
| 서버 B ACG — 인바운드 | TCP 25 · 465 · 993 · 443 ← `0.0.0.0/0` (메일 수신 · 메일 프로그램 · ACME TLS-ALPN · MTA-STS) · TCP 8080 ← **서버 A 사설 IP `/32` 만** · TCP 22 ← 관리 IP 만. 그 밖(80 · 3000 등)은 열지 않는다 |
|
||||
| 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · 53(DNS)은 남긴다 |
|
||||
| 서버 B ACG — 아웃바운드 | 좁힌다면 25(발송 — 열려 있음을 확인했다) · 443 · 80(ACME · 웹 포털 내려받기 · 외부 이미지 중계) · **53 UDP · TCP 를 모든 곳으로**(메일 전용 리졸버가 루트 · 각 도메인의 DNS 서버에 직접 묻는다 — NCP 의 DNS 서버만 열면 안 된다)은 남긴다 |
|
||||
| 서버 A ACG — 아웃바운드 | 좁혀 두었다면 서버 B 사설 IP 의 TCP 8080 을 더한다 |
|
||||
| Network ACL | 서브넷의 NACL 을 바꿔 두었다면 위 포트를 같게 연다 |
|
||||
| PTR | 서버 B 공인 IP → `mx1.arasystem.it.kr` — 가비아가 아니라 **NCP 고객지원**에 요청한다. 가비아의 `mx1` A 레코드를 먼저 넣는다(정방향 · 역방향이 맞아야 한다 — [PTR](#ptr)) |
|
||||
@@ -1162,4 +1290,93 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
|
||||
| `DKIM TXT … 의 공개 키가 엔진의 키와 다릅니다` · `DKIM TXT … 가 DNS 에 없습니다` | 키가 다르면 동료(mailcow) 앞 메일이 위조로 거절되고, 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. `setup.mjs --print-dns` 가 보여 주는 selector TXT 로 DNS 를 고치거나 넣고, 바깥에서 `dig TXT <selector>._domainkey.<도메인>` 으로 보인 뒤 다시 실행한다. 처음 켜는 실행이면 relay 를 켜지 않고 멈췄고, 이미 켜진 시범이면 켠 채로 실패만 알렸다 |
|
||||
| `[주의] 관리자 역할("System Administrator")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 …` | 관리자 역할의 이름을 바꿨거나 `emailReceive` 를 더했다 — 관리자 주소는 RCPT 거절에서 빠진다(메일을 받는 계정을 막지 않으려고). 관리자가 메일을 받지 않게 둔다면 역할을 되돌린다 |
|
||||
| `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) |
|
||||
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` |
|
||||
| `리졸버 네트워크 … 가 없습니다` · `… 가 리졸버 네트워크 … 에 없습니다` · `… 의 dns(…)가 MAIL_RESOLVER_IP(…) 하나가 아닙니다` · `… 컨테이너가 없습니다` | 메일 전용 리졸버가 든 compose 로 아직 다시 띄우지 않았다 — `docker compose … up -d`(mail-resolver 를 띄우고 mail 을 다시 만든다 — 몇 초 끊긴다) 뒤 setup. 아무것도 바뀌지 않았다 |
|
||||
| `… 가 떠 있지 않습니다`(mail-resolver) | `docker logs "${PROJECT_NAME}-mail-resolver"` — 설정 오류면 줄 번호가 나온다(`unbound-checkconf`). `could not open … unbound.conf`·`Permission denied` 면 저장소를 umask 077 로 클론했다 — `chmod 644 mail/resolver/unbound.conf`. 고친 뒤 `docker compose … up -d` |
|
||||
| mail-resolver 가 `Address already in use` 로 뜨지 않는다 | 다른 컨테이너가 `MAIL_RESOLVER_IP` 를 먼저 가져갔다(도커는 앞쪽부터 차례로 준다). `docker compose … up -d --force-recreate mail-resolver mail`, 되풀이되면 `MAIL_RESOLVER_IP` 를 서브넷의 끝 쪽으로 |
|
||||
| `… 의 … 주소(…)가 MAIL_RESOLVER_IP(…) 와 다릅니다` · `서브넷(…)이 MAIL_RESOLVER_SUBNET(…) 과 다릅니다` · `internal 이 아닙니다` | env 를 바꾼 뒤 컨테이너 · 네트워크를 다시 만들지 않았다 — `docker compose … down` 후 `up -d`(네트워크 · 컨테이너를 함께), 그다음 setup |
|
||||
| `리졸버 … 가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주지 않습니다` | 리졸버가 바깥에 묻지 못하거나 DNSSEC 이 깨졌다 — 괄호 안이 답마다의 결과다(UDP 루트 SOA · TCP 루트 DNSKEY). 이 서버의 바깥 53(UDP · **TCP**) 아웃바운드(NCP ACG), `docker exec "${PROJECT_NAME}-mail-resolver" drill . @127.0.0.1 NS`, 서버 시계(DNSSEC 은 시각을 본다), `docker logs` 의 리졸버 오류. 운영은 아무것도 바꾸지 않고 멈췄다. 개발은 `[주의]` 뒤 이어 가지만(개발 메일은 Mailpit), 리졸버가 바깥에 전혀 닿지 않으면 뒤의 리로드를 엔진이 거절해 거기서 종료 1 이다(아래 줄) |
|
||||
| `[실패] x:Action/set 요청이 거부되었습니다` + `생성 거부 (reload): validationFailed — Invalid address: failed to lookup address information` | 엔진이 설정을 다시 만들며 스팸 필터의 Pyzor 주소(`public.pyzor.org`)를 mail 컨테이너의 DNS(= 메일 전용 리졸버)로 풀지 못해 리로드를 통째로 거절했다 — 리졸버가 바깥에 닿지 못한다. `docker exec "${PROJECT_NAME}-mail-resolver" drill public.pyzor.org @127.0.0.1 A` 로 보고 [리졸버 장애 때](#리졸버-장애-때). 저장한 설정은 남아 있다(다음 리로드 · `restart mail` 때 적용) |
|
||||
| `메일 컨테이너(…)에서 리졸버를 확인하지 못했습니다 — 메일 컨테이너에 perl 이 없음(docker exec 종료 코드 127)` | 리졸버 문제가 아니다 — setup 은 메일 이미지의 perl 로 묻는데 `MAIL_IMAGE` 를 바꾼 새 이미지에 perl 이 없다(`docker exec "${PROJECT_NAME}-mail" perl -v`). 운영은 여기서 멈춘다 — 이미지를 되돌리거나, setup 의 리졸버 확인(`DNS_PROBE_SCRIPT`)을 그 이미지에 맞춘다. 개발은 `[주의]` |
|
||||
| `[주의] … 의 이번 기동 … 로그가 남아 있지 않아 … DNSSEC 을 검증했는지 확인하지 못했습니다` | mail 이 오래전에 떠서 그 기동의 로그가 회전(`json-file` 의 `max-size` · `max-file` 등)으로 지워졌다 — 실패가 아니다(메일 · 리졸버는 사전 확인대로다). 확인하려면 `docker compose … restart mail`(몇 초 끊긴다) 뒤 setup |
|
||||
| compose `up` 이 `optional dependency "mail-resolver" failed to start` 경고를 낸다 | mail 은 떴지만 리졸버가 unhealthy · 멈춤이라 바깥 DNS(바깥 메일 · SPF · DKIM · DNSBL)가 안 된다 — [리졸버 장애 때](#리졸버-장애-때) |
|
||||
| 특정 회사(도메인)로만 메일이 안 간다 · 그 도메인에서 온 메일만 SPF · DKIM · DMARC 가 `temperror` | 그 도메인의 DNSSEC 이 깨졌을 수 있다(리졸버가 검증해 SERVFAIL 로 답한다) — [DNSSEC 이 깨진 도메인](#dnssec-이-깨진-도메인-특정-회사로만-메일이-안-간다) |
|
||||
| `[주의] 리졸버 네트워크 … 에 mail · mail-resolver 밖의 컨테이너가 붙어 있습니다` · `리졸버의 바깥 길 … 에 다른 컨테이너가` (운영은 실패) | 그 컨테이너도 리졸버에 닿는다(리졸버도 그 컨테이너에 닿는다) — `docker network disconnect <네트워크> <컨테이너>`, compose 의 `networks` 확인 |
|
||||
| `[주의] 엔진이 … 때 리졸버로 DNSSEC 을 검증하지 못했다고 남겼습니다` | mail 이 리졸버보다 먼저 떴거나(서버 재부팅 — 도커는 compose 의 차례를 지키지 않는다. `docker compose … restart mail` 뒤 setup 으로 다시 본다), 리졸버가 TCP 로 답하지 못한다. 메일은 오가고 DANE 만 꺼진다. 리로드도 같은 확인을 다시 하지만 결과가 로그에 남지 않아 기동 로그로만 본다 |
|
||||
| `[주의] MAIL_SPAMHAUS_DQS_KEY 가 없어 Spamhaus 두 항목 … 을 꺼 둔다` (운영) | 키가 없으면 늘 나온다 — 실패가 아니다. 키를 받으면 `.env.production` 에 넣고 setup ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl) "Spamhaus") |
|
||||
| `Spamhaus 키(…)로 … 시험 항목 … 을 받지 못해 그 항목을 꺼 둡니다` | 키가 틀렸거나 · 계정이 끝났거나(체험 30일이 지났다 — 구독하거나 키를 비우고 setup) · 그 서비스(IP · 도메인)가 계정에서 꺼져 있다(꺼진 서비스는 SERVFAIL). Spamhaus 계정 화면에서 키 · 서비스를 본 뒤 setup. 그 항목은 꺼진 채다 — 공개 미러로 묻지 않는다. 키가 멈춘 뒤 이 실행 전까지는 엔진의 조회가 실패해 표시가 붙지 않았다(fail-open — "Spamhaus" 절) |
|
||||
| `MAIL_SPAMHAUS_DQS_KEY 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다` · `운영의 MAIL_SPAMHAUS_DQS_KEY 는 셸이 아니라 …` | 키를 `.env.production` 에 따옴표 · 공백 없이 그대로 넣는다(셸에는 두지 않는다 — `unset MAIL_SPAMHAUS_DQS_KEY`) |
|
||||
| `[주의] 스팸 차단 목록 …(STWT_…)을 찾지 못해 맞추지 않았습니다` | 엔진 기본 항목을 지웠거나 이름을 바꿨다 — 바꿨다면 그 항목의 조회 이름 · 켜짐을 관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 |
|
||||
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*`. `delivery.ip-lookup-failed … hostname = "mailpit"` 이면 개발 엔진의 DNS 가 `Custom` 이다 — setup 을 다시 돌리면 `System` 으로 돌아온다 |
|
||||
|
||||
### 리졸버 장애 때
|
||||
|
||||
메일 전용 리졸버(`mail-resolver`)가 멈추거나 바깥에 묻지 못하면 엔진의 바깥 DNS 가 모두 실패한다 — 바깥으로 보내는 메일은 MX 를 찾지 못해 대기열에서 다시 시도하고,
|
||||
받는 메일은 SPF · DKIM · DMARC · DNSBL 검사가 약해진다. 사내 메일 · 웹메일은 그대로 된다. 다만 **설정 리로드가 막힌다**(엔진이 Pyzor 주소를 풀지 못해 리로드를 통째로 거절 — 아래 3.).
|
||||
표시는 compose 의 `optional dependency "mail-resolver" failed to start` 경고, setup 사전 확인의 `… 가 떠 있지 않습니다` · `… DNSSEC 을 검증한 답을 주지 않습니다`,
|
||||
setup · 관리 화면 리로드의 `validationFailed — Invalid address: failed to lookup address information` 다. 자동으로 공용 DNS 로 넘어가는 길은 두지 않는다
|
||||
(넘어가면 Spamhaus 가 다시 막고, 장애를 모른 채 지나간다 — [메일 전용 리졸버](#메일-전용-리졸버-mail-resolver) "대가"). 아래는 사람이 고르는 길이다.
|
||||
|
||||
1. **리졸버부터 고친다** (운영 명령 — 개발은 `docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development`):
|
||||
```bash
|
||||
sudo docker ps -a --filter "name=${PROJECT_NAME}-mail-resolver" # Up (healthy) 여야 한다
|
||||
sudo docker logs --tail 20 "${PROJECT_NAME}-mail-resolver" # 설정 오류면 파일 · 줄 번호가 나온다
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill . @127.0.0.1 NS # 바깥(루트)에 묻는가
|
||||
# 설정을 고쳤으면 리졸버만 다시 만든다 (설정 파일만 바뀌면 compose 가 다시 만들지 않는다 — mail 은 그대로 둔다)
|
||||
sudo docker compose --env-file .env.production up -d --force-recreate mail-resolver
|
||||
```
|
||||
healthy 가 되면 setup 을 돌려 `[확인] 메일 전용 리졸버 …` 를 본다("운영 배포" 5)와 같은 형태). mail 이 리졸버 없이 떴다면 그 기동은 DANE 이 꺼지고 Pyzor 가 빠진 채다 —
|
||||
setup 의 리로드가 둘 다 다시 만들지만 리로드의 DNSSEC 확인은 로그에 남지 않으므로, 확인하려면 `sudo docker compose --env-file .env.production restart mail`(몇 초 끊긴다)
|
||||
뒤 setup 의 `[확인] 엔진 기동 로그(…)에 DNSSEC 경고가 없다` 를 본다.
|
||||
2. **mail 이 리졸버 때문에 뜨지 않을 때** — compose 는 리졸버가 unhealthy · 멈춤(설정 오류로 바로 끝남) · 재시작을 되풀이해도 경고만 내고 mail 을 띄운다
|
||||
(mail 의 `depends_on` 이 `required: false` — 2026-10-30 개발에서 셋 다 확인. 리졸버의 healthcheck 가 unhealthy 로 판정할 때까지는 기다린다 — 최대 2분쯤).
|
||||
그래도 mail 이 `Created` 에 남았다면(`required` 를 지운 compose 등) 리졸버를 기다리지 않고 띄운다:
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production up -d --no-deps mail
|
||||
```
|
||||
3. **리졸버를 곧 고칠 수 없을 때 — 비상 길: 엔진 DNS 를 공용 DNS 로.** 엔진 DNS 를 `System` 으로 돌려도 소용없다 — mail 컨테이너의 `dns:` 가 같은 리졸버라
|
||||
도커 DNS 도 바깥 이름을 그 리졸버에 묻는다. 관리 API(관리 화면의 DNS Resolver 설정과 같다 — 운영은 SSH 터널, "운영 배포")로 `@type` 을 `Quad9` 이나 `Cloudflare` 로 저장한 뒤
|
||||
**리로드가 아니라 mail 을 다시 띄운다** ([외부 릴레이](#외부-릴레이-ses-등)와 같은 호출 형식):
|
||||
```jsonc
|
||||
[["x:DnsResolver/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "update": { "singleton": { "@type": "Quad9" } } }, "0"]]
|
||||
```
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production restart mail # 몇 초 끊긴다
|
||||
```
|
||||
- **리로드(`ReloadSettings` · 관리 화면의 Reload)는 이때 엔진이 거절한다** — 설정을 다시 만들며 스팸 필터의 Pyzor 주소(`public.pyzor.org`)를 mail 컨테이너의 DNS(= 죽은 리졸버)로 풀다
|
||||
실패하면 리로드 전체를 버린다(`validationFailed — Invalid address: failed to lookup address information`). 기동은 그 항목(Pyzor)만 빼고 뜬다(로그 `registry.build-error … SpamPyzor`)
|
||||
— 0.16.27 소스 `crates/common/src/config/mailstore/spamfilter.rs` · `crates/common/src/cache/reload.rs`.
|
||||
2026-10-30 개발에서 리졸버의 바깥 길을 끊고 확인했다: `System` 으로 다시 띄우면 DNSSEC 경고(엔진 DNS 가 안 된다), `Quad9` 저장 뒤 리로드는 위 거절,
|
||||
`restart mail` 뒤에는 DNSSEC 경고 없이 떴다(엔진 DNS 가 Quad9 로 된다 — Pyzor 만 빠진 채).
|
||||
- 엔진은 `@type` 만 바꾸는 부분 갱신을 받고, 두 값 모두 기본이 `"useTls": true`(DNS over TLS — 바깥 853/TCP)다.
|
||||
이 서버에서 바깥 853 이 막혀 있으면 `"useTls": false` 를 함께 준다(53 UDP · TCP).
|
||||
- 그동안은 공용 DNS 를 거친다 — 공용 DNS 조회를 거부하는 차단 목록은 답하지 않아 검사가 약해진다(메일은 오간다). mail 컨테이너 자체의 이름 풀기(Pyzor · ACME · `getent`)는 여전히 리졸버라 안 된다.
|
||||
- **리졸버를 고친 뒤 setup 을 다시 돌린다** — 사전 확인을 통과하면 `[변경] 엔진 DNS — … Custom … · 전 Quad9` 처럼 리졸버로 되돌리고 리로드한다(개발은 `System`) —
|
||||
그 리로드가 빠졌던 Pyzor 도 다시 만든다. 기동 로그로 DNSSEC 을 확인하려면 그 뒤 `restart mail` 한 번과 setup(`[확인] 엔진 기동 로그 …`).
|
||||
운영 setup 은 리졸버가 확인되기 전에는 사전 확인에서 멈춰 엔진 DNS 를 바꾸지 않는다 — 비상 길을 연 채로 돌려도 공용 DNS 가 그대로 남으니 리졸버부터 고친다
|
||||
(개발 setup 은 `[주의]` 뒤 이어 가며 `System` 으로 저장한다 — 개발은 Mailpit 이라 비상 길이 필요 없다).
|
||||
|
||||
### DNSSEC 이 깨진 도메인 (특정 회사로만 메일이 안 간다)
|
||||
|
||||
메일 전용 리졸버는 DNSSEC 을 검증한다 — 서명이 깨진 도메인(만료된 RRSIG · 키를 바꾸다 상위의 DS 를 고치지 못함 등)은 SERVFAIL 이 된다.
|
||||
그 도메인으로 보내는 메일은 MX 를 찾지 못해 대기열에서 다시 시도하다 끝내 반송되고, 그 도메인에서 오는 메일은 SPF · DKIM · DMARC 조회가 `temperror` 다.
|
||||
검증을 켜면서 새로 생길 수 있는 증상이다(검증하지 않는 DNS 로는 답이 왔다). 다른 회사로는 잘 간다면 이것부터 본다.
|
||||
|
||||
1. **그 도메인 쪽 문제인지 본다** — 검증을 끄고(CD 비트) 물으면 답이 오는데 검증하면 SERVFAIL 이면 그 도메인의 DNSSEC 이 깨졌다:
|
||||
```bash
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill <도메인> @127.0.0.1 MX # rcode: SERVFAIL
|
||||
sudo docker exec "${PROJECT_NAME}-mail-resolver" drill -o CD <도메인> @127.0.0.1 MX # rcode: NOERROR + 답 → DNSSEC 이 깨졌다
|
||||
```
|
||||
(예: 시험용으로 일부러 깨 둔 `fail01.dnssec.works` 는 앞 줄 SERVFAIL, 뒷줄 `5.45.109.212` — 2026-10-30.) CD 로 물어도 SERVFAIL 이면 DNSSEC 이 아니라
|
||||
그 도메인의 DNS 서버가 답하지 않는 것이다 — 아래 예외로 풀리지 않는다. 바깥 도구로도 한 번 더 본다: <https://dnsviz.net/> · <https://dnssec-analyzer.verisignlabs.com/>
|
||||
(빨간 표시 — 다른 검증 리졸버(Google · Cloudflare)도 같은 SERVFAIL 을 준다).
|
||||
2. **그 도메인의 관리자에게 알린다** — 그쪽이 고치면 저절로 풀린다(unbound 는 검증 실패를 1분만 기억한다 — `val-bogus-ttl` 기본값 60).
|
||||
3. **급하면 그 도메인만 검증 예외로** — `mail/resolver/unbound.conf` 의 `server:` 안에 한 줄 더하고 리졸버만 다시 만든다:
|
||||
```
|
||||
domain-insecure: "example.com"
|
||||
```
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production up -d --force-recreate mail-resolver # 몇 초 동안 바깥 DNS 가 끊긴다(엔진이 다시 시도한다)
|
||||
```
|
||||
그 도메인과 하위 도메인 전체의 DNSSEC 검증을 끈다 — 그 도메인으로 가는 메일의 DANE 도 꺼진다. 다른 도메인은 그대로 검증한다
|
||||
(시험용 리졸버 컨테이너에서 `fail01.dnssec.works` 만 SERVFAIL → NOERROR, `dnssec.works` 는 그대로 ad · `fail02.dnssec.works` 는 그대로 SERVFAIL — 2026-10-30).
|
||||
예외는 저장소 파일이라 커밋 기록에 남는다 — 그쪽이 고친 것을 1. 로 확인하면 지운다.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# ==========================================
|
||||
# 메일 엔진 전용 재귀 DNS 리졸버 (unbound) — docker-compose.yml 의 mail-resolver 가 읽는다
|
||||
#
|
||||
# 엔진(Stalwart)의 DNS 조회 — 스팸 차단 목록(DNSBL) · SPF · DKIM · DMARC · MX · PTR · TLSA(DANE) — 를
|
||||
# 공용 · ISP DNS 가 아니라 이 서버에서 루트부터 직접 푼다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").
|
||||
# - Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다(127.255.255.254). 이 리졸버는 서버 자신의 공인 IP 로 묻는다
|
||||
# - DNSSEC 을 검증한다 — 엔진의 DANE 이 켜진다 (기동 로그 "DNS resolver cannot validate DNSSEC" 가 사라진다)
|
||||
#
|
||||
# 이미지 klutchell/unbound(distroless — 셸 없음)의 기본 설정을 이 파일로 통째로 바꾼다.
|
||||
# compose 가 처음부터 비-root(101:102)로 띄우므로 이 파일은 그 사용자가 읽을 수 있어야 한다(644 — 저장소를 umask 077 로 클론하지 않는다).
|
||||
# 바꾼 뒤에는 docker compose … up -d --force-recreate mail-resolver (설정 파일만 바뀌면 compose 가 다시 만들지 않는다)
|
||||
# ==========================================
|
||||
server:
|
||||
# 컨테이너 — 앞에서 돌고, 사용자를 바꾸지 않고(처음부터 비-root), chroot 하지 않는다
|
||||
do-daemonize: no
|
||||
username: ""
|
||||
chroot: ""
|
||||
directory: "/var/unbound"
|
||||
pidfile: ""
|
||||
logfile: ""
|
||||
use-syslog: no
|
||||
# 1 = 동작 정보와 오류만. 조회 이름은 남기지 않는다 — DNSBL 조회 이름에 Spamhaus DQS 키가 들어간다
|
||||
# (log-queries · log-replies · log-servfail · val-log-level 은 기본값(끔) 그대로 둔다)
|
||||
verbosity: 1
|
||||
|
||||
# 받는 곳 — 컨테이너의 53 (UDP · TCP). 엔진은 TCP 도 쓸 수 있어야 DNSSEC 을 믿는다 (Stalwart 의 기동 확인)
|
||||
# 이 컨테이너는 메일 엔진과의 전용 네트워크(mail-dns — 바깥 길 없음)와 자기 바깥 조회 길(mail-dns-egress)에만 붙고
|
||||
# 호스트 포트를 열지 않는다 — 닿는 것은 메일 엔진뿐이다. (비-root 가 53 에 붙는 것은 도커가 컨테이너마다
|
||||
# net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 — 권한(capability)은 모두 뺀다)
|
||||
interface: 0.0.0.0
|
||||
port: 53
|
||||
do-ip4: yes
|
||||
do-ip6: no
|
||||
do-udp: yes
|
||||
do-tcp: yes
|
||||
|
||||
# 묻는 쪽 — 사설 대역(도커 네트워크)과 자기 자신(healthcheck)만 받는다. 1차 막은 네트워크 구성(메일 엔진만 붙는다)이고, 이것은 그 뒤의 막이다 —
|
||||
# 누가 53 을 호스트에 공개해도 바깥(공인 주소)에서 쓰는 열린 리졸버가 되지 않게
|
||||
access-control: 0.0.0.0/0 refuse
|
||||
access-control: 127.0.0.0/8 allow
|
||||
access-control: 10.0.0.0/8 allow
|
||||
access-control: 172.16.0.0/12 allow
|
||||
access-control: 192.168.0.0/16 allow
|
||||
|
||||
# DNSSEC 검증 — 루트 신뢰 앵커는 이미지에 든 root.key(KSK-2017 · KSK-2024)다. RFC 5011 로 스스로 고쳐 쓰므로
|
||||
# /var/unbound 는 쓸 수 있어야 한다(컨테이너 쓰기 계층 — read_only 로 두지 않는다). 이미지를 올리면 새 파일로 시작한다
|
||||
module-config: "validator iterator"
|
||||
auto-trust-anchor-file: "/var/unbound/root.key"
|
||||
root-hints: "/var/unbound/root.hints"
|
||||
harden-dnssec-stripped: yes
|
||||
harden-glue: yes
|
||||
harden-below-nxdomain: yes
|
||||
|
||||
# 조회 이름은 필요한 만큼만 위로 보낸다(QNAME minimisation — 기본값) — 루트 · .net 서버는 DQS 키가 든 이름 전체를 보지 못한다
|
||||
qname-minimisation: yes
|
||||
hide-identity: yes
|
||||
hide-version: yes
|
||||
|
||||
# 사설 · 내부 주소로 풀리는 바깥 이름은 버린다 — 바깥 도메인의 MX 가 이 서버의 사설망(VPC · 도커 네트워크)을 가리켜
|
||||
# 엔진이 그쪽으로 메일을 보내는 일이 없게.
|
||||
# ⚠️ 127.0.0.0/8 은 넣지 않는다 — 스팸 차단 목록(DNSBL)은 127.0.0.x 로 답한다
|
||||
private-address: 10.0.0.0/8
|
||||
private-address: 172.16.0.0/12
|
||||
private-address: 192.168.0.0/16
|
||||
private-address: 169.254.0.0/16
|
||||
private-address: 100.64.0.0/10
|
||||
private-address: fc00::/7
|
||||
private-address: fe80::/10
|
||||
|
||||
# 리졸버 자신도 사설 · 내부 주소의 DNS 서버에는 묻지 않는다(위 private-address 와 짝) — 바깥 도메인이 이름 서버(NS)의 주소(glue)를
|
||||
# 사설 주소로 두어 이 리졸버가 VPC · 호스트 · 도커 네트워크(메일 엔진 포함)의 53 에 묻게 만드는 것을 막는다.
|
||||
# 127.0.0.0/8 · ::1 은 기본값(do-not-query-localhost: yes)이 이미 막는다. 이 리졸버는 루트부터 직접 풀어 사설 주소에 물을 일이 없다
|
||||
do-not-query-address: 10.0.0.0/8
|
||||
do-not-query-address: 172.16.0.0/12
|
||||
do-not-query-address: 192.168.0.0/16
|
||||
do-not-query-address: 169.254.0.0/16
|
||||
do-not-query-address: 100.64.0.0/10
|
||||
do-not-query-address: fc00::/7
|
||||
do-not-query-address: fe80::/10
|
||||
|
||||
# 크기 — 메일 서버 하나 몫 (compose 메모리 한도 128m 안)
|
||||
num-threads: 1
|
||||
msg-cache-size: 8m
|
||||
rrset-cache-size: 16m
|
||||
edns-buffer-size: 1232
|
||||
so-rcvbuf: 0
|
||||
so-sndbuf: 0
|
||||
prefetch: yes
|
||||
prefetch-key: yes
|
||||
+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