generated from ttipo/project-base
feat(docker): 리버스 프록시가 다른 서버에 있을 때 — frontend 를 사설 IP 에만 게시하는 선택 파일 #6
@@ -9,7 +9,8 @@
|
||||
|
||||
- [ ] 루트 `.env.{env}.example` → `.env.{env}` 복사 후 **placeholder 값 교체** (`change-me-*` 가 남아 있으면 안 된다, [[env-structure]])
|
||||
운영은 `BACKEND_API_URL`·`CORS_ORIGIN` 을 **실제 도메인**으로 — `localhost`/`example.com` 이 남으면 기동 실패한다
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1)도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
운영은 `TRUST_PROXY`(앞단 프록시 홉 수 — 외부 리버스 프록시 뒤면 2, 없으면 1. 프록시를 다른 프로젝트와 함께 쓰거나 다른 서버에 두면
|
||||
주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 IP>` — [[docker]] "외부 리버스 프록시 요구사항")도 필수다. 앱 네트워크 대역 `APP_NETWORK_SUBNET` 은
|
||||
호스트의 다른 도커·사내 네트워크와 겹치지 않는 값으로 둔다 (메일 엔진이 이 대역만 신뢰한다 — [[docker]])
|
||||
- [ ] `backend/.env.{env}.example` → `backend/.env.{env}` 복사 후 `JWT_SECRET` 등 고유 시크릿 작성 (compose `env_file` 로 로드 — 파일이 없으면 기동 실패)
|
||||
- [ ] `flutter/.env.example` → `flutter/.env` 복사 후 `API_URL` 확인 (Android 에뮬레이터: `10.0.2.2`)
|
||||
|
||||
@@ -16,10 +16,14 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
├─▶ frontend : 빌드 arg VITE_API_BASE_URL=/api 고정 (같은 주소 배포 — env 로 바꾸지 않는다)
|
||||
│ 런타임 env MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB → nginx 본문 한도,
|
||||
│ NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT} (frontend/nginx-env.envsh — 값이 틀리면 기동 중단)
|
||||
│ FRONTEND_PRIVATE_BIND(프록시가 다른 서버일 때만 — docker-compose.remote-proxy.yml, 사설 IPv4:포트) → 8080 게시
|
||||
├─▶ compose : COMPOSE_PROFILES · COMPOSE_FILE 도 --env-file 에서 읽는다 (운영의 remote-proxy 는 COMPOSE_FILE 로 — 빈 COMPOSE_FILE= 은 실패하니 주석으로)
|
||||
│ (호스트에서 `npm run dev` 로 띄울 때는 compose 를 거치지 않으므로
|
||||
│ vite.config.ts 가 이 파일을 `loadEnv` 로 직접 읽어 FRONTEND_PORT/BACKEND_PORT 사용)
|
||||
├─▶ network : APP_NETWORK_SUBNET → app-network 서브넷 고정 (메일 엔진의 신뢰 대역 — 사설 대역 /24~/28, frontend 는 붙지 않는다)
|
||||
│ EDGE_NETWORK_SUBNET → edge(frontend ↔ backend) 서브넷 고정 (TRUST_PROXY 주소 목록용)
|
||||
│ MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP → mail-dns(mail ↔ 메일 전용 DNS 리졸버, internal) 서브넷 · 리졸버 주소 고정
|
||||
│ (mail 의 dns · 엔진의 DNS 가 이 IP 를 쓴다 — 둘을 함께 바꾸고 setup 을 다시 실행한다)
|
||||
├─▶ db : DB_USER/DB_PASSWORD/DB_NAME, DB_DATA_DIR 볼륨
|
||||
├─▶ redis : REDIS_PASSWORD, REDIS_DATA_DIR 볼륨
|
||||
├─▶ uploads : 운영은 named volume <PROJECT_NAME>_uploads (env 없음), 개발만 UPLOAD_DATA_DIR 바인드 마운트
|
||||
@@ -31,6 +35,8 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
└─▶ mail/scripts/setup.mjs : `node --env-file=.env.{APP_ENV}` 로 같은 파일을 직접 읽는다 (mail/README.md)
|
||||
MAIL_ATTACHMENT_*(첨부 한도)도 읽는다 — 엔진의 임시 업로드 한도를 그 20배로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 돌린다
|
||||
MAIL_PILOT_LEGACY_HOST · MAIL_PILOT_LEGACY_IPS(10단계 파일럿 공존 — 옛 서버 주소 · 그 서버가 보내는 IP)는 setup 만 읽는다(compose · backend 에 넘기지 않는다)
|
||||
MAIL_SPAMHAUS_DQS_KEY(Spamhaus 조회 키 — 비밀)도 setup 만 읽는다. 운영은 .env.production 에만(셸에만 있으면 setup 이 멈춘다),
|
||||
개발은 비워 둔다 — 비면 Spamhaus 두 항목을 끈다. 키는 엔진의 스팸 필터 설정에 그대로 저장된다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
```
|
||||
|
||||
규칙:
|
||||
@@ -56,6 +62,7 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
스크립트는 env 파일 값과 셸 값이 다르면 중단해야 한다 (`mail/scripts/setup.mjs` 의 `findShellOverrides()`).
|
||||
예외는 복구 관리자 `MAIL_RECOVERY_ADMIN`(원래 셸에서 일회성으로 주는 값이라 검사하지 않는다)과 파일럿 키 둘이다 — 파일럿 키는 개발은 한 번 시험하려고 셸로 줄 수 있고(`[참고]` 를 낸다),
|
||||
운영은 반대로 파일에 없는 파일럿 값이 셸에 있으면 중단한다 (파일럿은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 한다). 운영에서 파일럿을 끄는 것은 값을 비우고 `--end-pilot` 을 줄 때뿐이다 — 값이 빠졌다고 저절로 끄지 않는다.
|
||||
Spamhaus 키 `MAIL_SPAMHAUS_DQS_KEY` 도 운영은 파일에만 둔다 — 셸에만 있으면 그 실행만 켜지고 다음 실행이 끄므로 중단한다.
|
||||
- Node 는 따옴표 없는 값의 `#` 부터를 **공백이 없어도** 주석으로 자르고, compose 는 그대로 읽는다.
|
||||
비밀번호 등은 영문·숫자로만 만든다 (`#`·따옴표·`$`·공백 금지).
|
||||
- 모든 env 파일은 커밋 금지, `*.example` 만 커밋한다. (`.gitignore` 의 `!*.example` 재포함 규칙)
|
||||
@@ -72,12 +79,13 @@ description: 환경변수 단일 진실 공급원(SSOT) 구조 — 루트 .env.{
|
||||
- 문제는 하나씩이 아니라 **한 번에 모아서** 보여준다 (재기동 반복 방지)
|
||||
- 새 필수 설정을 추가하면 이 파일의 목록과 테스트도 함께 갱신한다
|
||||
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`,
|
||||
> 루트 env의 대표 키: `PROJECT_NAME`, `APP_ENV`, `BUILD_MODE`, `COMPOSE_PROFILES`·`COMPOSE_FILE`(운영의 remote-proxy 일 때), 각종 `*_PORT`, `APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`·`MAIL_RESOLVER_SUBNET`/`MAIL_RESOLVER_IP`, `BACKEND_API_URL`, `CORS_ORIGIN`, `TRUST_PROXY`, `FRONTEND_PRIVATE_BIND`(remote-proxy 일 때),
|
||||
> `DB_*`/`DB_DATA_DIR`, `REDIS_*`/`REDIS_DATA_DIR`, `THROTTLE_TTL_MS`/`THROTTLE_LIMIT`, `UPLOAD_MAX_SIZE_MB`(·개발 전용 `UPLOAD_DATA_DIR`),
|
||||
> 메일 `MAIL_IMAGE`(패치까지 고정)·`MAIL_DOMAIN`·`MAIL_HOSTNAME`·`MAIL_PUBLIC_URL`·`MAIL_ADMIN_LOCAL_PART`(관리자 아이디, 기본 `admin` — setup·backend 공용, bootstrap 전에만)·
|
||||
> `MAIL_ADMIN_PASSWORD`·`MAIL_RECOVERY_ADMIN`(운영은 일회성 override)·`MAIL_GATEWAY_USER`/`MAIL_GATEWAY_PASSWORD`·`MAIL_DIRECTORY_LOCAL_PART`(사내 목록 계정 아이디, 기본 `mail-directory` — setup·backend 공용)·
|
||||
> `MAIL_JMAP_URL`·`MAIL_ATTACHMENT_MAX_SIZE_MB`/`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`·`MAIL_TRUSTED_NETWORKS`(비우면 `APP_NETWORK_SUBNET`, 그보다 넓으면 setup 실패)·
|
||||
> `MAIL_BLOCKED_ACCOUNTS`(선택 — 관리자 외에 웹메일 로그인·대리 접근을 막을 주소, 콤마. 비어 있어도 관리자는 항상 막힌다)·
|
||||
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")
|
||||
> `MAIL_PILOT_LEGACY_HOST`/`MAIL_PILOT_LEGACY_IPS`(선택 — 파일럿 공존 동안만, setup 전용. mail/README.md "파일럿 공존")·
|
||||
> `MAIL_SPAMHAUS_DQS_KEY`(선택 · 비밀 — Spamhaus 조회 키, setup 전용, 운영 파일에만)
|
||||
> (개발 전용 `MAIL_ADMIN_PORT`·`MAIL_DEV_USER_PASSWORD`·`MAILPIT_IMAGE`/`MAILPIT_UI_PORT`). 실제 목록은 `.env.development.example` 참조.
|
||||
> 실행/배포 명령은 [[docker]], 보안 규칙은 [[security]] 참조.
|
||||
|
||||
@@ -50,6 +50,7 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
│ (app-network = 메일 엔진의 신뢰 대역)
|
||||
├─▶ db · redis
|
||||
└─▶ mail :8080 (JMAP·관리 API)
|
||||
└─(mail-dns)─▶ mail-resolver ─(mail-dns-egress)─▶ 바깥 DNS
|
||||
```
|
||||
|
||||
| 네트워크 | 붙는 컨테이너 | 비고 |
|
||||
@@ -57,6 +58,8 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
| `proxy` (external) | 외부 리버스 프록시 · frontend | 다른 프로젝트와 함께 쓸 수 있다. 운영 전용 (dev 오버라이드가 지운다) |
|
||||
| `<PROJECT_NAME>_edge` | frontend · backend | `EDGE_NETWORK_SUBNET`(기본 `172.16.72.0/24`) 고정 — TRUST_PROXY 주소 목록용 |
|
||||
| `<PROJECT_NAME>_app-network` | backend · db · redis · mail (dev 는 mailpit 도) | `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`) 고정 — **메일 엔진의 신뢰 대역**. frontend 는 붙이지 않는다 |
|
||||
| `<PROJECT_NAME>_mail-dns` | mail · mail-resolver | `MAIL_RESOLVER_SUBNET`(기본 `172.16.73.0/24`) 고정 · `internal`(바깥 길 없음). 리졸버 주소 `MAIL_RESOLVER_IP`(기본 `172.16.73.254`)를 mail 의 `dns` 와 엔진 DNS 가 쓴다 |
|
||||
| `<PROJECT_NAME>_mail-dns-egress` | mail-resolver | 리졸버가 바깥 DNS 서버에 묻는 길. 서브넷은 고정하지 않는다(도커 기본 풀) — 서버의 사설망(VPC)과 겹치지 않는지 본다 |
|
||||
|
||||
- 브라우저는 화면과 같은 주소의 `/api` 를 부른다 (docs/mail/api-contract.md 머리말). 프론트 빌드는 `VITE_API_BASE_URL=/api` 로 고정이다.
|
||||
**backend 는 proxy 네트워크에 붙지 않는다** — 바깥에서는 nginx 의 `/api` 로만 들어온다.
|
||||
@@ -115,13 +118,17 @@ sudo docker compose --env-file .env.production logs -f backend
|
||||
- `MAIL_ATTACHMENT_MAX_SIZE_MB`·`UPLOAD_MAX_SIZE_MB`: 빈 값은 기본값(20·5), 그 밖은 0 으로 시작하지 않는 6자리 이하 양의 정수만 (`08`·`010` 거부 — 셸 산술이 8진수로 읽는다)
|
||||
- `NGINX_BACKEND_UPSTREAM`: `http://호스트[:포트]` 만. 경로(끝의 `/` 포함)를 붙이면 모든 `/api` 요청이 그 경로 하나로 가므로 거부한다
|
||||
- **외부 리버스 프록시 요구사항** (클라이언트 → 외부 프록시 → nginx → 백엔드 — 이 compose 의 운영 경로는 이것뿐이다.
|
||||
frontend 는 호스트 포트를 열지 않고 TLS 도 하지 않는다):
|
||||
frontend 는 TLS 를 하지 않고 호스트 포트도 열지 않는다 — **예외: 프록시가 다른 서버에 있을 때**(NCP 등 — 같은 VPC 의 Nginx Proxy Manager)는
|
||||
선택 파일 `docker-compose.remote-proxy.yml` 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 **사설 IP 에만**(`FRONTEND_PRIVATE_BIND`) 게시한다.
|
||||
`.env.production` 의 `COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml` 로 켜서 운영 명령은 그대로 쓴다. 공인 IP 를 사설 IP 로 NAT 하는 곳에서는
|
||||
게시 주소로 바깥을 막지 못하므로 8080 은 방화벽(NCP ACG)이 프록시 서버의 사설 IP 에서만 받게 한다 — 절차 · NPM 설정은 mail/README.md "리버스 프록시가 다른 서버에 있을 때"):
|
||||
- **`X-Forwarded-For` 를 이어 붙이거나 `$remote_addr` 로 새로 넣는다.** 클라이언트가 보낸 값을 그대로 넘기면 백엔드가 보는 IP 가 위조된다
|
||||
(로그인 실패 한도가 IP 기준이다).
|
||||
- **`TRUST_PROXY`**: proxy 네트워크를 이 프로젝트의 외부 프록시만 쓰면 홉 수 `2`.
|
||||
**다른 프로젝트와 함께 쓰면 주소 목록을 권장한다** — `<EDGE_NETWORK_SUBNET>,<외부 프록시 IP>` (예: `172.16.72.0/24,172.20.0.10`).
|
||||
홉 수는 "오른쪽에서 몇 번째 주소"만 믿으므로, 같은 네트워크의 다른 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면
|
||||
`X-Forwarded-For` 를 꾸며 IP 를 바꿀 수 있다. 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안)이며 고정해 둔다(`ipv4_address` 등).
|
||||
**프록시가 다른 서버에 있으면** 주소 목록 `<EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>` 다(같은 VPC 의 다른 서버가 8080 에 바로 붙어 꾸미는 것을 막는다 — 프록시 서버 위의 다른 컨테이너는 같은 IP 로 나가 가를 수 없다).
|
||||
- **요청 수 한도 `THROTTLE_LIMIT`** (운영 확인 항목 — 기본 1분 100회, `THROTTLE_TTL_MS`): 백엔드는 **경로마다 · 클라이언트 IP 마다** 센다 — 사무실이 NAT 하나로 나가면 모든 직원이 경로마다 한 칸을 나눠 쓴다.
|
||||
"직원 수 × 한 사람의 평소 분당 요청(가장 많이 부르는 경로 기준 — 예: 주소록 목록 `GET /api/contacts`)"보다 넉넉히 정한다. 넘으면 그 경로가 429 `REQ_001` 이다.
|
||||
외부 이미지 중계 `GET /api/mail-images/{token}`(9단계)만은 라우트에서 IP 마다 1분 600회로 따로 정했다(뉴스레터 한 통에 이미지가 수십 개).
|
||||
@@ -254,9 +261,11 @@ docker compose -f docker-compose.runner.yml --env-file .env up -d
|
||||
같은 PC 에 러너가 여럿이면 `RUNNER_NAME` 을 겹치지 않게 짓는다(이 저장소는 `arasystem-runner`).
|
||||
- `RUNNER_LABELS` 의 라벨이 워크플로의 `runs-on` 과 **일치해야** 잡을 집는다.
|
||||
- 러너는 도커 소켓에 접근하므로 사실상 호스트 관리자 권한을 갖는다 — 신뢰할 수 있는 워크플로만 실행되게 한다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t`)은 잡 컨테이너 안에서 docker 를 쓴다.
|
||||
- `ci.yml` 의 **infra 잡**(compose `config -q` · `docker build --check` · nginx 단계 `nginx -t` · 메일 리졸버 `unbound-checkconf` — compose 의 이미지 · 사용자 101:102)은 잡 컨테이너 안에서 docker 를 쓴다.
|
||||
act_runner 기본 설정(`container.docker_host` 비움)이 호스트 도커 소켓을 잡 컨테이너에 붙여 주고, 잡 이미지 `gitea/runner-images:ubuntu-latest` 에
|
||||
docker CLI·compose 가 들어 있다 (2026-10-27 확인: docker 29.7, compose 5.4). 러너 설정에서 `docker_host: "-"` 로 소켓을 막았다면 이 잡만 실패한다.
|
||||
`docker-compose.remote-proxy.yml` 도 이 잡이 본다 — 게시 주소를 준 `config -q` · proxy 네트워크가 빠졌는지 · 값이 없으면 멈추는지, 그리고 `frontend-bind-check` 를
|
||||
`compose run`(프로젝트 이름 `ci-bind-<run_id>`, DB 이미지)으로 받을 값 · 거절할 값을 하나씩 돌린다.
|
||||
|
||||
설치·문제 해결은 [`ci/README.md`](../../../ci/README.md) 참조.
|
||||
|
||||
@@ -285,7 +294,7 @@ dc --profile redis up -d
|
||||
|
||||
---
|
||||
|
||||
## 메일 (Stalwart · Mailpit) — profile `mail`
|
||||
## 메일 (Stalwart · unbound · Mailpit) — profile `mail`
|
||||
|
||||
직원 로그인 = 메일 계정이라 **예시 env 는 개발·운영 모두 `COMPOSE_PROFILES=mail`** 이다.
|
||||
구조·운영 배포·도메인 전환 절차는 [`mail/README.md`](../../../mail/README.md) 가 SSOT 다.
|
||||
@@ -310,8 +319,20 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- setup 은 엔진의 임시 업로드 크기 합계(`x:Jmap` 의 `uploadQuota` — 계정마다 1시간 동안 올린 양, 기본 50MB)를 첨부 합계 한도(`MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB`, 백엔드와 같은 계산)의
|
||||
20배(기본 524,288,000바이트) 이상으로 올린다 — 기본값이면 20MB 첨부 세 개로 그 직원의 첨부 올리기 · 필터 · 부재중 저장이 1시간까지 429 `REQ_001` 로 막혔다(2026-10-29).
|
||||
더 크게 둔 값은 내리지 않고, 개수(1,000) · 기간 · 한 번 크기는 그대로다. 첨부 한도 env 를 바꾸면 setup 도 다시 돌린다 (mail/README.md "엔진 한도").
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
- setup 은 **바꾸기 전에** 앱 네트워크 서브넷·구성원(frontend 등이 붙어 있지 않은지)·백엔드 주소와 리졸버 네트워크(서브넷 · 구성원 · 리졸버 주소 · mail 의 `dns`)를 확인하고, 어긋나면 아무것도 바꾸지 않고 멈춘다.
|
||||
**운영은 docker 를 쓸 수 있는 사용자로 호스트에서 돌린다** (docker 그룹 또는 `sudo -E`) — docker 가 없으면 바로 실패한다.
|
||||
- **메일 전용 DNS 리졸버** `mail-resolver`(unbound — 설정 `mail/resolver/unbound.conf`): 엔진의 DNS 조회(스팸 차단 목록 · SPF · DKIM · DMARC · MX · PTR · TLSA)를
|
||||
공용 · ISP DNS 대신 이 서버에서 직접 푼다 — Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다. DNSSEC 을 검증해 엔진의 DANE 이 켜지고, 조회 이름은 로그에 남기지 않는다.
|
||||
사설 · 내부 주소의 DNS 서버에는 묻지 않는다(`do-not-query-address` — 바깥 도메인이 이름 서버 주소를 사설 IP 로 두어 VPC 안을 두드리게 하지 못하게).
|
||||
setup 이 엔진의 `x:DnsResolver` 를 맞춘다 — 운영은 리졸버에 바로(Custom · UDP+TCP), 개발은 도커 DNS 를 거쳐(System — `mailpit` 같은 도커 이름 때문).
|
||||
사전 확인은 UDP 는 루트 SOA, TCP 는 루트 DNSKEY 를 DNSSEC 과 함께 묻는다 — UDP 로 DNSKEY 를 물으면 루트 키 교체 기간에 답이 잘려(TC) 멀쩡한 리졸버를 실패로 본다(2026-10-30 리뷰).
|
||||
mail 은 리졸버가 healthy 일 때 뜨지만(`depends_on … required: false`) 리졸버가 unhealthy 거나 뜨지 못하면 경고만 내고 mail 을 띄운다 — 리졸버 설정 오류 하나로
|
||||
`up -d mail` 이 옛 mail 을 지운 채 끝나 메일 전체가 서는 것을 막는다(2026-10-30 개발에서 확인). **서버 재부팅 때는 도커가 이 차례를 지키지 않는다** — 재부팅 뒤 `restart mail` 하고 setup 으로 `[확인]` 을 본다(먼저 뜨면 DANE 만 꺼진다).
|
||||
리졸버가 바깥에 닿지 못하는 동안은 **엔진이 설정 리로드를 통째로 거절한다**(스팸 필터의 Pyzor 주소 `public.pyzor.org` 를 같은 리졸버로 풀다 실패) — setup · 관리 화면의 리로드가 모두 막힌다.
|
||||
비상 길은 관리 화면에서 엔진 DNS 를 공용 DNS 로 저장한 뒤 리로드가 아니라 `restart mail`(엔진 DNS 를 System 으로 돌려도 mail 의 `dns:` 가 같은 리졸버라 소용없다) — mail/README.md "리졸버 장애 때".
|
||||
Spamhaus 두 항목(ZEN · DBL)은 `MAIL_SPAMHAUS_DQS_KEY` 가 있으면 시험 항목이 나오는지 본 뒤 DQS(`<키>.zen.dq.spamhaus.net` 등)로 켜고, 없으면 끈다(공개 미러는 소규모 · 비상업 용도라 쓰지 않는다).
|
||||
키는 출력하지 않지만(setup 의 모든 출력이 키를 가린다) 엔진의 스팸 필터 설정에 그대로 저장된다(관리 화면 · 관리 API · 메일 볼륨 백업). 체험 키는 기간이 끝나면 답이 멈추고
|
||||
엔진은 표시 없이 지나간다(fail-open) — 다음 setup 이 끄고 종료 1 로 알리므로 운영은 setup 을 주기적으로 돌려 본다 — mail/README.md "DNS 리졸버와 스팸 차단 목록".
|
||||
- 운영의 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 두지 않고 셸 일회성 override + `setup.mjs --recovery` 로만 쓴다
|
||||
(파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 — 절차는 mail/README.md "운영 배포").
|
||||
- 영구 관리자 아이디는 `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) — setup 과 백엔드가 함께 읽는다 (compose 가 backend 에 넘긴다).
|
||||
@@ -348,8 +369,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
| --- | --- | --- |
|
||||
| Node | `node:24.22.1-alpine3.24` | 아래 "Node 버전" |
|
||||
| nginx (운영 frontend) | `nginxinc/nginx-unprivileged:1.30.6-alpine3.24` | `frontend/Dockerfile` 의 `FROM … AS nginx-config`, 이 문서. 1.29.3 아래로 내리지 않는다 (`add_header_inherit`) |
|
||||
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다 |
|
||||
| Stalwart (mail) | `stalwartlabs/stalwart:v0.16.27` | 루트 `.env.development.example`·`.env.production.example` 의 `MAIL_IMAGE`(각자의 로컬 `.env.*` 도), `mail/README.md` 머리말, 이 문서. `mail/scripts/setup.mjs` 는 0.16 의 관리 API·bootstrap 동작에 맞춰져 있다 — 마이너(0.17 등)를 올리면 개발에서 setup 처음 설치·재실행과 웹메일을 다시 확인한다. setup 의 리졸버 확인은 메일 이미지의 perl(`perl-base`)로 묻는다 — 새 이미지에 perl 이 있는지도 본다 |
|
||||
| Mailpit (개발) | `axllent/mailpit:v1.32.0` | `.env.development.example` 의 `MAILPIT_IMAGE` |
|
||||
| unbound (메일 리졸버) | `klutchell/unbound:1.26.2@sha256:50cbeb32…` | `docker-compose.yml` 의 `mail-resolver`, `mail/README.md` 「DNS 리졸버와 스팸 차단 목록」의 이미지 칸(판 · digest 앞부분), 이 문서. 커뮤니티 이미지라 **digest 까지** 묶는다 — 올릴 때는 `imagetools inspect` 의 manifest list digest 로 함께 바꾼다. CI 의 unbound 설정 검사는 compose 의 이미지를 읽어 쓴다 |
|
||||
| CI 러너 | `gitea/act_runner:0.6.1` | `ci/docker-compose.runner.yml`. 잡 이미지(`RUNNER_LABELS` 의 `gitea/runner-images:ubuntu-latest`)는 각자의 `ci/.env` 값이라 아직 떠 있는 태그다 |
|
||||
|
||||
- **갱신 규칙**(공통): 일반 패치는 **배포 후 2주가 지나면** 올린다 (공급망 위험 — 갓 나온 버전을 피한다). **보안 릴리스는 CI 가 통과하는 대로** 올린다.
|
||||
@@ -421,6 +443,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
그 접속까지 믿게 될 위험이 커지고, `/28` 보다 좁으면 컨테이너 주소가 모자란다 (근거: `setup.mjs` 의 `APP_NETWORK_PREFIX_RANGE`).
|
||||
- 기본값은 도커 기본 주소 풀 밖이라 자동 배정 네트워크와 겹치지 않지만, 같은 호스트의 다른 프로젝트가 같은 값을 쓰면
|
||||
`up` 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 mail setup 을 다시 실행한다 (`EDGE_NETWORK_SUBNET` 도 같다).
|
||||
- 메일 리졸버 네트워크 `mail-dns` 도 서브넷(`MAIL_RESOLVER_SUBNET`, 기본 `172.16.73.0/24`, 사설 대역 `/24`~`/29`)과 리졸버 주소(`MAIL_RESOLVER_IP`, 끝 쪽 — 도커는 mail 에 앞쪽부터 준다)를 고정한다.
|
||||
mail 의 `dns` 와 엔진 설정이 IP 로만 받기 때문이다 — 바꾸면 둘을 함께 바꾸고 setup 을 다시 실행한다. 리졸버만 붙는 `mail-dns-egress` 는 고정하지 않아 도커 기본 풀에서 받는다 —
|
||||
운영 서버의 사설망(VPC)이 그 대역(172.17~31 · 192.168)이면 겹치지 않는지 본다.
|
||||
⚠️ 서브넷을 처음 고정하거나 바꿀 때, 네트워크 구성을 바꿀 때는 **`dc down` 후 `dc up -d`** (또는 `dc up -d --force-recreate`)로 전부 다시 만든다.
|
||||
그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등) 별칭을 잃어
|
||||
backend 가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (2026-10-26 개발 환경에서 겪음).
|
||||
@@ -441,9 +466,14 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
- **DB/Redis·backend·frontend(dev 컨테이너) 호스트 포트**: dev 에서만 열며 `127.0.0.1` 로 한정한다. 접두어 없이 쓰면 LAN 전체에 노출된다 ([[security]]).
|
||||
backend(3000·3001)는 호스트의 vite 개발 서버·도구만 붙으면 된다 — Android 에뮬레이터의 `10.0.2.2` 는 호스트 127.0.0.1 로 가서 그대로 되고,
|
||||
같은 LAN 의 실기기는 닿지 않는다. 운영 backend 는 호스트 포트를 열지 않는다(프론트 nginx 의 `/api` 로만).
|
||||
운영 frontend 도 열지 않는다 — 프록시가 다른 서버에 있을 때만 8080 을 사설 IP 에 연다(`docker-compose.remote-proxy.yml` 의 `frontend-bind-check` 가 사설 IPv4:포트가 아니면 frontend 를 띄우지 않는다).
|
||||
profile `frontend` 의 vite 컨테이너도 `127.0.0.1:${FRONTEND_PORT}` 다 (호스트 vite 와 같은 범위).
|
||||
- **메일 포트**: 8080(HTTP JMAP·관리 API)은 어떤 환경에서도 공개하지 않는다 (dev 는 `127.0.0.1:${MAIL_ADMIN_PORT}` 만).
|
||||
운영의 25·465·993·443 은 `0.0.0.0`(IPv4)로만 바인딩한다 — IPv6 접속은 docker-proxy 가 중계해 도커 게이트웨이 주소로 보이고,
|
||||
그러면 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다. dev 오버라이드는 `ports: !override` 로 운영 포트를 지운다.
|
||||
- **Mailpit**: 이미지 기본이 root 라 `user: "65534:65534"` 로 내린다. dev 전용(운영 compose 에 없다).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`.
|
||||
- **메일 리졸버**: 이미지가 root 로 떠서 권한을 내리는 꼴이라 처음부터 `user: "101:102"`(이미지의 unbound 사용자) · `cap_drop: ALL` · `no-new-privileges` 로 띄운다.
|
||||
53 에 붙는 것은 도커가 컨테이너마다 `net.ipv4.ip_unprivileged_port_start=0` 으로 두기 때문이다. 호스트 포트는 어떤 환경에서도 열지 않는다(열린 리졸버가 되지 않게).
|
||||
- **개발/운영 분리**: 개발은 `docker-compose.yml + docker-compose.dev.yml`, 운영은 `docker-compose.yml` 단독 + `.env.production`
|
||||
(프록시가 다른 서버에 있으면 `.env.production` 의 `COMPOSE_FILE` 로 `docker-compose.remote-proxy.yml` 을 더한다 — compose 가 `--env-file` 의 `COMPOSE_FILE` · `COMPOSE_PROFILES` 를 읽는다.
|
||||
빈 `COMPOSE_FILE=` 은 compose 가 실패하니 쓰지 않을 때는 주석으로 둔다).
|
||||
|
||||
@@ -26,11 +26,16 @@ REDIS_PORT=6379
|
||||
# 서브넷을 고정한다 — 메일 엔진은 app-network 대역만 신뢰한다 (게이트웨이 로그인·자동 차단 예외, mail/README.md).
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail(·mailpit) — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend ↔ backend 전용 (frontend 는 app-network 에 붙지 않는다)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS 와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
MAIL_RESOLVER_IP=172.16.73.254
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
@@ -166,6 +171,9 @@ MAIL_TRUSTED_NETWORKS=
|
||||
MAIL_PILOT_LEGACY_HOST=
|
||||
# 기존 서버가 이 서버에 붙을 때의 IPv4 · CIDR (콤마). 개발은 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳)
|
||||
MAIL_PILOT_LEGACY_IPS=
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 키는 운영 서버의 .env.production 에만 둔다 — 개발은 비워 둔다.
|
||||
# 비어 있으면 setup 이 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)을 꺼 둔다 — 개발도 운영과 같다(공개 미러는 소규모 · 비상업 용도다)
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다
|
||||
MAIL_DEV_USER_PASSWORD=change-me-in-local-env
|
||||
# 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다
|
||||
|
||||
+35
-1
@@ -23,11 +23,17 @@ REDIS_PORT=6379
|
||||
# APP_NETWORK_SUBNET : backend·db·redis·mail — 사설 대역, /24 ~ /28 (mail setup 이 검사한다)
|
||||
# EDGE_NETWORK_SUBNET : frontend(nginx) ↔ backend 전용. 인터넷을 향한 frontend 는 app-network 에 붙지 않는다.
|
||||
# TRUST_PROXY 를 주소 목록으로 쓸 때 이 대역을 넣는다 (아래)
|
||||
# MAIL_RESOLVER_SUBNET : mail ↔ 메일 전용 DNS 리졸버(mail-resolver) 전용 네트워크 mail-dns — 바깥 길이 없는 internal (사설 대역, /24 ~ /29)
|
||||
# MAIL_RESOLVER_IP : 그 안의 리졸버 주소(고정) — 엔진의 DNS(setup 이 맞춘다)와 mail 컨테이너의 dns 가 이 주소다. 서브넷의 끝 쪽에 둔다
|
||||
# (도커는 mail 에 앞쪽부터 차례로 준다). 둘은 함께 바꾸고 setup 을 다시 실행한다 (mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||||
# 도커 기본 주소 풀(172.17~31.x, 192.168.x) 밖의 값이라 자동으로 배정되는 네트워크와 겹치지 않는다.
|
||||
# 같은 호스트의 다른 프로젝트와 겹치면 up 이 "Pool overlaps" 로 실패한다 → 다른 /24 로 바꾸고 setup 을 다시 실행한다.
|
||||
# 서버의 사설망(NCP VPC 등)과도 겹치지 않게 한다 — 겹치면 그 대역으로 가는 길이 도커 브리지로 간다 (mail/README.md "NCP 쪽 할 일")
|
||||
# ==========================================
|
||||
APP_NETWORK_SUBNET=172.16.71.0/24
|
||||
EDGE_NETWORK_SUBNET=172.16.72.0/24
|
||||
MAIL_RESOLVER_SUBNET=172.16.73.0/24
|
||||
MAIL_RESOLVER_IP=172.16.73.254
|
||||
|
||||
# ==========================================
|
||||
# Database 설정
|
||||
@@ -59,13 +65,15 @@ REDIS_DATA_DIR=./data/redis
|
||||
#
|
||||
# - TRUST_PROXY : 백엔드 앞 프록시 — 실제 클라이언트 IP(로그인 실패 한도의 기준)를 읽는다. 운영 필수
|
||||
# 이 compose 의 운영 경로는 클라이언트 → 외부 리버스 프록시(TLS) → 프론트 nginx → 백엔드 하나뿐이다.
|
||||
# (프론트 nginx 는 호스트 포트를 열지 않고 TLS 도 하지 않으므로 외부 프록시 없이는 들어올 수 없다 — 1 은 해당 없음)
|
||||
# (프론트 nginx 는 TLS 를 하지 않고 호스트 포트도 열지 않으므로(다른 서버의 프록시용 구성은 사설 IP 에만) 외부 프록시 없이는 쓸 수 없다 — 1 은 해당 없음)
|
||||
# 2 = 홉 수. proxy 네트워크를 이 프로젝트의 외부 프록시만 쓸 때
|
||||
# 주소 목록 = <EDGE_NETWORK_SUBNET>,<외부 프록시 IP> (예: 172.16.72.0/24,172.20.0.10) — proxy 네트워크를
|
||||
# 다른 프로젝트와 함께 쓴다면 이쪽을 권장한다. 홉 수는 "몇 번째 주소"만 믿어서, 같은 네트워크의 다른
|
||||
# 컨테이너가 외부 프록시를 건너뛰고 frontend 에 바로 붙으면 X-Forwarded-For 를 꾸며 IP 를 바꿀 수 있다.
|
||||
# 외부 프록시 IP 는 frontend 가 보는 주소(proxy 네트워크 안 주소)이며 고정해 둔다(ipv4_address 등).
|
||||
# 넓은 사설 대역을 통째로 넣지 않는다.
|
||||
# 외부 프록시가 다른 서버에 있으면(아래 "리버스 프록시가 다른 서버에 있을 때") 주소 목록으로 —
|
||||
# <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP> (예: 172.16.72.0/24,10.0.1.11). 사설 IP 는 frontend 접근 로그의 첫 칸이다
|
||||
# true·0·false 는 운영에서 기동이 거부된다.
|
||||
# ==========================================
|
||||
BACKEND_API_URL=https://portal.arasystem.it.kr/api
|
||||
@@ -99,6 +107,23 @@ DB_SYNCHRONIZE=false
|
||||
# ==========================================
|
||||
COMPOSE_PROFILES=mail
|
||||
|
||||
# ==========================================
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (선택 — mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본은 외부 리버스 프록시가 이 호스트에서 external proxy 네트워크로 <PROJECT_NAME>-frontend:8080 에 붙는 구성이다.
|
||||
# 다른 서버의 프록시(예: 같은 VPC 의 Nginx Proxy Manager)가 사설망으로 붙는다면 COMPOSE_FILE 의 주석을 풀고 FRONTEND_PRIVATE_BIND 를 채운다 —
|
||||
# docker-compose.remote-proxy.yml 이 frontend 를 proxy 네트워크에서 빼고 8080 을 이 서버의 사설 IP 에만 게시한다.
|
||||
# COMPOSE_FILE : compose 파일 목록 (리눅스는 : 로 잇는다). 이 파일에 두면 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# -f 없이 그대로 두 파일을 읽는다 (compose v2.40 · 5.4 에서 확인). 쓰지 않을 때는 주석으로 둔다 —
|
||||
# 빈 값(COMPOSE_FILE=)은 compose 가 파일 목록으로 읽다 실패한다
|
||||
# FRONTEND_PRIVATE_BIND : <이 서버의 사설 IPv4>:<포트> (예: 10.0.1.12:8080 — ip -4 addr 로 본다).
|
||||
# 비어 있으면 compose 가 멈추고, 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 면 frontend 가 뜨지 않는다 (frontend-bind-check)
|
||||
# TRUST_PROXY 는 위 "화면·API 주소" 의 주소 목록으로 바꾼다 — <EDGE_NETWORK_SUBNET>,<프록시 서버의 사설 IP>
|
||||
# ⚠️ 게시 주소만으로는 바깥을 막지 못한다 — NCP 는 공인 IP 를 사설 IP 로 NAT 하므로 8080 은 방화벽(ACG)이 프록시 서버의 사설 IP 에서만 받게 한다
|
||||
# ==========================================
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
FRONTEND_PRIVATE_BIND=
|
||||
|
||||
# ==========================================
|
||||
# 파일 업로드 (로컬 디스크)
|
||||
#
|
||||
@@ -170,6 +195,15 @@ MAIL_TRUSTED_NETWORKS=
|
||||
# (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다.
|
||||
# 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다)
|
||||
# MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr
|
||||
# Spamhaus 조회 키 (선택 — mail/README.md "DNS 리졸버와 스팸 차단 목록"). 있으면 스팸 필터의 Spamhaus 두 항목(ZEN · DBL)이 공개 미러 대신
|
||||
# Spamhaus 의 "Blocklists via DNS Query"(옛 DQS — <키>.zen.dq.spamhaus.net)로 묻는다. 비어 있으면 setup 이 두 항목을 끄고 [주의] 를 낸다
|
||||
# (공개 미러는 소규모 · 비상업 용도라 쓰지 않는다 — 나머지 차단 목록은 그대로).
|
||||
# 무료 계정은 Spamhaus 의 Fair Use(소규모 · 비상업) 조건이다 — 회사가 대상인지는 Spamhaus 가 판단하고, 안 되면 비워 두거나(두 항목 꺼짐) 유료(30일 체험)로.
|
||||
# ⚠️ 체험(30일) 키는 기간이 끝나면 답이 멈춘다 — 구독하거나 키를 비우고 setup. 멈추면 엔진의 Spamhaus 조회가 실패해 표시가 붙지 않고(메일은 받는다)
|
||||
# 다음 setup 실행이 두 항목을 끄고 종료 1 로 알린다 — setup 을 주기적으로 돌려 종료 0 인지 보거나 Spamhaus 계정의 만료일을 본다.
|
||||
# 키(26자 영문 · 숫자)는 비밀이다 — 이 파일에만 둔다(파일에 없거나 파일과 다른 값이 셸에 있으면 setup 이 멈춘다). setup 은 출력하지 않지만 엔진의 스팸 필터 설정(조회 이름 식)에는
|
||||
# 그대로 저장된다 — 관리 화면 · 관리 API · 메일 볼륨 백업으로 보인다. 바꾸거나 비우면 setup 을 다시 실행한다
|
||||
MAIL_SPAMHAUS_DQS_KEY=
|
||||
# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐.
|
||||
# MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소만 이 서버로 넘긴다. 값은 셸이 아니라 이 파일에 둔다.
|
||||
# ⚠️ 켠 뒤에 값이 비면 setup 이 멈춘다 — 시범을 끝낼 때(전면 전환)는 둘 다 비우고 setup 에 --end-pilot 을 붙인다.
|
||||
|
||||
@@ -139,6 +139,36 @@ jobs:
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example config -q
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile frontend config -q
|
||||
docker compose -f docker-compose.yml --env-file .env.production.example config -q
|
||||
# 리버스 프록시가 다른 서버에 있을 때 (docker-compose.remote-proxy.yml — mail/README.md). 게시 주소는 이 단계에서만 준다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q
|
||||
# proxy 네트워크가 빠져야 한다 — 남으면 그 서버에 없는 external 네트워크를 찾다 up 이 멈춘다
|
||||
# (grep -q 는 쓰지 않는다 — 먼저 끝나면 pipefail 셸에서 compose 의 SIGPIPE 가 결과를 뒤집는다)
|
||||
if FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config --networks | grep -x proxy >/dev/null; then
|
||||
echo "::error::docker-compose.remote-proxy.yml 이 proxy 네트워크를 빼지 못했습니다"; exit 1
|
||||
fi
|
||||
# 게시 주소가 없으면 멈춰야 한다 (예시 env 의 FRONTEND_PRIVATE_BIND 는 비어 있다 — 빈 값이면 모든 주소에 열린다)
|
||||
if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1
|
||||
fi
|
||||
|
||||
# docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다
|
||||
# (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다
|
||||
- name: frontend 게시 주소 확인 (리버스 프록시가 다른 서버에 있을 때)
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
check() {
|
||||
FRONTEND_PRIVATE_BIND="$1" docker compose -p "ci-bind-${{ github.run_id }}" \
|
||||
-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example \
|
||||
run --rm --no-deps -T frontend-bind-check
|
||||
}
|
||||
for value in 10.0.1.12:8080 172.31.255.254:65535 192.168.0.10:18080; do
|
||||
check "$value" || { echo "::error::FRONTEND_PRIVATE_BIND=$value 를 거부했습니다 (받아야 한다)"; exit 1; }
|
||||
done
|
||||
for value in 8080 :8080 0.0.0.0:8080 127.0.0.1:8080 203.0.113.5:8080 172.32.0.1:8080 '[::]:8080' 10.0.1.12:0 10.0.1.12:08080; do
|
||||
if check "$value" 2>/dev/null; then
|
||||
echo "::error::FRONTEND_PRIVATE_BIND=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Dockerfile 검사
|
||||
run: |
|
||||
@@ -159,9 +189,23 @@ jobs:
|
||||
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
|
||||
fi
|
||||
|
||||
# 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf.
|
||||
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
|
||||
- name: unbound 설정 검사
|
||||
run: |
|
||||
touch backend/.env.production
|
||||
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | grep unbound)
|
||||
tag="ci-unbound-config:${{ github.run_id }}"
|
||||
printf 'FROM %s\nCOPY unbound.conf /etc/unbound/unbound.conf\n' "$image" | docker build -t "$tag" -f- mail/resolver
|
||||
docker run --rm --user 101:102 --entrypoint unbound-checkconf "$tag"
|
||||
|
||||
- name: 정리
|
||||
if: always()
|
||||
run: docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
run: |
|
||||
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
|
||||
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
|
||||
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
|
||||
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
|
||||
|
||||
error-codes-sync:
|
||||
name: 에러 코드 SSOT 동기화 검사
|
||||
|
||||
@@ -112,6 +112,11 @@ services:
|
||||
ports: !override
|
||||
- "127.0.0.1:${MAIL_ADMIN_PORT}:8080"
|
||||
|
||||
mail-resolver:
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 unless-stopped 를 덮어씀).
|
||||
# 개발도 같은 리졸버를 띄운다 — 엔진은 도커 DNS 를 거쳐(System) 바깥 이름을 이 리졸버에 묻는다(Mailpit 같은 도커 내부 이름은 도커 DNS 가 푼다)
|
||||
restart: "no"
|
||||
|
||||
# ----------------------------------------
|
||||
# mailpit — 개발 전용 메일 수집기 (profile `mail`, 운영 compose 에는 없다)
|
||||
#
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ==========================================
|
||||
# 선택 파일 — 리버스 프록시가 다른 서버에 있을 때 (mail/README.md "리버스 프록시가 다른 서버에 있을 때")
|
||||
#
|
||||
# 기본(docker-compose.yml 단독)은 외부 리버스 프록시가 같은 호스트에서 external proxy 네트워크로
|
||||
# <PROJECT_NAME>-frontend:8080 에 붙는다. 이 파일은 프록시가 다른 서버(예: 같은 VPC 의 Nginx Proxy Manager)에 있어
|
||||
# 사설망으로 이 서버의 frontend 에 붙는 구성으로 바꾼다:
|
||||
# - frontend 를 proxy 네트워크에서 뺀다 (이 서버에는 그 네트워크가 없다). edge 로만 backend 에 닿는 것은 그대로다
|
||||
# - frontend 의 8080 을 이 서버의 사설 IP 에만 게시한다 — FRONTEND_PRIVATE_BIND = <사설 IPv4>:<포트>
|
||||
# frontend 의 환경 · nginx 설정(X-Forwarded-For 이어 붙이기 · X-Request-Id · 실시간 알림)은 바꾸지 않는다.
|
||||
#
|
||||
# 쓰는 법 — 루트 .env.production 에 두 줄 (리눅스는 COMPOSE_FILE 을 : 로 잇는다):
|
||||
# COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml
|
||||
# FRONTEND_PRIVATE_BIND=10.0.1.12:8080
|
||||
# compose 가 --env-file 의 COMPOSE_FILE 을 읽으므로 문서의 운영 명령(sudo docker compose --env-file .env.production …)이
|
||||
# 그대로 두 파일을 읽는다 (-f 를 붙이지 않는다 — 하나라도 빠뜨리면 그 명령만 기본 구성으로 돈다).
|
||||
#
|
||||
# ⚠️ 게시 주소는 바깥을 막는 장치가 아니다. NCP 처럼 공인 IP 를 사설 IP 로 NAT 하는 곳에서는 사설 IP 에 게시한 포트에
|
||||
# 공인 IP 로도 닿는다 — 8080 은 방화벽(NCP 는 ACG)이 프록시 서버의 사설 IP 에서만 받게 한다.
|
||||
# ==========================================
|
||||
services:
|
||||
frontend:
|
||||
# 다른 서버의 프록시가 붙는 자리 — 이 서버의 사설 IPv4 에만 (0.0.0.0 · 빈 값으로 열지 않는다).
|
||||
# 값이 없으면 compose 가 멈추고, <사설 IPv4>:<포트> 꼴이 아니면 아래 frontend-bind-check 가 frontend 를 띄우지 않는다
|
||||
# (값이 포트뿐이면 "8080:8080" 이 되어 모든 주소에 열리기 때문이다)
|
||||
ports: !override
|
||||
- "${FRONTEND_PRIVATE_BIND:?FRONTEND_PRIVATE_BIND 가 비어 있습니다 — 이 서버의 사설 IPv4 와 포트 (예 10.0.1.12:8080) · mail/README.md 의 리버스 프록시가 다른 서버에 있을 때}:8080"
|
||||
# 이 서버에는 외부 프록시의 proxy 네트워크가 없다 — edge(frontend ↔ backend)만 남긴다.
|
||||
# app-network(메일 엔진의 신뢰 대역)에는 기본 구성처럼 붙이지 않는다
|
||||
networks: !override
|
||||
- edge
|
||||
depends_on:
|
||||
frontend-bind-check:
|
||||
condition: service_completed_successfully
|
||||
|
||||
# ----------------------------------------
|
||||
# 게시 주소 확인 — frontend 보다 먼저 한 번 돌고 끝난다 (docker compose ps -a 에 Exited (0))
|
||||
#
|
||||
# compose 의 ${…:?} 는 빈 값만 막는다. 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 는 여기서 막는다 — 실패하면 up 이
|
||||
# "frontend-bind-check didn't complete successfully" 로 멈추고 frontend 는 뜨지 않는다 (까닭: logs frontend-bind-check).
|
||||
# 셸만 쓴다 — 이미 받는 DB 이미지를 빌려 새 이미지 · 버전 고정 자리를 늘리지 않는다. 네트워크 없이 · 비-root · 읽기 전용.
|
||||
# 받는 값: RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16, 앞자리 0 없이) + ":" + 포트 1~65535(앞자리 0 없이)
|
||||
# ----------------------------------------
|
||||
frontend-bind-check:
|
||||
container_name: ${PROJECT_NAME}-frontend-bind-check
|
||||
image: ${DB_IMAGE}
|
||||
user: "65534:65534"
|
||||
network_mode: none
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
restart: "no"
|
||||
environment:
|
||||
- FRONTEND_PRIVATE_BIND=${FRONTEND_PRIVATE_BIND:-}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
v=$$FRONTEND_PRIVATE_BIND
|
||||
o='(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])'
|
||||
p='([1-9][0-9]{0,3}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])'
|
||||
case $$v in
|
||||
*[!0-9.:]*) ;;
|
||||
*)
|
||||
if printf '%s\n' "$$v" | grep -Eqx "(10(\.$$o){3}|172\.(1[6-9]|2[0-9]|3[01])(\.$$o){2}|192\.168(\.$$o){2}):$$p"; then
|
||||
echo "frontend-bind-check: frontend 를 $$v 에만 게시한다 (사설 IPv4)"
|
||||
exit 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
echo "frontend-bind-check: FRONTEND_PRIVATE_BIND=\"$$v\" 는 쓸 수 없어 frontend 를 띄우지 않습니다 — <이 서버의 사설 IPv4>:<포트> 만 받습니다 (10.x · 172.16~31.x · 192.168.x, 예 10.0.1.12:8080). 포트뿐인 값 · 0.0.0.0 · 공인 IP · IPv6 는 받지 않습니다 (mail/README.md \"리버스 프록시가 다른 서버에 있을 때\")" >&2
|
||||
exit 1
|
||||
|
||||
networks:
|
||||
# base 의 external proxy 네트워크를 지운다 — 이 서버에 없는 네트워크를 찾다 up 이 멈추지 않게
|
||||
proxy: !reset null
|
||||
@@ -11,6 +11,7 @@ services:
|
||||
# edge frontend ↔ backend 전용
|
||||
# app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다
|
||||
# (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다)
|
||||
# mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조)
|
||||
# ----------------------------------------
|
||||
frontend:
|
||||
container_name: ${PROJECT_NAME}-frontend
|
||||
@@ -255,14 +256,75 @@ services:
|
||||
- mail-config:/etc/stalwart
|
||||
- mail-data:/var/lib/stalwart
|
||||
restart: unless-stopped
|
||||
# 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다.
|
||||
# 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System)
|
||||
dns:
|
||||
- ${MAIL_RESOLVER_IP:-172.16.73.254}
|
||||
networks:
|
||||
- app-network
|
||||
- mail-dns
|
||||
# 리졸버가 답한 뒤에 뜬다 — 엔진은 기동할 때 리졸버가 DNSSEC 을 검증하는지 보고, 못 하면 DANE 을 끈다.
|
||||
# required: false — 평소에는 healthy 를 기다리고, 리졸버가 unhealthy 거나 떠 있지 못하면(설정 오류로 바로 끝남 · 재시작을 되풀이함)
|
||||
# compose 가 경고("optional dependency … failed to start")만 내고 mail 을 띄운다. 없으면 리졸버 오류 하나로 `up -d mail` 이 옛 mail 을 지운 뒤
|
||||
# 새 mail 을 띄우지 않고 끝나 메일 전체가 선다 — 바깥 DNS 만 안 되는 편이 낫다 (2026-10-30 개발 Compose 2.40 에서 넷 다 확인, mail/README.md "리졸버 장애 때")
|
||||
depends_on:
|
||||
mail-resolver:
|
||||
condition: service_healthy
|
||||
required: false
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1g
|
||||
|
||||
# ----------------------------------------
|
||||
# 6. Mail resolver (unbound) — 메일 엔진 전용 재귀 DNS · profile `mail`
|
||||
#
|
||||
# 엔진의 DNS 조회(스팸 차단 목록 DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다.
|
||||
# Spamhaus 는 공용 · ISP DNS 를 거친 조회를 거부한다(127.255.255.254). DNSSEC 을 검증해 엔진의 DANE 이 켜진다.
|
||||
# 설정 mail/resolver/unbound.conf · mail setup 이 엔진의 x:DnsResolver 를 이 주소로 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").
|
||||
#
|
||||
# 네트워크 — 메일 엔진만 닿게:
|
||||
# mail-dns mail ↔ 리졸버 전용. internal(바깥으로 나가는 길 없음). 리졸버 주소는 MAIL_RESOLVER_IP 로 고정한다 —
|
||||
# 엔진 설정 · mail 의 dns 가 주소(IP)만 받는다. 서브넷의 끝 쪽에 둔다(도커는 mail 에 앞쪽부터 차례로 준다)
|
||||
# mail-dns-egress 리졸버의 바깥 조회 길 — 리졸버 혼자 붙는다
|
||||
# app-network(메일 엔진의 신뢰 대역)에는 붙이지 않는다 — mail setup 이 두 네트워크의 구성원을 확인한다 (운영은 그 밖이 있으면 중단)
|
||||
# 이미지는 판까지 고정한다 (docker 스킬 "이미지 버전 고정"). 1.26.2 는 NLnet Labs 의 보안 릴리스(2026-10-21)라 2주를 기다리지 않았다
|
||||
# ----------------------------------------
|
||||
mail-resolver:
|
||||
profiles: ["mail"]
|
||||
container_name: ${PROJECT_NAME}-mail-resolver
|
||||
# 공식이 아닌(커뮤니티) 이미지라 판에 digest 까지 묶는다 — 같은 태그를 누가 다시 올려도 바뀌지 않게 (2026-10-30 manifest list digest)
|
||||
image: klutchell/unbound:1.26.2@sha256:50cbeb32b5838a829a7226b19cb151f9535bd7f9f3190f950780f452fe807478
|
||||
# 이미지는 root 로 떠서 53 에 붙은 뒤 내려가는 꼴이다 — 처음부터 비-root(이미지의 unbound 사용자 101:102)로 띄운다.
|
||||
# 53 에 붙는 것은 도커가 컨테이너마다 net.ipv4.ip_unprivileged_port_start=0 으로 두기 때문이다 — 권한(capability)은 모두 뺀다
|
||||
user: "101:102"
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
volumes:
|
||||
# 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다
|
||||
- ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
mail-dns:
|
||||
ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254}
|
||||
mail-dns-egress: {}
|
||||
healthcheck:
|
||||
# 데몬이 답하는지만 본다(자기 안의 localhost) — 바깥 DNS 가 잠깐 막혀도 mail 이 뜨지 못하는 일이 없게.
|
||||
# 바깥 조회 · DNSSEC 은 mail setup 이 메일 컨테이너에서 확인한다. drill-hc 는 이미지에 든 drill 의 종료 코드를 0 · 1 로 맞춘 것
|
||||
test: ["CMD", "drill-hc", "localhost", "@127.0.0.1", "A"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.25'
|
||||
memory: 128m
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
# 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외
|
||||
@@ -283,6 +345,20 @@ networks:
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${EDGE_NETWORK_SUBNET:-172.16.72.0/24}
|
||||
mail-dns:
|
||||
# mail ↔ mail-resolver 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(mail 의 바깥 길은 app-network 그대로).
|
||||
# 리졸버 주소(MAIL_RESOLVER_IP)를 고정하려고 서브넷을 고정한다. 겹치면 MAIL_RESOLVER_SUBNET · MAIL_RESOLVER_IP 를 함께 바꾼다
|
||||
name: ${PROJECT_NAME}_mail-dns
|
||||
driver: bridge
|
||||
internal: true
|
||||
ipam:
|
||||
config:
|
||||
- subnet: ${MAIL_RESOLVER_SUBNET:-172.16.73.0/24}
|
||||
mail-dns-egress:
|
||||
# 리졸버가 루트 · 각 도메인의 DNS 서버에 묻는 길 — 리졸버 혼자 붙는다. 믿는 대역이 아니라 서브넷을 고정하지 않는다 —
|
||||
# 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md)
|
||||
name: ${PROJECT_NAME}_mail-dns-egress
|
||||
driver: bridge
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
|
||||
+446
-29
@@ -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)까지 자체 확인
|
||||
@@ -216,15 +225,23 @@ node --env-file=.env.development mail/scripts/setup.mjs
|
||||
|
||||
사전 조건:
|
||||
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조)
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조 ·
|
||||
프록시가 다른 서버에 있고 사설망으로 붙으면 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망))
|
||||
- `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 · 본문 한도·시간 제한)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한).
|
||||
다른 서버의 Nginx Proxy Manager 라면 설정 · `TRUST_PROXY` 는 [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
- **`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 제한이 무력화된다. 배포 뒤 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 확인한다.
|
||||
@@ -270,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`.
|
||||
@@ -348,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)
|
||||
@@ -491,6 +621,13 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <
|
||||
- 기존 서버 IP(`MAIL_PILOT_LEGACY_IPS`)에 **사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다** — setup 이 같은 주소로 시범 표시 항목을 만들어야 한다(같은 주소의 항목은 둘일 수 없다). 있으면 setup 이 멈춘다 — 지운 뒤 다시 실행한다.
|
||||
- 시범 사용자의 mailcow 사서함은 **그대로 둔다** — Transport Map 은 mailcow 에 있는 받는 사람에게만 먹고, 새 서버가 owner@ 명의로 동료에게 보낼 때도 mailcow 의 `reject_unlisted_sender` 를 이 사서함으로 통과한다.
|
||||
|
||||
**시범 주소가 mailcow 에 없던 새 주소일 때** (실제 시범: `sysadmin@arasystem.it.kr` · 표시 이름 "시스템 관리자" — 직원 메일함이 아니라 관리용 새 주소로 시험한다):
|
||||
mailcow 에 **같은 주소의 메일함을 먼저 만든다** — 받기만 하는 자리다. Transport Map 은 mailcow 에 있는 받는 사람에게만 먹어 이 주소 앞 메일을 새 서버로 넘기고,
|
||||
새 서버가 이 주소로 동료에게 보낼 때 mailcow 의 `reject_unlisted_sender` 를 이 메일함으로 통과한다. 로그인은 처음부터 막아 둔다 — Active 를 "Disallow login (incoming mail is still accepted)",
|
||||
Allowed protocols 와 SOGo 접근을 모두 끄고 비밀번호는 무작위로(아래 9 를 미리 해 두는 것과 같다). 옛 메일이 없으니 imapsync(아래 5 · 7)는 건너뛰고,
|
||||
되돌리기의 "mailcow 로 되복사"(되돌리기 3)는 시범 동안 새 서버에 온 · 보낸 메일뿐이다. 새 서버에서 이 주소는 보통 직원 계정(`User` 역할)이다 —
|
||||
엔진 관리자(`MAIL_ADMIN_LOCAL_PART`)가 아니고 백엔드 차단 목록(`MAIL_BLOCKED_ACCOUNTS`)에도 넣지 않는다(넣으면 웹메일에 로그인하지 못한다). 아래 절차의 `owner@` 를 이 주소로 읽는다.
|
||||
|
||||
### env 와 실행 (새 서버)
|
||||
|
||||
`.env.production` 에 두 값을 적는다 — **셸이 아니라 파일에** (운영은 env 파일에 없는 시범 값이 셸에 있으면 setup 이 멈춘다):
|
||||
@@ -623,7 +760,19 @@ cd /opt/mailcow-dockerized
|
||||
alias dcx='docker compose exec postfix-mailcow'
|
||||
```
|
||||
|
||||
0. **읽기만 하는 점검** — 로컬로 새는 길이 없는지:
|
||||
**SSH 가 없을 때 (mailcow 관리 화면만)** — 운영 mailcow 에는 관리 화면만 쓸 수 있다. 아래 절차의 명령은 SSH 가 있을 때의 길이고, 화면으로는 이렇게 한다:
|
||||
|
||||
| 절차 | 화면으로 |
|
||||
| --- | --- |
|
||||
| 0 읽기 점검(명령) | SSH 가 있을 때만 한다 — 없으면 건너뛰고 4 · 6 의 시험 메일과 화면의 로그로 대신 본다. 도메인(백업 MX · "Relay all recipients" 가 꺼져 있는지)과 Synchronizations 는 화면으로 본다 |
|
||||
| 1 postscreen 허용 목록 | 파일이라 화면으로는 할 수 없다 — 대신 새 서버 IP(`<MX1_IP>`)가 차단 목록에 없는지 본다: [Spamhaus IP 조회](https://check.spamhaus.org/) · [MXToolbox Blacklist Check](https://mxtoolbox.com/blacklists.aspx). PTR(mx1)이 있고 목록에 없으면 보통 postscreen 을 통과한다 |
|
||||
| 2 Fail2ban 예외 | 그대로 화면 — System → Configuration → Options → Fail2ban parameters |
|
||||
| 4 · 6 의 로그(`docker compose logs postfix-mailcow` · `rspamd-mailcow`) | System → Information → Logs 의 Postfix · Rspamd (판에 따라 메뉴 이름이 다를 수 있다) |
|
||||
| 6 `postmap -q` · `postfix reload` | 하지 않는다 — Transport Map 은 저장하면 1분 안에 먹는다(reload 없이). 목록의 Test 버튼과 실제 시험 메일로 본다 |
|
||||
| 10 · 되돌리기 2 의 대기열(`postqueue`) | E-Mail → Queue Manager — 대기열을 보고 Flush queue 로 다시 보낸다 |
|
||||
| 되돌리기 1 의 `postfix reload` · `postmap -q` | 하지 않는다 — Deactivate 뒤 1분 기다린 다음 시험 메일이 mailcow 사서함에 오는지 본다 |
|
||||
|
||||
0. **읽기만 하는 점검** (SSH 가 있을 때 — 없으면 위 표) — 로컬로 새는 길이 없는지:
|
||||
```bash
|
||||
grep -n -A3 '^transport_maps' data/conf/postfix/main.cf # custom_transport.pcre, local_transport, mysql_relay_ne.cf, mysql_transport_maps.cf 차례
|
||||
dcx postconf -P 'smtpd/pass/smtpd_sender_restrictions' # permit_mynetworks,reject_unlisted_sender,reject_unknown_sender_domain
|
||||
@@ -894,7 +1043,8 @@ sudo docker compose --env-file .env.production start mail
|
||||
|
||||
## 리버스 프록시와 같은 호스트에 둘 때 (대안)
|
||||
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (1~3 은 문서로만 정리 — 검증하지 않았다)
|
||||
메일 서버를 따로 둘 수 있으면 4 를 권한다 — 프록시와 메일 엔진이 443 을 나눠 쓰지 않는다.
|
||||
|
||||
1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로.
|
||||
2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를
|
||||
@@ -912,6 +1062,9 @@ sudo docker compose --env-file .env.production start mail
|
||||
}
|
||||
```
|
||||
3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다.
|
||||
4. **메일 서버를 따로 둔다** — 프록시는 지금 서버에 그대로 두고, 새 서버에 웹메일 스택 전체를 올려 메일 엔진이 그 서버의 443 을 직접 쓴다.
|
||||
프록시는 포털만 같은 VPC 사설망으로 새 서버의 frontend(8080)에 넘긴다 — 공인 IP 를 서버 수만큼만 주는 곳(NCP 등)에서 1 대신.
|
||||
→ [리버스 프록시가 다른 서버에 있을 때](#리버스-프록시가-다른-서버에-있을-때-ncp-등--사설망)
|
||||
|
||||
⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면
|
||||
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
|
||||
@@ -920,6 +1073,181 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
|
||||
|
||||
---
|
||||
|
||||
## 리버스 프록시가 다른 서버에 있을 때 (NCP 등 — 사설망)
|
||||
|
||||
포털의 TLS 를 **다른 서버**의 리버스 프록시(예: 기존 서버의 Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께 쓴다)가 끝내고,
|
||||
**같은 VPC 사설망**으로 메일 서버의 frontend(8080)에 넘기는 구성이다 — [위 대안](#리버스-프록시와-같은-호스트에-둘-때-대안)의 4.
|
||||
메일 서버에는 웹메일 스택 전체(mail · backend · db · frontend)를 그대로 올리고 메일 엔진이 그 서버의 443 을 직접 쓴다(ACME TLS-ALPN · MTA-STS).
|
||||
예시 값: 서버 A(기존 — NPM) 사설 IP `10.0.1.11`, 서버 B(메일 — `mx1.arasystem.it.kr`) 사설 IP `10.0.1.12`.
|
||||
|
||||
```
|
||||
portal.arasystem.it.kr (11단계부터 files. 도) → 서버 A 공인 IP mx1.arasystem.it.kr → 서버 B 공인 IP (PTR 도)
|
||||
브라우저 ─TLS─▶ 서버 A (기존) Nginx Proxy Manager — 다른 사이트들과 80 · 443 을 함께
|
||||
│ http · 같은 VPC 사설망 → 10.0.1.12:8080 (서버 B 의 ACG: 8080 은 10.0.1.11 에서만)
|
||||
▼
|
||||
서버 B (메일 — 새로) docker-compose.yml + docker-compose.remote-proxy.yml
|
||||
<PROJECT_NAME>-frontend :8080 (사설 IP 에만 게시) ─(edge)─▶ backend ─(app-network)─▶ db · mail
|
||||
mail 25 · 465 · 993 · 443 (호스트에 직접, 0.0.0.0) ◀── 바깥 메일 서버 · 메일 프로그램 · ACME
|
||||
```
|
||||
|
||||
- 선택 파일 [`docker-compose.remote-proxy.yml`](../docker-compose.remote-proxy.yml) 이 바꾸는 것은 frontend 하나다 — `proxy` 네트워크에서 빼고(서버 B 에는 없다),
|
||||
8080 을 `FRONTEND_PRIVATE_BIND`(사설 IPv4:포트)에만 게시하고, 그 값을 먼저 보는 `frontend-bind-check` 를 붙인다.
|
||||
frontend 의 환경 · nginx 설정(`X-Forwarded-For` 이어 붙이기 · `X-Request-Id` · 실시간 알림)과 다른 서비스 · 네트워크(edge · app-network)는 기본 구성과 같다.
|
||||
- 서버 A → B 구간은 평문 HTTP 다 — 같은 VPC 사설망 안이라 받아들이고, 그래서 8080 은 서버 A 에서만 받는다(아래 ACG).
|
||||
|
||||
### NCP 쪽 할 일
|
||||
|
||||
| 할 일 | 값 |
|
||||
| --- | --- |
|
||||
| 서버 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`) · `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 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)) |
|
||||
| DNS (가비아) | `mx1` A → 서버 B 공인 IP · `portal` A → 서버 A 공인 IP(지금 그대로) · `files` A → 서버 A 공인 IP(11단계) |
|
||||
|
||||
- **게시 주소로는 바깥을 막지 못한다** — NCP 의 공인 IP 는 서버 안에 없고 사설 IP 로 옮겨(NAT) 들어와, 사설 IP 에 게시한 8080 에도 공인 IP 로 닿는다.
|
||||
8080 을 막는 것은 ACG 다. 서버 B 의 `ip -4 addr` 에 공인 IP 가 없으면 이 경우다 — 바깥 PC 에서 `curl -m 5 http://<서버 B 공인 IP>:8080/` 이 시간 초과여야 한다.
|
||||
(NCP 의 NAT · ACG 는 NCP 문서 기준이고 이 저장소에서 시험하지 않았다 — 배포 뒤 아래 "확인"으로 본다)
|
||||
- 메일 포트(25 · 465 · 993 · 443)는 기본 구성 그대로 `0.0.0.0` 에 게시한다. 공인 IP 로 들어온 접속이 원래 주소 그대로 보이는지는 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 본다.
|
||||
- 두 서버의 사설 IP 를 바꾸면(서버를 다시 만들면) `FRONTEND_PRIVATE_BIND` · `TRUST_PROXY` · NPM 의 Forward IP · ACG 를 함께 고친다.
|
||||
|
||||
### 서버 B — env 와 실행
|
||||
|
||||
`.env.production`([운영 배포](#운영-배포))에 셋을 넣는다 — 앞의 둘은 예시 env 의 "리버스 프록시가 다른 서버에 있을 때" 칸, `TRUST_PROXY` 는 바꾼다:
|
||||
|
||||
```bash
|
||||
COMPOSE_FILE=docker-compose.yml:docker-compose.remote-proxy.yml # 리눅스는 : 로 잇는다
|
||||
FRONTEND_PRIVATE_BIND=10.0.1.12:8080 # 서버 B 의 사설 IPv4:포트 (ip -4 addr)
|
||||
TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWORK_SUBNET>,<서버 A 사설 IP> — 아래 "TRUST_PROXY"
|
||||
```
|
||||
|
||||
- **`COMPOSE_FILE` 은 env 파일에 둔다** — compose 가 `--env-file` 의 이 값을 읽으므로 이 문서의 운영 명령(`sudo docker compose --env-file .env.production …`)이 `-f` 없이 그대로 두 파일을 읽는다.
|
||||
`-f` 두 개로 쓰면 명령마다 붙여야 하고, 하나라도 빠뜨린 명령은 기본 구성(서버 B 에 없는 `proxy` 네트워크)으로 돈다. `-f` 를 주면 `COMPOSE_FILE` 보다 그쪽이 이긴다.
|
||||
- 그다음은 [운영 배포](#운영-배포) 절차 그대로다. 띄우기 전 · 뒤에 본다:
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production config --networks # edge · app-network 만 — proxy 가 보이면 COMPOSE_FILE 이 먹지 않았다
|
||||
sudo docker port "${PROJECT_NAME}-frontend" # 8080/tcp -> 10.0.1.12:8080 한 줄
|
||||
sudo docker compose --env-file .env.production logs frontend-bind-check
|
||||
```
|
||||
- **게시 주소 확인** — `FRONTEND_PRIVATE_BIND` 가 비어 있으면 compose 가 바로 멈춘다(`required variable FRONTEND_PRIVATE_BIND is missing a value`).
|
||||
값이 있으면 frontend 보다 먼저 `frontend-bind-check`(셸만 쓰는 한 번짜리 컨테이너 — DB 이미지를 빌린다 · 네트워크 없음 · 비-root)가 RFC 1918 사설 IPv4(10/8 · 172.16/12 · 192.168/16):포트(1~65535, 앞자리 0 없이)인지 본다.
|
||||
아니면 `up` 이 `service "frontend-bind-check" didn't complete successfully: exit 1` 로 멈추고 frontend 는 뜨지 않는다 — 포트뿐인 값(`8080` · `:8080` — compose 는 모든 주소에 연다) · `0.0.0.0` · `127.0.0.1` · 공인 IP · IPv6.
|
||||
떠 있던 frontend 의 값을 잘못 바꾸면 compose 가 frontend 를 다시 만든 뒤 띄우지 않으므로 포털이 멈춘다(닫힌 쪽) — 값을 고치고 `up -d`.
|
||||
끝나면 멈춰 있어 `ps` 에는 없고 `ps -a` 에 `Exited (0)` 이다. 앱 네트워크에 붙지 않으므로 mail setup 의 구성원 확인과도 상관없다.
|
||||
- 확인 (2026-10-30 — compose v2.40.2(윈도) · 5.4.0(리눅스, CI 잡 이미지), 예시 env + 위 값):
|
||||
- `config` 의 frontend 는 networks `edge` 하나, ports `host_ip: 10.0.1.12` · `published: "8080"` · `target: 8080`, depends_on 에 `frontend-bind-check`(`service_completed_successfully`) —
|
||||
기본 구성과의 차이는 이 셋뿐이고 환경 · healthcheck 는 같다. 맨 위 networks 에 `proxy` 가 없다.
|
||||
- `COMPOSE_FILE` 을 `--env-file` 에만 두고 `-f` 없이 돌린 `config` 가 위와 같았다(윈도는 `;` 로 잇는다). `COMPOSE_PROFILES=mail` 도 그대로 먹었고, `-f docker-compose.yml` 을 주면 기본 구성이 나왔다.
|
||||
- `frontend-bind-check` — `10.0.1.12:8080` · `172.31.255.254:65535` · `192.168.0.10:18080` 은 0, `8080` · `:8080` · `0.0.0.0:8080` · `127.0.0.1:8080` · `203.0.113.5:8080` · `172.32.0.1:8080` · `[::]:8080` · `10.0.1.12:0` · `10.0.1.12:08080` 은 1
|
||||
(`10.0.1.12` · `010.0.1.12:8080` · `10.0.1.12:65536` 은 compose 가 먼저 거절). 잘못된 값의 `up -d` 는 종료 코드 1 · frontend `Created`(시작하지 않음)이고,
|
||||
떠 있던 frontend 에서 값을 잘못 바꾸면 다시 만들어진 frontend 가 `Created` 로 남았다 (시험용 프로젝트 — frontend 대신 alpine).
|
||||
|
||||
### TRUST_PROXY — 서버 A 의 주소 목록
|
||||
|
||||
- 값은 `<EDGE_NETWORK_SUBNET>,<서버 A 사설 IP>` (예 `172.16.72.0/24,10.0.1.11`) — 백엔드의 운영 env 검증이 받는다(2026-10-30 확인).
|
||||
서버 A 사설 IP 는 frontend 가 보는 접속 주소다 — 서버 A 의 NPM 은 도커지만 서버 밖으로는 서버 A 의 사설 IP 로 나간다. 배포 뒤 `sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"` 의 첫 칸으로 확인하고,
|
||||
같은 줄의 X-Forwarded-For(넷째 `"…"` 칸) 끝이 브라우저의 공인 주소인지 본다.
|
||||
- 홉 수 `2` 도 정상 흐름에서는 같은 IP 를 읽지만 "오른쪽에서 두 번째 주소"를 **누가 붙었든** 믿는다. 같은 VPC 의 다른 서버가 8080 에 바로 붙으면(ACG 를 잘못 열었을 때)
|
||||
`X-Forwarded-For` 를 꾸며 백엔드가 그 값을 클라이언트 IP 로 쓴다 — 로그인 실패 한도 · 요청 수 한도가 IP 기준이다. 주소 목록은 서버 A 에서 온 것만 믿어, 다른 곳에서 바로 붙으면 그 서버 자신의 주소로 센다.
|
||||
|
||||
| frontend 가 받은 `X-Forwarded-For` (백엔드가 보는 접속은 edge 의 frontend) | 주소 목록 | 홉 수 `2` |
|
||||
| --- | --- | --- |
|
||||
| 정상 — `203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 브라우저가 꾸밈 — `1.2.3.4, 203.0.113.50, 10.0.1.11` | `203.0.113.50` | `203.0.113.50` |
|
||||
| 같은 VPC 의 다른 서버가 8080 에 바로 — `1.2.3.4, 10.0.2.20` | `10.0.2.20` | `1.2.3.4` (꾸민 값) |
|
||||
| 서버 A 의 다른 컨테이너가 8080 에 바로 — `1.2.3.4, 10.0.1.11` | `1.2.3.4` (꾸민 값) | `1.2.3.4` (꾸민 값) |
|
||||
|
||||
(2026-10-30, 개발 백엔드 컨테이너의 Express 5.2.1 · proxy-addr 2.0.8 과 백엔드의 `resolveTrustProxy` 로 확인)
|
||||
- **남는 것** — 서버 A 의 모든 것(다른 사이트의 컨테이너 포함)은 같은 사설 IP 로 나가 NPM 과 가를 수 없다(표의 마지막 줄). 서버 A 는 믿는 쪽에 든다 — 그 서버에 믿지 못할 것을 두지 않는다.
|
||||
NPM 이 사설 대역에서 온 접속의 `X-Real-IP` 를 클라이언트 주소로 믿는 것(같은 VPC 의 다른 서버가 NPM 을 거쳐 꾸밀 수 있다)은 아래 고급 칸으로 끈다.
|
||||
|
||||
### Nginx Proxy Manager 설정
|
||||
|
||||
서버 A 의 NPM 에 Proxy Host 를 하나 만든다. 기본값은 NPM **v2.16.0**(2026-09-24, 지금 최신) 소스로 확인했다 —
|
||||
[`nginx.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/nginx.conf) ·
|
||||
[`proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/proxy.conf) ·
|
||||
[`block-exploits.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/block-exploits.conf) ·
|
||||
[`assets.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/assets.conf) ·
|
||||
[`log-proxy.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/docker/rootfs/etc/nginx/conf.d/include/log-proxy.conf) ·
|
||||
템플릿 [`proxy_host.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/proxy_host.conf) ·
|
||||
[`_listen.conf`](https://github.com/NginxProxyManager/nginx-proxy-manager/blob/v2.16.0/backend/templates/_listen.conf). 서버 A 의 NPM 판이 다르면 같은 파일을 대조한다.
|
||||
|
||||
| 탭 · 칸 | 값 | 까닭 |
|
||||
| --- | --- | --- |
|
||||
| Details › Domain Names | `portal.arasystem.it.kr` | |
|
||||
| Details › Scheme · Forward Hostname / IP · Forward Port | `http` · 서버 B 사설 IP(`10.0.1.12`) · `8080` | `FRONTEND_PRIVATE_BIND` 와 같은 값 |
|
||||
| Details › Cache Assets | **끔** | 켜면 `.js` · `.css` · `.png` 등으로 끝나는 주소를 NPM 이 30분 캐시한다 — 응답의 `Cache-Control` · `Set-Cookie` 를 무시하고 키가 호스트 + 주소뿐이라(로그인한 사람을 가르지 않는다), 그런 주소의 API 가 생기면 남의 응답이 나간다(지금 서명 그림 · 첨부는 id 로 끝난다). 우리 캐시 머리글(`index.html` no-cache · `/assets/` 30일 immutable)도 지운다 |
|
||||
| Details › Block Common Exploits | **끔** | PHP 사이트용 패턴이 메일 검색어에 걸린다 — 아래 표 |
|
||||
| Details › Websockets Support | 끔 | 웹메일은 웹소켓을 쓰지 않는다 (실시간 알림은 SSE — 보통 HTTP 응답) |
|
||||
| Details › Access List | Publicly Accessible | 로그인은 웹메일이 한다 |
|
||||
| SSL › SSL Certificate | Let's Encrypt 새 인증서 (`portal.`) | 서버 A 의 80 으로 HTTP-01 |
|
||||
| SSL › Force SSL | 켬 | |
|
||||
| SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) |
|
||||
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
|
||||
| Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
|
||||
|
||||
```nginx
|
||||
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
|
||||
proxy_read_timeout 150s;
|
||||
# 접근 로그에 검색어 · 토큰(쿼리)이 남지 않게 — NPM 의 proxy 형식은 "$request_uri"(쿼리째)와 Referer 를 남긴다.
|
||||
# 접속 기록은 프론트 nginx 의 접근 로그(쿼리 없음 · 브라우저 주소는 X-Forwarded-For 칸)로 본다
|
||||
access_log off;
|
||||
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
|
||||
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
|
||||
real_ip_header proxy_protocol;
|
||||
```
|
||||
|
||||
- **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 —
|
||||
위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다.
|
||||
- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`).
|
||||
|
||||
v2.16.0 기본값과 이 포털에 필요한 것:
|
||||
|
||||
| 항목 | v2.16.0 기본 | 필요 | 할 일 |
|
||||
| --- | --- | --- | --- |
|
||||
| 본문 한도 | `client_max_body_size 2000m` (http) | `MAIL_ATTACHMENT_MAX_SIZE_MB` + 1MB(기본 21m) 이상 | 그대로 — 줄이면 그 이상으로 |
|
||||
| 읽기 시간 | `proxy_read_timeout 90s` (http) | 150s 이상(첨부) · 실시간 알림은 25초 ping 보다 길면 된다 | 고급 칸 150s |
|
||||
| 응답 전체 시간 상한 | 없음 | 실시간 알림 1시간 | 그대로 |
|
||||
| 응답 버퍼링 | nginx 기본(켬) — `X-Accel-Buffering` 을 무시하게 한 곳이 없다 | `/api/mail-events` 만 바로 흘려보낸다 | 그대로 — 프론트 nginx 가 그 응답에 `X-Accel-Buffering: no` 를 넘긴다 |
|
||||
| 압축 | `gzip on` · `gzip_types` 없음 → `text/html` 만 | `text/event-stream` 은 압축하지 않는다 | 그대로 — custom `http.conf` 등으로 `gzip_types` 를 넓히지 않는다 |
|
||||
| `Accept-Encoding` | http 단계에 `proxy_set_header Accept-Encoding ""` 가 있지만, Proxy Host 의 `location /` 가 `proxy.conf` 로 머리글을 따로 정해 물려받지 않는다 | | 그대로 — 브라우저의 값이 frontend 까지 가서 JS · CSS · JSON 은 프론트 nginx 가 압축한다(SSE 는 하지 않는다) |
|
||||
| 클라이언트가 끊으면 | `proxy_ignore_client_abort off` | 바로 끊는다 | 그대로 |
|
||||
| `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 |
|
||||
| `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` |
|
||||
| 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` |
|
||||
|
||||
**Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
|
||||
|
||||
| 요청 | 걸리는 패턴 | 결과 |
|
||||
| --- | --- | --- |
|
||||
| 메일 검색 API `?q=viagra` (소문자 — `valium` · `xanax` · `cialis` · `cocaine` · `tramadol` · `prozac` · `libido` 등) | 스팸 낱말 | 403 |
|
||||
| `?q=concat(` · `?q=<b>javascript</b>`(`%3C…script…%3E`) | SQL · 스크립트 | 403 |
|
||||
| 검색 화면 새로 고침 — `/mail/search?q=http://example.com` · `?q=../etc` (화면 주소는 `/` · `:` 를 인코딩하지 않는다) | 파일 주입 | 403 |
|
||||
| 같은 검색어의 API(`q=http:%2F%2F…` · `q=..%2F…`) · 한글 검색어 | — | 통과 |
|
||||
|
||||
(2026-10-30, `block-exploits.conf` 를 그대로 넣고 realip · 로그 형식 · 템플릿 차례를 흉내 낸 nginx 1.30.6 으로 확인. 같은 시험에서 사설 주소에서 온 `X-Real-IP: 1.2.3.4` 가 `$remote_addr` 를 바꾸고
|
||||
고급 칸의 `real_ip_header proxy_protocol;` 이 막는 것, 템플릿의 `access_log` 뒤의 `access_log off;` 로 그 Proxy Host 의 로그가 비는 것, `location /` 가 머리글을 따로 정하면 `Accept-Encoding` 이
|
||||
그대로 넘어가는 것도 봤다. 실제 NPM 에는 넣어 보지 않았다)
|
||||
|
||||
### 확인 (배포 뒤)
|
||||
|
||||
```bash
|
||||
# 서버 A 에서 — 사설망으로 닿는다 (200)
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://10.0.1.12:8080/
|
||||
# 바깥 PC 에서 — 공인 IP 의 8080 은 닫혀 있다 (시간 초과)
|
||||
curl -m 5 http://<서버 B 공인 IP>:8080/
|
||||
# 서버 B 에서 — 첫 칸이 서버 A 사설 IP, X-Forwarded-For 칸 끝이 브라우저의 공인 주소
|
||||
sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
|
||||
```
|
||||
|
||||
- 포털에 로그인해 20MB 첨부를 올려 본다(본문 한도 · 150s). 브라우저 개발자 도구 › 네트워크의 `mail-events` 가 200 으로 열린 채 25초마다 `ping` 을 받는다(버퍼링 · 압축 없음).
|
||||
- `https://portal.arasystem.it.kr/api/health` 는 바깥에서 404(`RES_001` JSON)다 — 기본 구성과 같다.
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
@@ -981,4 +1309,93 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요)
|
||||
| `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