diff --git a/.env.development.example b/.env.development.example index fcfca61..33266aa 100644 --- a/.env.development.example +++ b/.env.development.example @@ -159,6 +159,13 @@ MAIL_TRUSTED_NETWORKS= # (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다. # 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다) # MAIL_BLOCKED_ACCOUNTS=ops@arasystem.test +# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐. +# 개발은 비워 두고, 시험할 때만 셸에서 일회성으로 준다 (기존 서버 역 = Mailpit — 개발에서는 셸 값을 일부러 받는다): +# MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs +# 값 없이 다시 실행하면 setup 이 시범 설정을 지운다. 기존 서버 주소 — host · host:port · [host]:port (포트 기본 25) +MAIL_PILOT_LEGACY_HOST= +# 기존 서버가 이 서버에 붙을 때의 IPv4 · CIDR (콤마). 개발은 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳) +MAIL_PILOT_LEGACY_IPS= # 개발용 테스트 직원 계정(ara.kim@ 등)의 공통 비밀번호 — 개발 환경에서만 만들어진다 MAIL_DEV_USER_PASSWORD=change-me-in-local-env # 개발 전용 메일 수집기 — 사내 도메인 밖으로 보낸 메일이 여기에만 쌓인다 diff --git a/.env.production.example b/.env.production.example index 58f8d9d..3fe58ed 100644 --- a/.env.production.example +++ b/.env.production.example @@ -170,3 +170,10 @@ MAIL_TRUSTED_NETWORKS= # (선택) 웹메일 로그인·대리 접근을 막을 계정 — 주소 전체를 콤마로. 비어 있어도 위 관리자(MAIL_ADMIN_LOCAL_PART)는 항상 막힌다. # 관리자를 더 만들었다면 여기에 넣는다 (setup 이 빠진 관리자 계정을 [주의] 로 알려 준다) # MAIL_BLOCKED_ACCOUNTS=ops@arasystem.it.kr +# 시범 공존 — 한 사람만 새 서버를 먼저 쓰고 나머지 직원은 기존 서버(mailcow)에 남을 때 (mail/README.md "파일럿 공존"). 비우면 꺼짐. +# MX 는 mailcow 그대로이고, mailcow 의 Transport Map 이 시범 사용자 주소만 이 서버로 넘긴다. 값은 셸이 아니라 이 파일에 둔다. +# ⚠️ 켠 뒤에 값이 비면 setup 이 멈춘다 — 시범을 끝낼 때(전면 전환)는 둘 다 비우고 setup 에 --end-pilot 을 붙인다. +# 기존 서버 주소 — host · host:port · [host]:port (운영은 포트 25 만 — 기본값, IPv4 주소면 인증서 이름 확인을 끈다). 예: mail.arasystem.it.kr +MAIL_PILOT_LEGACY_HOST= +# 기존 서버가 이 서버에 붙을 때의 공인 IPv4 · CIDR(/24 까지, 콤마) — HOST 가 있으면 필수. 예: 46.250.248.43 +MAIL_PILOT_LEGACY_IPS= diff --git a/mail/README.md b/mail/README.md index 375c127..0d3d5cc 100644 --- a/mail/README.md +++ b/mail/README.md @@ -117,17 +117,24 @@ node --env-file=.env.development mail/scripts/setup.mjs 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — 200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다. 게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)). - 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다) + 시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다). + 보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다 10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11 (7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다) 11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인 +12. **시범 공존** (`MAIL_PILOT_LEGACY_HOST` 가 있을 때 — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)) — 바꾸기 전에 도메인 · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 보고, + 막는 것(맨 앞에 주인 표시인 자동 차단 예외 · RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외)을 6 · 8 과 함께 저장해 9 의 리로드로 먼저 걸고, RCPT 가드가 답하는지 본 뒤 → + 발송 경로 `legacy` · 발송 전략 → 리로드 → 도메인 `allowRelaying`(저장 즉시) → 다시 RCPT · MAIL FROM 을 실제로 보내 확인한다 — + 켜기 전에 가드가 2초 안에 답하지 않으면 켜지 않고, 켠 뒤에 답하지 않으면 `allowRelaying` 을 바로 다시 끄고 멈춘다(끄지 못하면 그렇게 알린다). + 값이 비면 거꾸로(`allowRelaying` 먼저, 꺼진 것을 다시 읽어 확인한 뒤에만 나머지) 지운다 — 운영은 `--end-pilot` 이 있어야 한다 그 밖의 안전장치: - **운영은 docker 를 쓸 수 있어야 한다** — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작). - **셸에 `APP_ENV`·`APP_NETWORK_SUBNET` 등이 이미 있으면 중단한다.** Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않아, 셸에 `APP_ENV=development` 가 남은 채 `.env.production` 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다. - (`MAIL_RECOVERY_ADMIN` 만 예외 — 원래 셸에서 일회성으로 주는 값이다) + (`MAIL_RECOVERY_ADMIN` 은 예외 — 원래 셸에서 일회성으로 주는 값이다. 시범 공존 값 `MAIL_PILOT_LEGACY_*` 는 **개발에서만** 예외 — 시험할 때 셸에서 한 번 켜 본다. + 운영은 env 파일에 없는 시범 값이 셸에 있어도 중단한다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다) - `APP_NETWORK_SUBNET` 은 사설 대역의 네트워크 주소, 접두 길이 `/24`~`/28` 만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다 (넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다). - `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다. @@ -444,8 +451,298 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org < --- +## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저) + +전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다. +**MX 는 mailcow 그대로**이고, mailcow 의 Transport Map 이 시범 사용자 주소 하나만 새 서버로 넘긴다. 새 서버 쪽은 루트 env 두 개로 setup 이 맞춘다. + +- 예시 값: 시범 사용자 `owner@arasystem.it.kr`, 동료(mailcow) `colleague@arasystem.it.kr`, mailcow `mail.arasystem.it.kr`(`46.250.248.43`), 새 서버 IP `` +- 근거: 2026-10-29 개발 엔진(0.16.27) 조사 · 시험과 mailcow(2026-10 판) 설정 파일 · 문서 조사. **mailcow 쪽 절차와 운영의 실제 흐름은 아직 해 보지 않았다** — "개발 확인"이라고 적은 것만 개발 엔진에서 봤다. + +### 메일 흐름 + +| 메일 | 길 | 새 서버가 하는 일 | +| --- | --- | --- | +| 바깥 → 시범 사용자 | 바깥 → mailcow(MX · 스팸 검사) → Transport Map → mx1:25 | mailcow IP 에서 인증 없이 받는다 — 그 IP 는 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다(연결 IP 가 mailcow 라 늘 실패한다). DKIM 검증 · 스팸 판정은 그대로 | +| 동료 → 시범 사용자 | SOGo · 메일 프로그램 → mailcow → Transport Map → mx1:25 | 사내 도메인 MAIL FROM 을 **mailcow IP 에서만** 받는다. 받는 쪽 한도 · 자동 차단에서 mailcow IP 를 뺀다 | +| 시범 사용자 → 동료 | 웹메일 · 465 → mx1 → 발송 경로 `legacy` → mailcow:25 → 동료 사서함 | 새 서버에 없는 사내 주소도 받아서(`allowRelaying`) mailcow 로 넘긴다. DKIM 은 새 selector 로 서명 | +| 새 서버가 만든 메일 → 동료 | 반송 · 필터 전달 · 부재중 → `legacy` → mailcow | 봉투 보낸 사람은 빈 봉투 `<>`(반송)이거나 시범 사용자 주소(전달 · 부재중) — mailcow 의 `reject_unlisted_sender` 를 통과한다 | +| 시범 사용자 → 바깥 | mx1 → 받는 쪽 MX 직접 | 시범 앞과 같다 — mailcow 를 거치지 않는다(SPF 에 새 IP) | +| 시범 사용자 → 시범 사용자 | mx1 안 | 로컬 | +| 인증 없이 mx1:25 로 온, **새 서버에 없는** 사내 주소 (mailcow 포함 어디서든) | — | RCPT `550 5.1.2` — 받아 놓고 되돌려 보내지 않는다 (루프 1차 막기) | +| mailcow 가 아닌 IP 에서 온 사내 도메인 MAIL FROM | — | `550 5.7.1` (위조 막기 — 빈 봉투 `<>` 는 그대로 받는다) | + +루프는 두 겹으로 막는다 — ① RCPT 가드: 인증 없는 세션의 새 서버에 없는 사내 주소는 받지 않는다 ② 발송 전략: `legacy` 로는 **인증한 제출(웹메일 · 465)과 엔진이 만든 메일(필터 전달 · 부재중 · 반송 · 보고)만** 보낸다 — +인증 없이 들어온 메일은 로컬(배달 실패 → 보낸 사람에게 반송, 그 반송은 동료 앞이면 `legacy` 로). 엔진의 홉 한도(`x:MtaStageData` 의 `maxReceivedHeaders` 50 → `450 4.4.6`)가 마지막 막이다. +**RCPT 가드 없이 `allowRelaying` 만 켜진 상태는 setup 이 만들지 않는다** — 켤 때는 가드가 걸린 뒤에 켜고, 켠 뒤 가드가 답하지 않으면 바로 다시 끄고, 끌 때는 `allowRelaying` 이 꺼진 것을 엔진에서 다시 읽어 확인한 뒤에만 가드를 뺀다(2026-10-30 리뷰 H1 · M1). + +### 사전 조건 + +- **운영 전환 체크리스트의 "1 결정 · 준비" · "2 서버 배포"를 마쳤다** — 새 서버가 mx1 로 떠 있고([운영 배포](#운영-배포)), setup 을 다시 돌리면 전부 `[유지]` 다. +- **DNS 는 더하기만** (가비아) — [전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)의 단계 1 · 3 · 4: + `mx1` A → `` · PTR → `mx1.arasystem.it.kr`, setup 이 출력한 새 DKIM selector TXT 두 개(`v1-ed25519-…` · `v1-rsa-…`), SPF 에 `ip4:`. + **DKIM TXT 는 첫 시범 실행 전에 퍼져 있어야 한다** — 운영 setup 이 이 호스트의 리졸버로 읽어, 없거나 키가 다르면 켜지 않고 멈춘다. + **MX · DMARC · autodiscover 는 바꾸지 않는다.** mx1 에 AAAA 는 두지 않는다(아래 "주의" 의 IPv6). +- 새 서버의 계정은 **시범 사용자 하나만**. 다른 직원 · 별칭 · 공용 주소 · `dmarc@` · `postmaster@` 는 만들지 않는다(아래 "주의"). +- 새 서버 → mailcow **25** 가 열려 있다(setup 이 확인한다 — 운영은 25 만 받는다. 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다). + mailcow 가 새 서버에 붙을 때의 IP 를 안다 — 보통 `mail.arasystem.it.kr` 의 A(`46.250.248.43`). +- `MAIL_ADMIN_LOCAL_PART`(기본 `admin`) · `MAIL_DIRECTORY_LOCAL_PART`(기본 `mail-directory`) · 게이트웨이 `MAIL_GATEWAY_USER` 의 @ 앞(예 `mail-gateway`)이 mailcow 의 실제 주소와 겹치지 않는다 — + 셋 다 새 서버의 로컬 주소(메일을 받지 않는 시스템 계정)라, 시범 사용자가 mailcow 의 같은 이름 주소로 보내면 새 서버가 `not-found` 로 거절한다. +- 기존 서버 IP(`MAIL_PILOT_LEGACY_IPS`)에 **사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다** — setup 이 같은 주소로 시범 표시 항목을 만들어야 한다(같은 주소의 항목은 둘일 수 없다). 있으면 setup 이 멈춘다 — 지운 뒤 다시 실행한다. +- 시범 사용자의 mailcow 사서함은 **그대로 둔다** — Transport Map 은 mailcow 에 있는 받는 사람에게만 먹고, 새 서버가 owner@ 명의로 동료에게 보낼 때도 mailcow 의 `reject_unlisted_sender` 를 이 사서함으로 통과한다. + +### env 와 실행 (새 서버) + +`.env.production` 에 두 값을 적는다 — **셸이 아니라 파일에** (운영은 env 파일에 없는 시범 값이 셸에 있으면 setup 이 멈춘다): + +```bash +MAIL_PILOT_LEGACY_HOST=mail.arasystem.it.kr # host · host:port · [host] · [host]:port (포트 기본 25 — 운영은 25 만) +MAIL_PILOT_LEGACY_IPS=46.250.248.43 # mailcow 가 이 서버에 붙을 때의 공인 IPv4 · CIDR (콤마) +``` + +그리고 [운영 배포](#운영-배포) 5) 와 같은 형태로(`MAIL_ADMIN_URL` · sudo 여부도 같게) setup 을 실행한다: + +```bash +node --env-file=.env.production mail/scripts/setup.mjs +``` + +| 값 | 받는 꼴 | 운영에서 막는 것 | +| --- | --- | --- | +| `MAIL_PILOT_LEGACY_HOST` | `host` · `host:port` · `[host]` · `[host]:port` — 포트는 1~65535, 앞자리 0 없이(기본 25). IPv6 는 받지 않는다 | 25 가 아닌 포트 · `mailpit` · 점 없는 이름(도커 내부 이름으로 풀릴 수 있다) · 사설 · 특수 IPv4(문서 · 시험 대역 `192.0.2.0/24` · `198.51.100.0/24` · `203.0.113.0/24` · `198.18.0.0/15` · `192.0.0.0/24` 포함) · `MAIL_HOSTNAME`(모든 환경). 이름이 사설 · 특수 주소로 풀려도 멈춘다(실행할 때 메일 컨테이너 안에서 푼다) | +| `MAIL_PILOT_LEGACY_IPS` | IPv4 · CIDR(네트워크 주소로)을 콤마로 | 비어 있음(HOST 가 있으면 필수) · 사설 · 특수 대역(위와 같은 문서 · 시험 대역 포함) · `APP_NETWORK_SUBNET` 과 겹침 · `/24` 보다 넓음 · 이 서버 자신의 주소 | + +- 기존 서버 주소가 IPv4(예: `46.250.248.43`)면 발송 경로의 인증서 이름 확인을 끈다(`allowInvalidCerts: true` — 출력에 알린다). 이름(`mail.arasystem.it.kr`)이면 mailcow 의 공인 인증서로 확인한다 — **이름을 권한다**. +- 개발은 셸에서 일회성으로 켜 본다(개발만 셸 값을 받는다) — 기존 서버 역은 Mailpit, 기존 서버 IP 는 비우면 백엔드 컨테이너 주소(개발의 시험 SMTP 가 나가는 곳 — 앱 네트워크 전체는 쓰지 않는다): + `MAIL_PILOT_LEGACY_HOST=mailpit:1025 node --env-file=.env.development mail/scripts/setup.mjs` → 값 없이 다시 돌리면 바로 끈다. + +### setup 이 하는 일 + +먼저 확인한다 — 하나라도 어긋나면 **아무것도 바꾸지 않고** 멈춘다. 다만 **RCPT 가드 없이 `allowRelaying` 이 열려 있으면 그것부터 닫는다**(`[변경]`, 다시 읽어 확인) — +그다음 확인이 모두 맞으면 아래 차례(A → B → C)로 다시 켜고, 어긋나면 닫힌 채 멈춘다(머리글 "allowRelaying 만 닫고 중단합니다"). +**닫지 못하면**(저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면) 다른 확인 · 설정(경로 · 전략 · C) 없이 멈추되(종료 1), 멈추기 전에 **RCPT 가드만 다시 건다** — +RCPT 단계 식이 setup 의 것이거나 비었을 때만 가드 갈래를 저장하고 시스템 Sieve 스크립트를 켠 뒤 리로드하고 가드를 확인한다(2026-10-30 3차 리뷰 R1). 멈춤 글이 그 결과를 싣는다 — +"대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(550 · Nms). relay 는 여전히 켜져 있습니다" 또는 "RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다" +(운영자의 식이면 건드리지 않고 그렇다고 적는다). 어느 쪽이든 관리 화면에서 그 도메인의 relay 허용(`allowRelaying`)을 끈 뒤 다시 실행한다(N1). 멈출 때는 그 전에 쌓인 확인 실패도 함께 찍는다. + +| 확인 | 까닭 | +| --- | --- | +| `x:MtaStageRcpt` 의 `waitOnFail` 이 고정 값이고 4초 이상(기본 5초) · (`allowRelaying` 이 이미 켜져 있으면) 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 이다 | 가드(Sieve)의 거절은 곧바로(개발 0.2~1.1ms), 엔진 자체의 없는 주소 거절은 `waitOnFail` 뒤(개발 4,999~5,003ms)다 — 응답 시간으로 둘을 가른다. 가드가 답하지 않으면 relay 를 먼저 닫는다. `waitOnFail` 이 바뀌어 있어도 확인은 보낸다 — 250 이면(시간과 상관없이 가드가 없다) 닫고, 550 이면 relay 를 그대로 두고 `waitOnFail` 문제로 멈춘다(N3) | +| 도메인에 catch-all 이 없다 | catch-all 은 `allowRelaying` 보다 먼저라 동료 앞 메일이 catch-all 함으로 간다 | +| 하위 주소(`subAddressing`)가 기본(`Enabled` — `+`), 바깥 디렉터리(`directoryId`)가 없다 | RCPT 가드가 `+` 를 가정한다 · 엔진의 계정만 로컬로 본다 | +| 기존 서버 주소가 엔진의 서버 이름(`x:SystemSettings` 의 `defaultHostname` · `MAIL_HOSTNAME`)과 다르고, 메일 컨테이너 안에서 풀어도 엔진 자신(메일 컨테이너 주소 · 이 호스트의 IPv4 · 서버 이름의 주소 · 루프백)이 아니며, 기존 서버의 인사(220)가 엔진의 서버 이름이 아니다 · (운영) 이름이 사설 · 특수 주소로 풀리지 않는다 | 자기 자신으로 보내면 메일이 돈다 — 인사 이름으로 보는 것은 DNS · NAT 와 상관없이 잡힌다 | +| 메일 컨테이너 안에서 `curl -X NOOP smtp://<기존 서버>:<포트>/<서버 이름>` 이 220 · 250 — 운영은 `--ssl-reqd`(STARTTLS 와 인증서까지, IPv4 주소면 인증서는 보지 않는다) | 닿지 않으면 동료 앞 메일이 새 서버 대기열에 쌓인다. 메일은 보내지 않는다. **이미 켜진 시범의 같은 기존 서버**가 잠깐 닿지 않거나 이름이 풀리지 않으면 `[주의]` 만 하고 이어 간다(다른 정비를 막지 않는다) — 기존 서버를 바꾸는 실행이면 멈춘다 | +| 발송 경로 이름 `legacy` · RCPT 단계 스크립트 · 운영의 발송 전략("사내 도메인은 로컬, 나머지는 경로 하나" 꼴)을 다른 설정이 쓰지 않는다 | setup 은 남의 설정을 덮어쓰지 않는다 — 외부 릴레이(SES 등)를 쓰고 있으면 그 경로가 바깥 발송으로 남는다 | +| 기존 서버 IP 에 메일 컨테이너 주소가 없다 · (운영) 이 서버 자신의 주소가 없다 · (운영) 기존 서버 이름의 주소가 목록에 있는지(없으면 `[주의]`) | 위조 막기를 메일 컨테이너에서 확인한다 | +| 기존 서버 IP 에 사람이 만든 자동 차단 예외(`x:AllowedIp`)가 없다 | setup 의 시범 표시 항목이 그 주소를 써야 한다(아래 "주인 표시") — 지운 뒤 다시 실행하면 setup 이 같은 주소로 만든다 | +| 기존 서버 IP 로만 된 시범 꼴의 갈래(위조 막기 · 검사 예외 · "Sender IP throttle")가 표시 없이 있지 않다 · 한 칸에 setup 꼴의 갈래가 둘 이상이 아니다 | 같은 원칙(N4) — 운영자가 넣은 같은 IP 의 갈래는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고, 시범을 끝낼 때 함께 지워진다. 그 갈래를 지운 뒤(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다. 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 setup 의 갈래도 이렇게 보인다 — 그때는 같은 설명으로 표시를 다시 만들어도 된다. 둘 이상이면 하나만 남기고 지운다. 다른 IP 의 같은 꼴 갈래는 막지 않고 건드리지도 않는다 | +| (운영) 활성 DKIM selector 의 TXT 가 DNS 에 있고 공개 키가 엔진의 키와 같다 — 이 호스트의 리졸버로 읽는다 | 다르면 mailcow 가 owner 의 메일을 위조로 거절한다(`R_DKIM_REJECT` → `SPOOFED_UNAUTH` — 아래 mailcow 절차 4). 없으면 DMARC 가 SPF 정렬에만 기대게 되어(`R_DKIM_PERMFAIL` 은 0점), 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일이 거절된다. **처음 켤 때 다르거나 TXT 가 확실히 없으면**(ENOTFOUND · ENODATA) 멈춘다 — relay 를 켜지 않는다, TXT 를 넣고 퍼진 뒤 다시(N8). 이미 켜져 있으면 실패로 보고만(닫지 않는다). 리졸버 오류 · 엔진이 공개 키를 주지 않으면 `[주의]`(견줄 수 없음 — 운영에서는 아직 돌려 보지 않았다) | + +그다음 순서대로 맞춘다 — **막는 것부터**: + +| 차례 | 엔진 설정 | 값 | +| --- | --- | --- | +| A | `x:AllowedIp` | 기존 서버 IP — 설명 `[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP` (→ `ReloadBlockedIps`). **시범 설정의 주인 표시**라 A 의 맨 앞에서 만든다 — 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다(N5). 바뀐 IP 의 옛 항목은 식을 모두 고친 뒤에 지운다 | +| A | `x:MtaStageRcpt` 의 `script` | 시스템 계정 갈래 + **RCPT 가드** — 인증 없는 세션의 새 서버에 없는 사내 주소(+태그 꼴 포함)는 시스템 계정과 같은 스크립트로 `550 5.1.2` | +| A | `x:MtaInboundThrottle` "Sender address to recipient throttle" | 인증 없는 세션 중 기존 서버 IP 가 아닌 것만 센다 | +| A | `x:MtaStageMail` 의 `isSenderAllowed` | 맨 앞 갈래 — 인증 없는 세션의 사내 도메인 MAIL FROM 이 기존 서버 IP 가 아니면 false → `550 5.7.1` | +| A | `x:SenderAuth` 의 `spfEhloVerify` · `spfFromVerify` · `reverseIpVerify` · `dmarcVerify` | 맨 앞 갈래 — 기존 서버 IP 면 `disable` (나머지 갈래는 그대로) | +| A | `x:MtaInboundThrottle` "Sender IP throttle" | 기존 서버 IP 를 세지 않는다(찾지 못하거나 엔진 기본 · setup 의 값이 아니면 `[주의]`) | +| | `ReloadSettings` | A 가 걸린다 | +| | **켜기 전 확인** | 인증 없는 세션의 새 서버에 없는 사내 주소 RCPT 가 **2초 안에** 550 — relay 가 꺼져 있어도 같은 판별이다(RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돈다 · 개발 실측 0.3~0.7ms). 아니면 B · C 없이 종료 1(N2) | +| B | `x:MtaRoute` Relay `legacy` | `:<포트>`, STARTTLS 가 있으면 쓴다 — 설명 `시범 공존 — … [setup.mjs]` | +| B | `x:MtaOutboundStrategy` 의 `route` | 0 새 서버의 주소 → 로컬 · 1 그 밖의 사내 주소 + `source` 가 `authenticated` · `autogenerated` · `dsn` · `report` → `legacy` · 2 그 밖의 사내 주소 → 로컬 · 나머지 → 시범 앞의 값(운영 `mx`, 개발 `mailpit`) | +| | `ReloadSettings` | B 가 걸린다 | +| C | `x:Domain` 의 `allowRelaying` | `true` — **저장 즉시 먹는다**(리로드가 필요 없다). 그래서 A · B 가 걸리고 가드를 확인한 뒤에 둔다 | + +- 갈래 1 의 `source` 는 **허용 목록**이다(2026-10-30 리뷰 L1). 0.16.27 의 값은 `authenticated`(웹메일 · SMTP 인증 제출) · `unauthenticated` · `dmarc_pass`(인증 없는 수신) · `dsn`(반송) · + `report`(보고) · `autogenerated`(필터 전달 · 부재중 등 Sieve) · `unknown`(표시가 없는 대기열 메일)이다(소스 `smtp/src/queue/mod.rs`). 빠진 셋은 legacy 로 가지 않는다 — + 인증 없는 수신은 RCPT 가드가 먼저 막고, `unknown` 은 0.16.27 이 새로 만드는 메일에는 붙지 않는다(옛 판에서 남은 대기열 메일뿐). **엔진 마이너를 올리면 이 목록을 다시 본다.** +- **주인 표시** — 식(위조 막기 · 검사 예외 · "Sender IP throttle")에는 설명 칸이 없어, setup 은 **시범 표시가 있는 자동 차단 예외의 주소와 같은 IP 의 갈래만** 자기 것으로 본다. + 표시가 없으면 같은 꼴이어도 운영자 것으로 보고 켤 때도 끌 때도 건드리지 않는다(시범 설정이 있을 때만 `[주의]` 로 알린다). 뺄 때는 맨 앞만이 아니라 모든 갈래에서 찾는다. + 기존 서버 IP 로만 된 운영자 갈래는 켜기 전에 멈추고(위 확인 표), 한 칸에 setup 꼴의 갈래가 둘 이상이면(setup 은 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것) 켤 때는 멈추고 + 끌 때는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의]`) — 켤 때도 끌 때도 운영자 갈래를 합치거나 지우지 않는다(N4). +- **받는 사람 한도**("Sender address to recipient throttle")는 표시로 가리지 않는다 — 8단계부터 setup 이 match 를 통째로 정하는 칸이라, 켤 때는 기존 서버 IP 를 뺀 식으로, + 끌 때는 "인증 없는 세션에만"으로 되돌린다. 표시를 보는 것은 끄는 실행이 relay 가 켜져 있어 시범 설정을 남길 때 그 예외가 setup 의 것인지 가르는 데뿐이다(N5). + +확인 — 메일 컨테이너 안에서 자기 네트워크 주소의 25 로, RCPT 까지만 (메일은 보내지 않는다): + +- **켜기 전**(A 의 리로드 뒤 · B 앞): 인증 없는 세션 `RCPT TO:@도메인>` → **550 이고 2초 안** (RCPT 가드, 보낸 사람은 빈 봉투). 아니면 relay 를 켜지 않고 종료 코드 1(2차 리뷰 N2). + 이미 켜진 시범이면 relay 가 켜진 채로 보고, 가드가 답하지 않으면 곧바로 끈다. +- **켠 뒤**(C 바로 뒤): 같은 확인. 아니면 **`allowRelaying` 을 곧바로 다시 끄고**(다시 읽어 확인) 종료 코드 1 — 가드 · 경로는 남겨 두어 원인을 고친 뒤 같은 명령으로 다시 켠다(2026-10-30 리뷰 M1). + 끄지 못하면 "끄려 했지만 끄지 못했습니다 — 지금 가드 없이 열려 있을 수 있습니다"로 알린다(N1). +- 인증 없는 세션 `MAIL FROM:@도메인>` → `550 5.7.1` (메일 컨테이너는 기존 서버 IP 가 아니다) +- [개발] 인증한 세션(465, 테스트 직원) `RCPT TO:` → 250 뒤 RSET — 운영은 직원 비밀번호가 없어 하지 않는다(아래 mailcow 절차 4 의 실제 발송으로 본다). + 비밀번호는 `docker exec` 의 환경변수가 아니라 표준 입력으로 넘긴다 + +다시 돌리면 전부 `[유지]`, 값이 바뀌면 그 칸만 `[변경]` 이다. 끌 때는 거꾸로 — `allowRelaying` 을 먼저 끄고(저장 즉시) **엔진에서 다시 읽어 꺼진 것을 확인한 뒤에만** +발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 → 리로드 → `legacy` 경로(발송 전략을 되돌린 리로드 뒤 — 걸려 있는 전략이 지워진 경로를 고르는 틈이 없게, L5). +**운영은 값을 비우는 것만으로는 끄지 않는다** — env 파일에서 값이 빠진 채 돌리면 "값을 다시 넣거나 `--end-pilot`" 으로 멈춘다(아무것도 바꾸지 않는다). 개발은 바로 끈다. +`allowRelaying` 은 켜져 있는데 setup 의 `legacy` 경로가 없으면(관리 화면에서 경로만 지웠을 수도 있다) 누가 켠 것인지 알 수 없어 끄지 않고 실패로 보고하며, +**그동안 RCPT 가드 · 위조 막기 · 예외 · 발송 전략도 남긴다**(가드 없는 relay 를 setup 이 만들지 않는다). `allowRelaying` 을 끈 뒤 다시 돌리면 그때 뺀다. +**다른 도메인**이 `allowRelaying` 을 켜 두었으면 setup 은 그 도메인을 끄지 않고 시범 설정을 남긴다(RCPT 가드는 모든 사내 도메인을 덮는다) — "다른 도메인 … 이 켜 두어"로 알린다. +시범 설정이 하나도 없는데 `allowRelaying` 만 켜져 있으면 바꾸지 않고 실패로 알린다(요약 `시범 설정 없음 · allowRelaying 켜짐` — 가드 없이 열린 relay 다). + +개발 확인 (2026-10-29, 0.16.27 — 기존 서버 역 Mailpit, 기존 서버 IP 역 백엔드 컨테이너): + +- 처음 실행은 A · B · C 가 `[생성]` · `[변경]`, 다시 돌리면 전부 `[유지]` · 종료 코드 0. 기존 서버 IP 를 둘로 늘렸다 줄이면 그 칸만 `[변경]` · `[삭제]`(여러 IP 의 식도 엔진이 받았다). +- 웹메일 → 새 서버에 없는 사내 주소는 `legacy` 경로로 한 번 배달(엔진 로그 `delivery.delivered … hostname = ""`, Mailpit 의 id 가 같다), 있는 계정은 로컬, 바깥 주소는 개발 외부 경로로, 시스템 계정은 여전히 `not-found`. +- 인증 없는 25 의 없는 사내 주소(+태그 포함) 550 · 있는 계정(+태그 · 대문자 포함) 250, 메일 컨테이너의 사내 도메인 MAIL FROM `550 5.7.1`, 기존 서버 IP 의 사내 도메인 MAIL FROM 은 받아서 배달(`Authentication-Results: … none`). +- 기존 서버 IP 가 새 서버에 없는 주소를 되돌려 보내면 RCPT 550 — Mailpit 에 되돌아 배달된 것이 없고 대기열이 빈다. +- 기존 서버 주소가 엔진 자신(`mail:25`) · `MAIL_HOSTNAME` · 닿지 않는 주소면 아무것도 바꾸지 않고 멈췄다. 값 없이 다시 돌리면 엔진 설정이 시범 앞과 같아졌다(스냅숏 비교) — 웹메일의 없는 사내 주소는 다시 `not-found`. + +개발 확인 (2026-10-30 리뷰 수정 뒤, 기존 서버 역 `arasystem-mailpit:1025` — 엔진 로그의 `hostname` 으로 개발 외부 경로 `mailpit` 과 가른다): + +- **허용 목록 경로** — 465 인증 제출(`queue.authenticated-message-queued`) · doyun 의 필터 전달 · 부재중 응답(`queue.autogenerated-queued`)이 새 서버에 없는 사내 주소로 `legacy` 에 한 번씩. + 부재중은 개발의 `.test` 도메인 탓에 스팸 점수가 높아(7.5) 시험 동안만 기존 서버 IP 를 스팸 필터에서 빼고 되돌렸다. RCPT 가드 갈래만 잠시 뺀 엔진에서 기존 서버 IP 가 보낸 인증 없는 메일은 + `legacy` 로 가지 않고 로컬 실패(`delivery.dsn-perm-fail`) → 그 반송은 동료(새 서버에 없는 사내 주소) 앞이라 `legacy` 로 한 통. +- **닫힌 쪽(M1)** — 위처럼 가드 갈래가 빠진 채 relay 가 열린 엔진 · 시스템 Sieve 스크립트를 끈 엔진(가드 식은 있지만 RCPT 가 250)에서 시범 값으로 돌리자, + setup 이 먼저 `allowRelaying` 을 끄고(`[변경] … RCPT 가드 없이 allowRelaying 이 켜져 있어 …` · `… RCPT 가드가 답하지 않아(… 250 2.1.5 OK) …`) 가드 · 스크립트를 되살린 뒤 C 로 다시 켰다 — 켠 뒤 확인 1ms 안팎, 종료 코드 0. +- **이미 켜진 시범(L2)** — 기존 서버 역을 멈춘(`docker pause`) 채 같은 값으로 돌리면 `[주의] … 닿지 않습니다 … 이미 켜진 시범이라 이어 간다` · 종료 코드 0. + 다른 기존 서버(닿지 않는 포트)로 바꾸는 실행은 멈췄고 엔진 스냅숏이 그대로였다. +- **H1** — 시범이 켜진 엔진에서 `legacy` 경로만 지우고 값 없이 돌리면 `[유지] … allowRelaying 이 켜져 있어 끝내지 않는다` · 가드 그대로(RCPT 550) · 종료 코드 1. + `allowRelaying` 을 손으로 끈 뒤 다시 돌리자 나머지를 뺐고, 끝난 엔진 스냅숏이 시험 전과 같았다. +- 켬 → 같은 값으로 다시(전부 `[유지]`) → 값 없이(끄기 — `[삭제] … legacy` 가 리로드 뒤) → 다시 값 없이(전부 `[유지]`) 뒤 엔진 스냅숏이 시험 전과 같았다. + +개발 확인 (2026-10-30 2차 리뷰 수정 뒤): + +- **켜기 전 확인(N2)** — 처음 켤 때 `[생성] 자동 차단 예외`(표시)가 A 의 맨 앞에 나오고, A 의 리로드 뒤 relay 가 꺼진 채 `[확인] … 켜기 전 확인(allowRelaying 꺼짐): … 곧바로 거절 (… 0.4ms)` 다음에 C. + 시범이 켜진 엔진에서 relay 만 잠시 끄고 잰 RCPT 5번 0.3~0.7ms(켠 채로는 0.4~0.7ms) — relay 와 상관없이 가드의 빠른 550 이다. +- **다시 읽기(N1)** — `x:Domain/set`(allowRelaying false · true) 바로 뒤 `x:Domain/get` 100번 모두 저장한 값과 같았다. +- **waitOnFail 을 바꾼 엔진(N3)** — `waitOnFail` 1s + 가드가 먹는 엔진: `[주의] RCPT 가드가 550 으로 답합니다 …` 뒤 relay 를 그대로 두고 `waitOnFail` 문제로 멈춤(종료 1, "켜진 채로 두었습니다"). + 거기서 시스템 Sieve 를 끄면 확인이 250 → `[변경] … 껐다` · "allowRelaying 만 닫고 중단합니다"(종료 1). `waitOnFail` 을 되돌리고 다시 돌리자 Sieve 를 되살리고 다시 켰다(종료 0). +- **운영자 갈래(N4)** — 켜기 전 엔진의 `spfFromVerify` 에 기존 서버 IP 갈래를 손으로 넣자 사전 확인에서 멈췄고(종료 1) 엔진이 그대로였다. + 켜진 엔진의 `dmarcVerify` 에 setup 과 같은 글의 갈래를 하나 더 넣자 "둘 이상"으로 멈췄고(종료 1) 바뀐 것이 없었다. +- 켬 → 같은 값으로 다시(바뀐 줄 없음) → 값 없이(끄기) → 다시 값 없이 뒤, 그리고 위 시험들을 되돌린 뒤 엔진 스냅숏이 시험 전과 같았다. + +### mailcow 쪽 절차 (mailcow 호스트 `/opt/mailcow-dockerized` — 아직 해 보지 않았다) + +mailcow 2026-10(master) 의 설정 파일 · 화면 · 문서로 정리했다. 운영 중인 mailcow 가 더 오래됐으면 화면 이름이 다를 수 있다 — 0 의 명령으로 먼저 대조한다. + +```bash +cd /opt/mailcow-dockerized +alias dcx='docker compose exec postfix-mailcow' +``` + +0. **읽기만 하는 점검** — 로컬로 새는 길이 없는지: + ```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 + dcx cat /opt/postfix/conf/custom_transport.pcre # 비어 있거나 owner@ · 도메인과 무관해야 + dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_virtual_mailbox_maps.cf # 사서함이 받는 사람으로 살아 있다 + docker compose exec acme-mailcow dig +short A mx1.arasystem.it.kr @172.22.1.254 # + docker compose exec acme-mailcow dig +short AAAA mx1.arasystem.it.kr @172.22.1.254 # 비어 있어야 + ``` + 도메인이 백업 MX · "Relay all recipients" 가 아닌지, owner 앞 Sync job 이 없는지(E-Mail → Configuration → Synchronizations)도 본다. +1. **postscreen 예외** — `data/conf/postfix/custom_postscreen_whitelist.cidr` 에 `/32 permit` (재시작 없이 다시 읽는다). 새 IP 가 차단 목록 하나에만 있어도 postscreen 에서 끊길 수 있다. + Forwarding Hosts 에는 넣지 않는다 — 넣으면 그 IP 의 메일은 스팸 검사 · 위조 판정 없이 들어온다. +2. **Fail2ban 예외** — System → Configuration → Options → Fail2ban parameters → "Allowlisted networks/hosts" 에 `` (imapsync 로그인 실패가 쌓여 mx1 IP 가 막히면 SMTP 도 끊긴다). +3. **새 서버 setup** — 위 "env 와 실행". 이때부터 새 서버의 owner@ 가 동료에게 보낼 수 있다(mailcow 는 아직 owner 앞 메일을 자기 사서함에 넣는다). +4. **반대 방향부터 시험** — 새 서버의 owner@(웹메일)로 동료에게 한 통. 실사용자에게 영향이 없는 방향이라 먼저 한다: + ```bash + docker compose logs --since 15m postfix-mailcow | grep -E 'from=|\[\]' + # 좋음: client=mx1.arasystem.it.kr[] … 다음 줄에 relay=dovecot[…]:24 status=sent + # 나쁨: NOQUEUE: reject … Sender address rejected (mailcow 에 없는 봉투 발신자) · milter-reject … 5.7.1 (rspamd 거절) + docker compose logs --since 15m rspamd-mailcow | grep -E 'SPOOFED_UNAUTH|R_SPF_FAIL|R_DKIM_REJECT|DMARC_POLICY_SOFTFAIL' # 없어야 + ``` + 동료가 받은 메일 머리글이 `Authentication-Results … spf=pass dkim=pass (header.s=v1-…) dmarc=pass` 이고 `X-Spamd-Result` 에 `SPOOFED_UNAUTH` 가 없어야 한다. + SPF · DKIM · DMARC 가운데 하나라도 실패하면 mailcow 가 위조로 보고(+50점) **거절**한다 — DMARC 가 `p=none` 이어도 막아 주지 않는다. + DKIM 은 키가 있는데 서명이 맞지 않을 때(`R_DKIM_REJECT`)다 — selector 의 키를 찾지 못하는 것(`R_DKIM_PERMFAIL`)은 0점이라 SPF 정렬로 DMARC 가 통과하면 받는다. + 없는 주소(`colleaguee@`)로 보내면 mailcow 가 550 → 새 서버가 owner 에게 반송한다(루프 없음). +5. **imapsync 1차** — mailcow → 새 서버, owner 만 ([imapsync](#imapsync)). +6. **Transport Map 추가** — System → Configuration → Routing → Transport Maps → "Add transport": + + | 칸 | 값 | + | --- | --- | + | Destination | `owner@arasystem.it.kr` (`owner+태그@` 도 맞는다) | + | Next hop | `[mx1.arasystem.it.kr]:25` — 대괄호는 MX 조회를 끈다. **앞에 `smtp:` 를 쓰지 않는다**(mailcow 가 붙인다) | + | Username / Password | 비움 | + | "Destination is a regular expression to match against MX name" | **끔** | + | Active | 켬 | + + 목록의 **Test** 버튼(보내는 주소 `colleague@arasystem.it.kr`, 받는 주소 `owner@arasystem.it.kr` — PHP 가 next hop 에 직접 시험 메일을 보낸다)으로 새 서버가 mailcow IP 의 사내 도메인 메일을 받는지 본다. 그다음: + ```bash + dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # smtp_via_transport_maps:[mx1.arasystem.it.kr]:25 + dcx postmap -q colleague@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf # (출력 없음) + dcx postfix reload # 바로 반영 (안 해도 30초~1분) + # 실제 발송 — 바깥(Gmail) · SOGo 동료 · owner 가 든 별칭 · owner+test@ 에서 각 한 통 + docker compose logs --since 15m postfix-mailcow | grep -E 'to=' # relay=mx1.arasystem.it.kr[]:25 … status=sent + ``` +7. **마지막 imapsync**(차이분). +8. **owner 의 메일 프로그램을 mx1 로** — IMAP `mx1.arasystem.it.kr:993` · SMTP `:465`, 손으로 설정한다(autodiscover 는 mailcow 를 가리킨다). 휴대폰 ActiveSync 등 옛 프로필은 지운다. +9. **mailcow 의 옛 사서함 접근 줄이기** — 사서함 편집의 Allowed protocols(IMAP · POP3 · SMTP · Sieve · ActiveSync · CalDAV/CardDAV)와 SOGo 접근을 줄이고 앱 비밀번호를 지운 뒤, + 마지막 동기화가 끝나면 Active 를 **"Disallow login (incoming mail is still accepted)"** 로. **지우거나 "Inactive" 로 두지 않는다** — 그러면 owner 앞 메일이 모두 550 이고, + 새 서버에서 owner 가 동료에게 보내는 메일도 `reject_unlisted_sender` 로 거절된다. +10. 며칠 본다 — mailcow Queue Manager(E-Mail → Queue Manager), rspamd 기록, 반송, DMARC 보고서. + +### 지켜볼 것 + +새 서버에서 owner 가 받은 메일의 `X-Spam-Score` · `X-Spam-Result` 를 **세 통** 본다 — 바깥(Gmail) 한 통, 동료(SOGo) 한 통, DMARC 가 엄격한(`p=reject`) 바깥 도메인 한 통. +mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Results: … none`) `DMARC_NA` · `AUTH_NA` 가 각 1점 붙는다. 스팸 문턱은 5점이다. + +- 개발에서는 점수가 늘 높다 — 기존 서버 IP 로 넘긴 동료 흉내 메일이 7.5점(`.test` 도메인 탓 `FROM_INVALID` · `ENV_FROM_INVALID` 각 2점, 시험 글의 제목 인코딩 1점). 운영 값으로 판단한다. + 조사 때 p=reject 바깥 발신(서명 없음)을 넘기자 이 설정으로 3.5점(받은편지함), 검사 예외가 없을 때는 11.0점(스팸함)이었다. +- mailcow 가 8~15점에 붙이는 `X-Spam-Flag: YES` 는 새 서버 규칙(`SPAM_FLAG` +5)이 받아 스팸함으로 간다. owner 의 mailcow Sieve · 부재중 · 전달 규칙은 더 돌지 않는다 — 새 서버에 다시 만든다. +- 동료가 받은 owner 의 메일 — `X-Spamd-Result` 에 `SPOOFED_UNAUTH` · `R_DKIM_REJECT` 가 없어야 한다(위 4). + +### 되돌리기 (시범을 멈추고 mailcow 로) + +1. mailcow — Transport Maps 의 owner@ 행 → Quick actions → **Deactivate**(행을 남긴다) → `dcx postfix reload`. 30초~1분 안에 다시 mailcow 사서함으로 배달된다. + `dcx postmap -q owner@arasystem.it.kr mysql:/opt/postfix/conf/sql/mysql_transport_maps.cf` 가 비어야 한다. +2. 큐에 남은 owner 앞 메일 — `dcx postqueue -p` 로 보고 `dcx postqueue -f` (다시 경로를 받아 로컬로). +3. **시범 동안 새 서버로 들어온 메일 · 보낸 메일을 mailcow 로 되복사** — 반대 방향 imapsync(새 서버 → mailcow) 또는 mailcow 의 Synchronizations 에 mx1 IMAP 을 원본으로. +4. owner 의 mailcow 접근 되살리기 — Active 를 Active 로, Allowed protocols · SOGo · 앱 비밀번호, 메일 프로그램을 mailcow 로. +5. **새 서버 대기열에 동료 앞 메일이 남지 않았는지** 본다 — 관리 화면의 메일 대기열(Queue) 또는 관리 API `x:QueuedMessage/query` · `/get`. + 시범을 끄면 발송 전략이 "사내 도메인 → 로컬"로 돌아가, 아직 mailcow 로 못 간 동료 앞 메일(mailcow 일시 장애 · 지연 — 다시 시도는 3일, 동료 앞 반송은 10번까지)은 + 다음 시도에서 로컬 배달 실패 → owner 에게 반송된다. 남아 있으면 mailcow 가 받을 수 있게 된 뒤 다 나갈 때까지 기다리거나(대기열에서 바로 다시 시도), + 기다릴 수 없으면 그 목록(받는 사람 · 제목)을 적어 두고 끈 뒤 owner 가 mailcow 에서 다시 보낸다. +6. 새 서버 — **Transport Map 을 끈 뒤에** 시범을 끝낸다: 두 값을 비우고 `--end-pilot`(아래). 거꾸로 하면 그사이 owner 가 동료에게 보낸 메일이 `not-found` 로 거절된다. +7. 넣었던 것 정리 — postscreen 예외 줄, Fail2ban 예외. DNS 의 `ip4:` · 새 DKIM TXT 는 남겨도 해가 없다. + +### 시범 끝내기 — 전면 전환 + +[전환 절차](#arasystemitkr-전환-절차-mailcow--stalwart)대로 모두의 계정을 새 서버에 만들고(단계 2) MX 를 mx1 로 바꾼다(단계 6). 그 뒤: + +1. MX 전파(TTL)가 끝나 mailcow 에 새 수신이 없어지면 mailcow 의 Transport Map 행을 지운다(Remove) — 그때까지 mailcow 로 온 owner 앞 메일은 그대로 새 서버로 넘어온다. +2. 새 서버 — `.env.production` 의 두 값을 비우고 `--end-pilot` 으로 실행한다: + ```bash + node --env-file=.env.production mail/scripts/setup.mjs --end-pilot + ``` + `allowRelaying`(꺼진 것을 다시 읽어 확인) → 발송 전략 → RCPT 가드 · 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외 차례로 지우고 리로드한 뒤 `legacy` 경로를 지운다. 다음 실행부터는 `--end-pilot` 없이 `[유지] 시범 공존 — 꺼짐` 이다. + +- 계정을 만든 순간부터 그 주소 앞 사내 메일은 새 서버의 로컬 배달이다 — 계정 만들기 → imapsync → MX 바꾸기를 짧게 이어서 한다. +- 위조 막기(인증 없는 사내 도메인 MAIL FROM 은 mailcow IP 에서만)도 함께 빠진다. 전환 뒤에도 남길지는 정할 일이다(원래 봉투를 그대로 넘기는 바깥 전달 서비스가 막힌다). + +### 주의 + +- **시범 기간에는 동료 계정을 새 서버에 만들지 않는다** — 새 서버에 있는 주소는 늘 로컬 배달이라, 만든 순간부터 owner 가 그 동료에게 보낸 메일이 새 서버에 쌓이고 mailcow 로 가지 않는다(동료는 mailcow 에서 읽는데). + `postmaster@` · `dmarc@` · 공용 주소 · 별칭도 같다. 이주 기간에 미리 만들어야 하면 발송 전략을 "옮긴 사람 목록" 꼴로 바꿔야 한다 — setup 은 지원하지 않는다. +- **DKIM** — 동료 앞 메일은 새 selector 두 개(`v1-ed25519-…` · `v1-rsa-…`)로 서명된다. 키가 다르면 mailcow 가 위조로 거절하고, TXT 가 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다(위 mailcow 절차 4). setup 이 활성 selector 를 알리고, 운영에서는 **켜기 전에** DNS 와 견준다(처음 켤 때 키가 다르거나 TXT 가 확실히 없으면 `allowRelaying` 을 켜지 않고 멈춘다 — **TXT 는 첫 시범 실행 전에 넣고 퍼진 것을 본다**. 리졸버 오류는 `[주의]`). + DNS 를 손으로 관리하는 동안 엔진은 키를 스스로 바꾸지 않는다 — 다만 키를 지우거나 알고리즘을 더하면 새 키를 **DNS 에 넣기 전에** 쓴다(전환 절차 "주의할 레코드" 의 DKIM 자동 교체). +- **SOGo 일정 초대** — mailcow 사용자가 SOGo 에서 owner 를 초대하면 메일을 거치지 않고 owner 의 **옛 SOGo 달력**에 바로 들어간다(알림 메일만 새 서버로 온다 — SOGo 버그 트래커의 관리자 답, 확인하지 않았다). +- **autodiscover** — 그대로 mailcow 를 가리킨다. owner 의 프로그램이 자동 설정을 쓰면 mailcow(새 메일이 없는 옛 함)에 잡힌다 — 손으로 설정한다. +- **크기** — mailcow 와 새 서버 모두 메일 한 통 100MB(104,857,600바이트 — 새 서버는 엔진 기본값)다. 웹메일 첨부는 따로 `MAIL_ATTACHMENT_*` 한도다. +- **IPv6 · SPF** — mx1 에 AAAA 를 두지 않는다. mailcow 가 IPv6 로 붙으면 IPv4 예외가 모두 빗나가 동료 메일이 `550 5.7.1` 이다(mailcow 는 `inet_protocols = all`). + 새 서버 → mailcow 는 IPv4 로만 간다(컨테이너에 IPv6 가 없고 Relay 경로는 IPv4 먼저) — SPF 는 `ip4:` 만 더한다. +- **EHLO 이름** — 새 서버가 mailcow 에 붙을 때의 이름은 엔진의 서버 이름(`defaultHostname` = bootstrap 때의 `MAIL_HOSTNAME` = mx1)이다. + `mail.arasystem.it.kr` 이면 mailcow 가 "loops back to myself" 로 멈춘다 — setup 은 기존 서버 주소가 서버 이름과 같으면 멈춘다. +- **사내 오타 주소** — 새 서버에 없는 사내 주소는 모두 "받음"이라, 웹메일의 즉시 `not-found`(받는 사람별 결과) 대신 mailcow 의 550 → **몇 초 뒤 반송(DSN)이 받은편지함에** 온다. + 제출 기록에도 남지 않는다(대기열에서 빠지면 `250 2.1.5 Queued` 로 돌아간다). +- **사내 목록** — 받는 사람 추천 · 주소록의 "사내"는 새 서버의 계정 목록이라 **시범 사용자 혼자**만 보인다. 동료는 개인 주소록 · 최근 보낸 사람으로만 나온다(시범 동안 백엔드는 바꾸지 않는다). +- **주소 추측 막기가 약하다** — RCPT 가드의 거절(Sieve)은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번이면 끊기 · 자동 차단 셈)을 거치지 않는다(개발: RCPT 한 번에 가드 0.2~1.1ms, 엔진 자체 거절 4,999~5,003ms — setup 은 이 차이로 가드가 걸렸는지 본다). + 시범 중 MX 는 mailcow 라 mx1:25 에 바로 붙는 것은 스캐너뿐이다. 시스템 계정 거절도 같은 성질이다. +- **mailcow 가 멈추면** — 동료 앞 메일은 새 서버 대기열(`local` 일정 — 3일)에서 다시 시도하다 반송되고, owner 앞 메일은 mailcow 큐(4시간 뒤 지연 안내, 5일 뒤 반송)에서 기다린다. +- **DMARC 보고** — 엔진은 자기 도메인(arasystem.it.kr) 메일로는 DMARC · DKIM 실패 보고를 만들지 않는다(소스 [`smtp/src/reporting/dmarc.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/smtp/src/reporting/dmarc.rs) 의 `is_local_report_domain`) — + `rua=dmarc@` 로 보고를 보내 mailcow 의 `reject_unlisted_sender` 에 걸리는 일은 없다. 다른 도메인에 대한 집계 보고(기본 하루 한 번)는 그 도메인의 `rua` 로 새 서버가 직접 보낸다(봉투 `noreply-dmarc@arasystem.it.kr`). + mailcow IP 의 DMARC 검사를 끄므로 mailcow 를 거친 메일로는 보고 거리가 생기지 않는다. +- mailcow 의 공용 주소 · 별칭(`all@` 등)으로 보내면 mailcow 가 펼치고 owner 몫은 새 서버로 되돌아온다(자기 사본 — 확인하지 않았다). + +--- + ## arasystem.it.kr 전환 절차 (mailcow → Stalwart) +**전면 전환 전에 한 사람이 먼저 새 서버를 쓴다** — [파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저)(MX 는 mailcow 그대로, 아래 단계 1 · 3 · 4 의 DNS 만 더한다). +시범을 거친 뒤 아래 표대로 모두를 옮기고, MX 를 바꾼 다음 시범 설정을 끝낸다(`--end-pilot`). + 현재 (DNS: 가비아): | 레코드 | 값 | @@ -466,7 +763,7 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org < | --- | --- | --- | | 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL | | 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` | -| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처) | 관리 화면 계정 목록 | +| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처). **시범 공존 중이면 전면 전환 때 한다** — 만든 순간부터 그 주소 앞 사내 메일이 새 서버에 쌓인다([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "주의") | 관리 화면 계정 목록 | | 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` | | 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` | | 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 | @@ -482,9 +779,15 @@ sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org < 리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다. - **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다. - **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다. -- **DKIM 자동 교체** — 도메인 설정이 `dkimManagement: Automatic`(90일마다 새 selector, 7일 뒤 이전 키 은퇴)이고 DNS 는 수동(가비아)이다. - 교체된 키의 TXT 가 DNS 에 없으면 서명 검증이 실패하므로, **운영 전에 관리 화면에서 교체 방식을 확인**하고 - 교체 때마다 새 selector 를 등록하거나 자동 교체를 끈다. (교체 시점의 동작은 확인하지 않았다) +- **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만, + **0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는 + 활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 + (소스 [`services/src/task_manager/dkim.rs`](https://github.com/stalwartlabs/stalwart/blob/v0.16.27/crates/services/src/task_manager/dkim.rs) — 일정은 DNS 게시가 있을 때만 잡는다, + 엔진 시험 `tests/src/automation/dkim.rs` 의 "manual DNS" — 교체도 다시 시도도 하지 않는다. 개발 엔진의 두 키도 일정이 없다 — 2026-10-29). + - 주의 — **알고리즘에 활성 키가 없으면** 엔진이 새 키를 만들어 **DNS 에 게시하기 전에 곧바로 서명에 쓴다**(DNS 수동일 때). 키를 지우거나, 알고리즘(`algorithms`)을 더하거나, + `dkimManagement` 를 다른 값에서 `Automatic` 으로 바꿀 때가 그렇다 — 그런 일을 하면 새 selector 의 TXT 를 바로 넣는다. + - 자동 DNS(`Automatic`)라면 새 키를 먼저 게시하고 전파를 확인한 뒤에 쓴다(실패하면 `pending` 으로 두고 옛 키로 계속 서명). + - 키를 손으로 바꾸는 방법은 아직 확인하지 않았다 — 엔진은 활성(`active`) 키를 모두 서명에 쓴다. 교체가 필요해지면 개발 엔진에서 먼저 시험한다. ### imapsync @@ -658,6 +961,24 @@ PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) | 사내 목록(`GET /api/directory-entries`)이 `503 MAIL_001` | setup 출력의 요약 `사내 목록` 줄을 본다 — 준비되지 않았으면 위 줄들. 준비됐는데도 그러면 backend 의 `MAIL_DIRECTORY_LOCAL_PART` 가 setup 과 같은지 보고 `up -d backend` | | `[주의] RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다` | 메일을 받지 않는 시스템 계정은 지금처럼 받아 놓고 반송한다. 그 식에 시스템 계정일 때 `arasystem-system-recipients` 를 고르는 갈래를 합치거나, 그 설정이 필요 없으면 기본값(`{"match":{},"else":"false"}`)으로 되돌리고 setup 재실행 ([보안](#보안)) | | `시스템 계정의 RCPT 거절 확인 실패 — …` | setup 이 `ReloadSettings` 까지 했는지, `x:MtaStageRcpt` 의 `script` 식과 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive: true`)를 관리 API 로 본다. 확인은 메일 컨테이너 안의 `curl` 로 RCPT 까지만 보낸다 | +| `시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` · `… allowRelaying 만 닫고 중단합니다` | 아무것도 바뀌지 않았다 — 뒤의 머리글이면 가드 없이 열려 있던 relay 만 닫았다(위에 `[변경] … allowRelaying 을 껐다`, 엔진에 없는 사내 주소는 다시 550). 아래 줄의 항목을 고친다 — 기존 서버에 닿지 않으면 이 호스트의 바깥 25 · 기존 서버의 STARTTLS · 인증서 이름, 자기 자신으로 풀리면 `MAIL_PILOT_LEGACY_HOST`, 기존 서버 IP 의 사람이 만든 자동 차단 예외 · 기존 서버 IP 로 된 운영자 갈래(아래 줄)는 지운 뒤 다시, `waitOnFail` 은 기본값(5s)으로, DKIM 은 아래 줄 ([파일럿 공존](#파일럿-공존-mailcow-와-함께--한-사람-먼저) "setup 이 하는 일"). 이미 켜진 시범이면 "시범은 켜진 채로 두었습니다" — 고친 뒤 같은 명령으로 다시 실행한다 | +| `MAIL_PILOT_LEGACY_HOST 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다` | 운영의 env 파일에서 시범 값이 빠졌다. 시범을 이어 가려면 두 값을 다시 넣고, 끝내려면(전면 전환 — MX 를 옮긴 뒤) 값을 비운 채 `--end-pilot` 으로 실행한다. 아무것도 바뀌지 않았다 | +| `운영의 시범 공존 값(…)은 셸이 아니라 .env.production 에 둡니다` | 셸에 `MAIL_PILOT_LEGACY_*` 가 남아 있다 — 파일에 적고 `unset` 뒤 다시 | +| `도메인 … 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어 …` | 시범을 켠 적이 없는 엔진에서 누가 relay 만 켜 두었다 — 가드 없이 열린 relay 라 엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다. setup 은 남의 설정이라 끄지 않는다(요약 `시범 설정 없음 · allowRelaying 켜짐`, 종료 1). 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용을 끈다 | +| `도메인 … 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(legacy)가 없습니다` | 누가 켰는지 알 수 없어 끄지 않았다 — 관리 화면에서 `legacy` 경로만 지웠을 수도 있다. RCPT 가드 · 위조 막기 · 예외 · 발송 전략은 **남겼다**(가드 없는 relay 를 만들지 않는다 — 요약은 `시범 공존 끝내지 못함`). 끝낼 것이면 관리 API `x:Domain/set` 으로 `allowRelaying: false` 뒤 다시 실행하면 나머지를 뺀다. 이어 갈 것이면 시범 값을 넣고(운영은 env 파일) 실행하면 경로를 다시 만든다 | +| `RCPT 가드가 답하지 않아(…) 시범 공존의 allowRelaying 을 켜지 않고 중단합니다` | 켜기 전 확인(A 의 리로드 뒤 · relay 꺼진 채)에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — relay 는 열지 않았다(엔진에 없는 사내 주소는 그대로 550). 가드 · 표시 · 예외는 저장돼 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 | +| `… allowRelaying 을 끄려 했지만 끄지 못했습니다 (…)` 뒤에 `— 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(…)` | 가드가 답하지 않아 relay 를 닫으려 했는데 저장이 거절되었거나 · 엔진에 닿지 않았거나 · 다시 읽어도 켜져 있다. setup 이 멈추기 전에 RCPT 가드만 다시 걸었고 지금은 가드가 550 으로 거절한다 — relay 는 켜진 채다. 관리 화면에서 그 도메인의 relay 허용(관리 API `x:Domain/set` 의 `allowRelaying: false`)을 끄고, 꺼진 것을 본 뒤 같은 명령으로 다시 실행한다 | +| `… 끄지 못했습니다 (…)` 뒤에 `RCPT 가드도 다시 걸지 못했습니다 — 지금 가드 없이 열려 있습니다` · `… 열려 있을 수 있습니다` | relay 를 닫지 못했고 가드도 다시 걸지 못했다(저장 · 리로드 실패, 다시 걸었는데 확인이 250 이거나 확인하지 못함, 또는 RCPT 단계 식을 다른 설정이 써서 건드리지 않음) — **지금 열려 있다(있을 수 있다).** 관리 화면에서 relay 허용을 바로 끄고, 꺼진 것을 본 뒤 다시 실행한다. 켜기 전 · 켠 뒤 확인에서 끄지 못한 경우(가드는 이미 걸었다)도 "열려 있을 수 있습니다"로 같다. setup 은 그 밖의 설정을 더 바꾸지 않고 멈췄다 | +| `RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다` | 켠 뒤 확인에서 엔진에 없는 사내 주소가 2초 안에 550 이 아니었다 — `allowRelaying` 을 다시 껐다(엔진에 없는 사내 주소는 다시 550, owner 가 동료에게 보내는 메일도 `not-found`). 가드 · 경로는 남아 있다. setup 이 리로드까지 했는지, `x:MtaStageRcpt` 의 `script` 와 시스템 Sieve 스크립트 `arasystem-system-recipients`(`isActive`)를 관리 API 로 본 뒤 같은 명령으로 다시 실행한다 | +| `[변경] 시범 공존 — RCPT 가드 없이 allowRelaying 이 켜져 있어 → … allowRelaying 을 껐다` · `… RCPT 가드가 답하지 않아(…) → …` | 실행을 시작할 때 relay 가 가드 없이 열려 있었다(누가 RCPT 식을 바꿨거나 시스템 Sieve 스크립트를 껐다) — 먼저 닫았다. 같은 실행이 확인을 마치면 가드를 되살리고 다시 켠다 — 끝이 `[완료]` 면 된 것이다 | +| `시범 공존 확인 실패 — …` (위조 막기 · 인증한 세션) | 설정은 저장됐다. setup 이 리로드까지 했는지, `x:MtaStageMail` 의 `isSenderAllowed` · 도메인의 `allowRelaying` 을 관리 API 로 본다. 고치기 어려우면 값을 비우고(운영은 `--end-pilot`) 끈다 | +| `다른 도메인 … 이 allowRelaying 을 켜 두어 시범 설정(…)을 빼지 않았습니다` | 시범을 끄는 실행인데 시범 도메인 밖의 도메인이 relay 를 켜 두었다 — RCPT 가드가 모든 사내 도메인을 덮으므로 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다. setup 은 그 도메인을 끄지 않는다 — 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행한다 | +| `도메인 … 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다` | 시범 도메인의 relay 를 끄는 저장을 엔진이 받았는데 반영되지 않았다 — 시범 설정을 그대로 두었다. 관리 화면에서 relay 허용을 끈 뒤 다시 실행한다 | +| `운영의 MAIL_PILOT_LEGACY_HOST 포트는 25 만 받습니다` | 587 · 465 는 인증이 있어야 중계되는데 setup 은 발송 경로에 인증을 넣지 않는다 — mailcow 의 25 를 쓴다(`[mail.arasystem.it.kr]:25` 또는 포트 없이) | +| `[주의] 시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시 … 에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다` | 위조 막기 · 검사 예외 · IP 한도 식에 시범 꼴의 갈래가 있는데 시범 표시(자동 차단 예외)에 없는 IP 다 — 운영자가 넣은 것이면 그대로 둔다(기존 서버 IP 와 같은 IP 면 아래 줄처럼 멈춘다). setup 이 넣은 것인데 표시 항목을 지웠다면 관리 화면에서 그 갈래를 지운다 | +| `… 에 기존 서버 IP(…)로 된 갈래가 setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시 … 가 지워진 것입니다` | 켜기 전에 멈췄다(아무것도 바뀌지 않았다). 운영자가 넣은 것이면 — 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 끝낼 때 지워지므로 — 그 갈래를 지우고(IP 한도는 엔진 기본 — 모든 IP — 으로) 다시 실행한다(시범 동안은 setup 의 갈래가 같은 일을 한다). 켜진 시범에서 setup 의 시범 표시(자동 차단 예외)만 지워진 것이면 그 IP 로 같은 설명(`[setup.mjs] 시범 공존 — 기존 서버(mailcow) 중계 IP`)의 자동 차단 예외를 다시 만든 뒤 실행해도 된다 | +| `… 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다` | setup 은 한 칸에 하나만 넣는다 — 나머지는 운영자가 같은 글로 넣은 것이다. 하나만 남기고 지운 뒤 다시 실행한다. 끄는 실행에서는 맨 앞 하나만 빼고 나머지를 남긴다(`[주의] … 맨 앞 하나만 빼고`) | +| `DKIM TXT … 의 공개 키가 엔진의 키와 다릅니다` · `DKIM TXT … 가 DNS 에 없습니다` | 키가 다르면 동료(mailcow) 앞 메일이 위조로 거절되고, 없으면 DMARC 가 SPF 정렬에만 기대게 되어 반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. `setup.mjs --print-dns` 가 보여 주는 selector TXT 로 DNS 를 고치거나 넣고, 바깥에서 `dig TXT ._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.*` | diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index 73a5bd1..c6dd5ba 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -7,6 +7,8 @@ * node --env-file=.env.development mail/scripts/setup.mjs --print-dns # 권장 DNS 레코드도 출력 * MAIL_RECOVERY_ADMIN=… node --env-file=.env.production mail/scripts/setup.mjs --recovery * # 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다 + * node --env-file=.env.production mail/scripts/setup.mjs --end-pilot + * # 운영의 시범 공존 끝내기(전면 전환) — MAIL_PILOT_LEGACY_HOST · _IPS 를 비우고 함께 준다 * * 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md). * 비밀번호는 어떤 경우에도 출력하지 않는다. @@ -48,13 +50,23 @@ * (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다. * 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다) * 10. [개발] 테스트 직원 계정 + * 시범 공존 (MAIL_PILOT_LEGACY_HOST 가 있을 때 — mail/README.md "파일럿 공존") + * 한 사람만 새 서버를 쓰고 나머지는 기존 서버(mailcow)에 남는 동안: 이 엔진에 없는 사내 주소는 기존 서버로 넘기고, + * 기존 서버 IP 에서 인증 없이 온 메일을 받고, 두 서버 사이에서 메일이 돌지 않게 한다. 켜는 순서가 중요하다 — + * A 막는 것(RCPT 가드 · MAIL FROM 위조 막기 · 기존 서버 IP 예외 · 자동 차단 예외) → 리로드 + * → B 경로(발송 경로 legacy · 발송 전략) → 리로드 → C 도메인 allowRelaying(저장 즉시 먹는다). + * 바꾸기 전에 도메인(catch-all · 하위 주소 · 바깥 디렉터리) · 서버 이름 · 기존 서버에 닿는지 · 자기 자신이 아닌지를 본다. + * 값을 비우면 거꾸로(allowRelaying 먼저) 지운다 — 운영은 --end-pilot 을 함께 줘야 한다(env 파일에서 값이 빠졌을 때 조용히 끝나지 않게) * * 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행) * — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기 */ import { spawnSync } from 'node:child_process'; +import { randomBytes } from 'node:crypto'; +import { resolveTxt } from 'node:dns/promises'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { request as httpsRequest } from 'node:https'; +import { networkInterfaces } from 'node:os'; import { dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -233,6 +245,12 @@ const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' }; const PLAIN_ADDRESS_PATTERN = /^[a-z0-9._-]+@[a-z0-9.-]+$/; /** RCPT 거절 확인에 쓰는 EHLO 이름 — 예약 도메인 (엔진은 FQDN 이 아닌 EHLO 를 거절한다) */ const RCPT_CHECK_EHLO = 'setup-check.example.net'; +/** + * RCPT 거절 확인의 보낸 사람 — 빈 봉투(MAIL FROM:<>, 반송과 같은 꼴). 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고 + * (예전의 게이트웨이 주소는 사내 도메인이라 걸렸다 — 2026-10-29 개발 엔진), 확인이 어긋나 curl 이 DATA 까지 가더라도 빈 봉투에는 반송이 생기지 않는다. + * curl 8.14 는 빈 값('')을 받지 않아 '<>' 로 준다 — 그대로 MAIL FROM:<> 가 된다 (엔진은 250 으로 받는다, 같은 날 확인) + */ +const RCPT_CHECK_SENDER = '<>'; /** RCPT 거절 확인에서 한 세션에 보내는 RCPT 수 상한 — 엔진은 한 세션의 RCPT 실패를 5번까지만 받는다 (x:MtaStageRcpt 의 maxFailures) */ const RCPT_CHECK_MAX_PROBES = 4; @@ -243,7 +261,7 @@ const DEV_RELAY = { address: 'mailpit', port: 1025, }; -/** 개발 전용 — 사내 도메인은 로컬 배달, 나머지는 전부 Mailpit 으로 */ +/** 개발 전용 — 사내 도메인은 로컬 배달, 나머지는 전부 Mailpit 으로 (= baseOutboundRoute 의 else 가 'mailpit' 인 꼴) */ const DEV_OUTBOUND_ROUTE = { match: { 0: { if: 'is_local_domain(rcpt_domain)', then: "'local'" } }, else: `'${DEV_RELAY.name}'`, @@ -255,6 +273,85 @@ const DEV_USERS = [ { name: 'seojun.park', description: '박서준' }, ]; +/* + * 시범 공존 (10단계 — mail/README.md "파일럿 공존") + * 한 사람(시범 사용자)만 같은 @도메인 주소로 이 엔진을 쓰고, 나머지 직원은 기존 서버(mailcow)에 남는다. MX 는 기존 서버 그대로이고, + * 기존 서버가 시범 사용자 주소만 이 엔진으로 넘긴다(mailcow Transport Map). 이 엔진이 할 일: + * - 시범 사용자가 동료(이 엔진에 없는 사내 주소)에게 보낸 메일을 기존 서버로 넘긴다 — 발송 경로 legacy + 발송 전략 + 도메인 allowRelaying + * - 기존 서버 IP 에서 인증 없이 넘어온 메일을 받는다 — 그 IP 만 SPF · 역방향 DNS · DMARC 검사 · 받는 쪽 한도 · 자동 차단에서 뺀다 + * - 두 서버 사이에서 메일이 돌지 않게 한다 — RCPT 가드(1차) + 발송 전략의 source 조건(2차) + * 바깥 발송은 기존 서버를 거치지 않고 이 엔진이 직접 한다 (발송 전략의 else 는 시범 앞의 값 그대로 — 운영 'mx', 개발 'mailpit'). + * 근거: 2026-10-29 개발 엔진(Stalwart 0.16.27) 조사 — 식 · 변수 · 순서를 모두 저장하고 효과까지 봤다. + */ +const PILOT_HOST_ENV_KEY = 'MAIL_PILOT_LEGACY_HOST'; +const PILOT_IPS_ENV_KEY = 'MAIL_PILOT_LEGACY_IPS'; +const PILOT_ENV_KEYS = [PILOT_HOST_ENV_KEY, PILOT_IPS_ENV_KEY]; +/** 운영에서 시범 공존을 끝낼 때 함께 주는 플래그 — 값이 빠진 env 파일로 돌려 조용히 끝나지 않게 */ +const END_PILOT_FLAG = '--end-pilot'; +const DEFAULT_PILOT_PORT = 25; +/** + * 운영의 기존 서버 포트 — 25 만. 587 · 465 는 AUTH 가 있어야 중계되는데 setup 은 발송 경로에 AUTH 를 넣지 않는다 + * (587 은 EHLO · STARTTLS · NOOP 확인은 통과해도 실제 중계는 인증이 없어 거절되고 반송된다 — 2026-10-30 리뷰 L4) + */ +const PILOT_PRODUCTION_PORT = 25; +const MAX_PORT = 65535; +const PORT_PATTERN = /^[1-9]\d{0,4}$/; +/** 호스트 이름 — 영문 · 숫자 · -, 점으로 나눈 칸마다 63자, 모두 253자 (소문자로 맞춰 검사) */ +const HOSTNAME_PATTERN = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/; +/** 기존 서버로 가는 발송 경로(x:MtaRoute 의 Relay) 이름 — 발송 전략의 route 식이 이 이름을 고른다 */ +const PILOT_ROUTE_NAME = 'legacy'; +const PILOT_ROUTE_DESCRIPTION = `시범 공존 — 새 서버에 없는 사내 주소를 기존 서버(mailcow)로 ${MANAGED_MARK}`; +/** 기존 서버 IP 의 자동 차단 예외 — 막히면 시범 사용자가 받는 메일이 모두 기존 서버 대기열에 묶인다 */ +const PILOT_ALLOWED_IP_REASON = `${MANAGED_MARK} 시범 공존 — 기존 서버(mailcow) 중계 IP`; +/** + * 운영의 기존 서버 주소로 받지 않는 대역 — 사설(RFC 1918) · 루프백 · 링크 로컬 · CGNAT · IETF 예약 · 문서 · 시험 · 멀티캐스트 · 예약. + * 기존 서버는 공인 주소로 붙는다 (IPS 항목 · HOST 의 IPv4 · HOST 이름이 풀린 주소 모두) + */ +const NON_PUBLIC_IPV4_RANGES = [ + '0.0.0.0/8', + '10.0.0.0/8', + '100.64.0.0/10', + '127.0.0.0/8', + '169.254.0.0/16', + '172.16.0.0/12', + '192.0.0.0/24', + '192.0.2.0/24', + '192.168.0.0/16', + '198.18.0.0/15', + '198.51.100.0/24', + '203.0.113.0/24', + '224.0.0.0/4', + '240.0.0.0/4', +]; +/** 기존 서버 IP 한 항목의 가장 넓은 접두 — 이보다 넓으면 그 대역의 다른 서버까지 기존 서버처럼 믿는다 (운영) */ +const PILOT_IPS_WIDEST_PREFIX = 24; +/** 기존 서버에 닿는지 볼 때(메일 컨테이너 안의 curl NOOP) 기다리는 시간 */ +const PILOT_REACH_TIMEOUT_SECONDS = 15; +/** + * RCPT 가드가 답했다고 보는 응답 시간의 문턱 — RCPT 를 보낸 뒤 550 이 이보다 빨리 오면 가드(Sieve 즉시 거절), 늦으면 엔진 자체의 거절(waitOnFail 대기)이다. + * 2026-10-30 개발 엔진(0.16.27): Sieve 거절 0.2~0.3ms, 엔진 자체 거절 4,999~5,003ms(waitOnFail 5s) — 2초면 양쪽 모두에서 넉넉히 멀다 (2026-10-30 리뷰 M1) + */ +const PILOT_GUARD_FAST_REPLY_MS = 2000; +/** 개발 — 인증한 세션 확인(465 SMTPS 제출)에 쓰는 엔진 포트 (compose 내부망, 백엔드 컨테이너에서 mail:465) */ +const DEV_SUBMISSION_PORT = 465; +/** + * RCPT 가드 — 인증 없는 세션에서 이 엔진에 없는 사내 주소는 시스템 계정과 같은 스크립트(SYSTEM_RCPT_SCRIPT)로 550. + * 도메인의 allowRelaying 은 받는 사람 해석기에서 돌아 세션이 인증했는지 모른다 — 켜면 인증 없는 세션(바깥 · 기존 서버)도 모르는 사내 주소를 + * 250 으로 받아 놓고 배달하지 못해 보낸 사람에게 반송한다. 이 가드가 그것을 막고, 기존 서버가 그런 주소를 되돌려 보내도 받지 않는다(루프 1차 막기). + * 식의 rcpt 는 소문자지만 +태그를 떼기 전이고 is_local_address 도 태그를 떼지 않는다 — 있는 계정의 +태그 꼴은 따로 본다(구분자는 엔진 기본 +). + * ⚠️ Sieve 거절은 엔진 자체의 없는 주소 거절(5초 대기 · 세션당 실패 5번 · 자동 차단 셈)을 거치지 않는다 — 주소 추측 막기가 약해진다 (README). + */ +const PILOT_RCPT_GUARD = + "is_empty(authenticated_as) && is_local_domain(rcpt_domain) && !is_local_address(rcpt) && " + + "!(contains(rcpt, '+') && is_local_address(split_once(rcpt, '+')[0] + '@' + rcpt_domain))"; +/** MAIL FROM 위조 막기의 조건 앞부분 — 뒤에 기존 서버 IP 조건이 붙는다. 맞으면 isSenderAllowed 가 false → 550 5.7.1 (빈 봉투 <> 는 걸리지 않는다) */ +const PILOT_SENDER_GUARD_PREFIX = 'is_empty(authenticated_as) && is_local_domain(sender_domain) && !'; +/** 기존 서버 IP 에서 온 세션만 끄는 검사 (x:SenderAuth) — SPF 는 기존 서버를 보고, DMARC 를 남기면 서명 없는 p=reject 발신이 점수를 안고 들어온다 */ +const PILOT_SENDER_AUTH_FIELDS = ['spfEhloVerify', 'spfFromVerify', 'reverseIpVerify', 'dmarcVerify']; +/** 엔진 기본 받는 쪽 한도 가운데 보낸 IP 마다 연결 수 — 기존 서버 IP 만 뺀다 (식에 remote_ip 만 쓰면 연결 단계에서 센다) */ +const IP_THROTTLE = { description: 'Sender IP throttle', key: ['remoteIp'] }; +const IP_THROTTLE_DEFAULT_MATCH = { match: {}, else: 'true' }; + /** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */ const PLACEHOLDER_PATTERN = /change[-_]?me/i; @@ -348,19 +445,102 @@ function readEnvFiles() { * 셸에 APP_ENV=development 가 남은 채 --env-file=.env.production 으로 실행하면 * 운영 서버에 개발용 설정(Mailpit 발송 경로 등)을 넣게 된다 → 값이 어긋나면 중단한다. * 복구 관리자(MAIL_RECOVERY_ADMIN)는 원래 셸에서 일회성으로 주는 값이라 검사하지 않는다. + * exempt — 그 밖에 검사하지 않을 키 (개발의 시범 공존 값 — loadConfig) */ -function findShellOverrides(fromFiles) { +function findShellOverrides(fromFiles, exempt = []) { return [...fromFiles] .filter( ([key, value]) => - key !== RECOVERY_ENV_KEY && WATCHED_KEY.test(key) && process.env[key] !== undefined && process.env[key] !== value, + key !== RECOVERY_ENV_KEY && + !exempt.includes(key) && + WATCHED_KEY.test(key) && + process.env[key] !== undefined && + process.env[key] !== value, ) .map(([key]) => key); } +/** + * 시범 공존 값 가운데 env 파일이 아니라 셸에서 온 것 — 비어 있지 않은 값이 env 파일 값과 다르다(파일에 없는 것 포함). + * 개발은 일부러 받는다(이번 실행에만 켜 보는 시험 — mail/README.md "파일럿 공존"). 운영은 받지 않는다 — 시범 상태는 엔진에 남는 설정이라 + * env 파일(SSOT)만 보고도 알 수 있어야 하고, 그래야 값이 빠진 파일로 돌렸을 때 --end-pilot 없이는 멈춘다. + */ +function pilotKeysFromShell(fromFiles) { + return PILOT_ENV_KEYS.filter((key) => { + const value = process.env[key]?.trim(); + return Boolean(value) && fromFiles.get(key)?.trim() !== value; + }); +} + +/** + * 기존 서버 주소 — host · host:port · [host] · [host]:port (대괄호는 떼어도 같다 — Relay 경로는 MX 를 찾지 않는다). + * 돌려주는 값: { host, port, isIpv4 } 또는 문제를 적은 글 + */ +function parseLegacyHost(text) { + const match = /^\[([^\]]*)\](?::([^:]*))?$/.exec(text) ?? /^([^[\]:]*)(?::([^:]*))?$/.exec(text); + if (!match) return '형식은 host · host:port · [host]:port 입니다 (IPv6 는 쓰지 않는다 — 메일 컨테이너에 IPv6 가 없다)'; + const [, host, portText] = match; + if (portText !== undefined && !(PORT_PATTERN.test(portText) && Number(portText) <= MAX_PORT)) { + return `포트는 1~${MAX_PORT} 의 10진수(앞자리 0 없이)여야 합니다`; + } + const ip = parseIpv4(host); + const isIpv4 = ip !== null; + if (isIpv4 && formatIpv4(ip) !== host) return 'IPv4 주소는 앞자리 0 없이 적습니다 (예: 46.250.248.43)'; + if (!isIpv4 && (!HOSTNAME_PATTERN.test(host) || /^[\d.]+$/.test(host))) { + return '호스트는 이름(영문 · 숫자 · - · .)이나 IPv4 주소여야 합니다'; + } + return { host, port: portText === undefined ? DEFAULT_PILOT_PORT : Number(portText), isIpv4 }; +} + +/** + * 기존 서버 IP 목록 — 콤마로 나눈 IPv4 · CIDR. 같은 값은 한 번만. 돌려주는 값: { ips, problems } + * 운영은 공인 주소만(사설 · 앱 네트워크 대역 · 특수 대역 거부), 한 항목이 /24 보다 넓으면 거부한다. + */ +function parseLegacyIps(text, { isProduction, appNetworkSubnet }) { + const ips = []; + const issues = []; + for (const item of text.split(',').map((value) => value.trim()).filter(Boolean)) { + const cidr = parseCidr(item); + const parts = item.split('/'); + const [ipText, bits] = parts; + if ( + cidr === null || + parts.length > 2 || + formatIpv4(cidr.base) !== ipText || + (bits !== undefined && !/^(?:0|[1-9]\d?)$/.test(bits)) + ) { + issues.push(`${item} — IPv4 주소나 CIDR 이어야 합니다 (앞자리 0 없이, 예: 46.250.248.43 · 46.250.248.0/28)`); + continue; + } + const [start] = cidrRange(cidr); + if (start !== cidr.base) { + issues.push(`${item} — CIDR 은 네트워크 주소로 적습니다 (예: ${formatIpv4(start)}/${cidr.prefix})`); + continue; + } + if (isProduction) { + if (cidr.prefix < PILOT_IPS_WIDEST_PREFIX) { + issues.push(`${item} — /${PILOT_IPS_WIDEST_PREFIX} 보다 넓습니다. 기존 서버가 보내는 주소만 적습니다 (그 대역의 다른 서버까지 기존 서버처럼 믿게 된다)`); + continue; + } + if (parseCidr(appNetworkSubnet) !== null && cidrOverlaps(item, appNetworkSubnet)) { + issues.push(`${item} — 앱 네트워크(APP_NETWORK_SUBNET ${appNetworkSubnet}) 안입니다. 기존 서버의 공인 주소를 적습니다`); + continue; + } + if (NON_PUBLIC_IPV4_RANGES.some((range) => cidrOverlaps(item, range))) { + issues.push(`${item} — 사설 · 특수 대역입니다. 기존 서버가 이 서버에 붙을 때의 공인 주소를 적습니다`); + continue; + } + } + if (!ips.includes(item)) ips.push(item); + } + return { ips, problems: issues }; +} + function loadConfig(argv) { const fromFiles = readEnvFiles(); - const overridden = findShellOverrides(fromFiles); + // 개발의 시범 공존 값은 셸에서 일회성으로 받아도 된다 (pilotKeysFromShell 머리말). APP_ENV 를 셸로 바꿨다면 아래 검사가 먼저 멈춘다 + const devPilotExempt = readEnv('APP_ENV') === 'development' ? PILOT_ENV_KEYS : []; + const overridden = findShellOverrides(fromFiles, devPilotExempt); if (overridden.length > 0) { throw new SetupError(`셸 환경변수가 env 파일 값을 가리고 있습니다: ${overridden.join(', ')}`, { hint: @@ -528,6 +708,57 @@ function loadConfig(argv) { errors.push(`운영의 MAIL_PUBLIC_URL 은 경로 없는 https:// 주소여야 합니다 (현재: ${publicUrl ?? '없음'}, 예: https://${hostname || 'mx1.example.kr'}).`); } + // 시범 공존 — 기존 서버(mailcow)와 함께 (mail/README.md "파일럿 공존"). MAIL_PILOT_LEGACY_HOST 를 비우면 꺼진다 + const endPilot = argv.includes(END_PILOT_FLAG); + const pilotShellKeys = pilotKeysFromShell(fromFiles); + if (isProduction && pilotShellKeys.length > 0) { + errors.push( + `운영의 시범 공존 값(${pilotShellKeys.join(', ')})은 셸이 아니라 .env.production 에 둡니다 — 시범 상태는 엔진에 남는 설정이라 env 파일만 보고도 알 수 있어야 한다. ` + + `파일에 적고 셸에서 지운 뒤 다시 실행하세요 (unset ${pilotShellKeys.join(' ')}).`, + ); + } + const pilotHostRaw = readEnv(PILOT_HOST_ENV_KEY); + const pilotIpsRaw = readEnv(PILOT_IPS_ENV_KEY); + let pilot = null; + if (pilotHostRaw) { + if (endPilot) { + errors.push(`${END_PILOT_FLAG} 는 시범 공존을 끝낼 때 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비우고 함께 줍니다 (지금 ${PILOT_HOST_ENV_KEY} 가 있다).`); + } + const parsed = parseLegacyHost(pilotHostRaw.toLowerCase()); + if (typeof parsed === 'string') { + errors.push(`${PILOT_HOST_ENV_KEY} — ${parsed} (현재: "${pilotHostRaw}").`); + } else { + if (parsed.host === hostname) { + errors.push(`${PILOT_HOST_ENV_KEY}(${parsed.host}) 가 이 엔진의 MAIL_HOSTNAME 과 같습니다 — 기존 서버(mailcow)의 주소를 적습니다 (자기 자신으로 보내면 메일이 돈다).`); + } + if (isProduction && parsed.host === DEV_RELAY.address) { + errors.push(`운영의 ${PILOT_HOST_ENV_KEY} 에 개발용 ${DEV_RELAY.address} 를 쓸 수 없습니다 — 기존 서버(mailcow)의 주소(예: mail.arasystem.it.kr)를 적습니다.`); + } else if (isProduction && !parsed.isIpv4 && !parsed.host.includes('.')) { + errors.push(`운영의 ${PILOT_HOST_ENV_KEY}(${parsed.host})는 점이 든 이름(예: mail.arasystem.it.kr)이나 공인 IPv4 여야 합니다 — 점 없는 이름은 도커 내부 이름으로 풀릴 수 있다.`); + } else if (isProduction && parsed.isIpv4 && NON_PUBLIC_IPV4_RANGES.some((range) => cidrContains(range, parsed.host))) { + errors.push(`운영의 ${PILOT_HOST_ENV_KEY}(${parsed.host})가 사설 · 특수 대역입니다 — 기존 서버의 공인 주소나 이름을 적습니다.`); + } + if (isProduction && parsed.port !== PILOT_PRODUCTION_PORT) { + errors.push( + `운영의 ${PILOT_HOST_ENV_KEY} 포트는 ${PILOT_PRODUCTION_PORT} 만 받습니다 (지금 ${parsed.port}) — 587 · 465 는 AUTH 가 있어야 중계되는데 ` + + 'setup 은 발송 경로에 AUTH 를 넣지 않는다(587 은 확인은 통과해도 실제 중계가 인증이 없어 거절되고 반송된다).', + ); + } + pilot = { ...parsed, ips: [], allowInvalidCerts: parsed.isIpv4 }; + } + const legacyIps = parseLegacyIps(pilotIpsRaw ?? '', { isProduction, appNetworkSubnet }); + for (const problem of legacyIps.problems) errors.push(`${PILOT_IPS_ENV_KEY} — ${problem}`); + if (isProduction && legacyIps.ips.length === 0 && legacyIps.problems.length === 0) { + errors.push( + `운영은 ${PILOT_HOST_ENV_KEY} 와 함께 ${PILOT_IPS_ENV_KEY}(기존 서버가 이 서버에 붙는 공인 IPv4, 예: 46.250.248.43)가 있어야 합니다 — ` + + '그 IP 에서 온 메일만 사내 도메인 보낸 사람 · 검사 예외로 받는다.', + ); + } + if (pilot) pilot.ips = legacyIps.ips; + } else if (pilotIpsRaw) { + errors.push(`${PILOT_IPS_ENV_KEY} 만 있고 ${PILOT_HOST_ENV_KEY} 가 비어 있습니다 — 시범 공존을 켜려면 둘 다, 끄려면 둘 다 비웁니다.`); + } + if (errors.length > 0) { throw new SetupError('환경변수 설정이 올바르지 않습니다.', { detail: errors.map((message) => ` - ${message}`).join('\n'), @@ -563,6 +794,10 @@ function loadConfig(argv) { uploadQuotaFloor, devUserPassword, mailpitUiPort: readEnv('MAILPIT_UI_PORT'), + // 시범 공존 — { host, port, isIpv4, ips, allowInvalidCerts } 또는 null(꺼짐). 개발에서 ips 가 비면 preparePilot 이 백엔드 주소로 채운다 + pilot, + endPilot, + pilotShellKeys, warnings, printDns: argv.includes('--print-dns'), docker: dockerAvailable(), @@ -616,6 +851,16 @@ function cidrWithin(inner, outer) { return aStart >= bStart && aEnd <= bEnd; } +/** 두 대역(또는 주소)이 한 주소라도 겹치는가 */ +function cidrOverlaps(first, second) { + const a = parseCidr(first); + const b = parseCidr(second); + if (a === null || b === null) return false; + const [aStart, aEnd] = cidrRange(a); + const [bStart, bEnd] = cidrRange(b); + return aStart <= bEnd && bStart <= aEnd; +} + /** 앱 네트워크 서브넷으로 쓸 수 없는 이유 — 쓸 수 있으면 null (기준: APP_NETWORK_PREFIX_RANGE 머리말) */ function describeSubnetProblem(text) { const cidr = parseCidr(text); @@ -706,6 +951,23 @@ function statusInsideMail(cfg, url) { return result.status === 0 && Number.isInteger(code) ? code : null; } +/** + * 메일 컨테이너 안에서 이름을 IPv4 로 풀어 본다 (이미지의 getent — 엔진이 쓰는 것과 같은 리졸버 설정). + * IPv4 주소면 그대로. 돌려주는 값: 주소 배열(못 풀면 빈 배열), docker 로 물을 수 없으면 null + */ +function resolveIpv4InsideMail(cfg, name) { + if (parseIpv4(name) !== null) return [name]; + const result = runDocker(['exec', cfg.container, 'getent', 'ahostsv4', name]); + if (result.error || result.status === null) return null; + // getent 는 못 찾으면 2 — 빈 결과로 본다 + if (result.status !== 0) return []; + const found = (result.stdout ?? '') + .split(/\r?\n/) + .map((line) => line.trim().split(/\s+/)[0]) + .filter((ip) => ip && parseIpv4(ip) !== null); + return [...new Set(found)]; +} + // ============================================================ // HTTP · JMAP // ============================================================ @@ -1185,8 +1447,12 @@ async function ensureAllowedIps(client, cfg) { log(`${present.has(address) ? '[유지]' : '[생성]'} 자동 차단 예외 ${address}`); if (!present.has(address)) create[`ip${index}`] = { address, reason: ALLOWED_IP_REASON }; }); - // 이 스크립트가 만든 것 중 MAIL_TRUSTED_NETWORKS 에서 빠진 대역은 지운다 (사람이 만든 항목은 건드리지 않는다) - const stale = entries.filter((entry) => entry.reason?.startsWith(MANAGED_MARK) && !cfg.trustedNetworks.includes(entry.address)); + // 이 스크립트가 만든 것 중 MAIL_TRUSTED_NETWORKS 에서 빠진 대역은 지운다 (사람이 만든 항목은 건드리지 않는다). + // 시범 공존의 기존 서버 IP(PILOT_ALLOWED_IP_REASON)는 ensurePilotInboundTrust 가 맡는다 + const stale = entries.filter( + (entry) => + entry.reason?.startsWith(MANAGED_MARK) && entry.reason !== PILOT_ALLOWED_IP_REASON && !cfg.trustedNetworks.includes(entry.address), + ); for (const entry of stale) log(`[삭제] 자동 차단 예외 ${entry.address} (MAIL_TRUSTED_NETWORKS 에서 빠짐)`); if (Object.keys(create).length === 0 && stale.length === 0) return; @@ -1800,34 +2066,51 @@ async function nonReceivingSystemAddresses(client, cfg, directory) { return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])]; } -/** 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다 */ -function systemRcptScriptExpression(addresses) { +/** + * 시스템 계정 주소 → x:MtaStageRcpt 의 script 식 — 받는 주소가 그 주소이거나 그 +태그 꼴일 때만 SYSTEM_RCPT_SCRIPT 를 고른다. + * guard(시범 공존)면 갈래를 하나 더 둔다 — 인증 없는 세션의 모르는 사내 주소도 같은 스크립트로 (PILOT_RCPT_GUARD). + */ +function systemRcptScriptExpression(addresses, { guard = false } = {}) { const condition = addresses .map((address) => { const at = address.lastIndexOf('@'); return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`; }) .join(' || '); - return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' }; + const script = `'${SYSTEM_RCPT_SCRIPT.name}'`; + const match = { 0: { if: condition, then: script } }; + if (guard) match[1] = { if: PILOT_RCPT_GUARD, then: script }; + return { match, else: 'false' }; } -/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */ +/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때). 시범 공존의 가드 갈래(둘째)가 있어도 된다 */ function isOwnRcptScriptExpression(value) { const branches = Object.values(value?.match ?? {}); - return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false'; + const script = `'${SYSTEM_RCPT_SCRIPT.name}'`; + if (value?.else !== 'false' || branches.length < 1 || branches.length > 2 || branches[0]?.then !== script) return false; + return branches.length === 1 || (branches[1]?.if === PILOT_RCPT_GUARD && branches[1]?.then === script); +} + +/** RCPT 단계 식에 시범 공존의 가드 갈래가 있는가 */ +function hasRcptGuard(value) { + return Object.values(value?.match ?? {}).some((branch) => branch?.if === PILOT_RCPT_GUARD); } /** - * 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true. - * 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고, - * 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다. + * setup 의 RCPT 단계 식(또는 기본값)에 가드 갈래를 더한 식 — 시스템 계정 갈래는 그대로 두고 둘째에 가드를 둔다(이미 있으면 그대로). + * 기본값(식 없음)이면 가드 갈래 하나만 — 다음 평소 실행이 시스템 계정 갈래를 채운다. setup 의 식도 기본값도 아니면 null */ -async function ensureSystemRecipientsRejected(client, addresses) { - const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address)); - if (unsafe.length > 0) { - problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`); - return false; - } +function rcptScriptWithGuard(value) { + const script = `'${SYSTEM_RCPT_SCRIPT.name}'`; + const guardBranch = { if: PILOT_RCPT_GUARD, then: script }; + if (stableJson(value) === stableJson(RCPT_SCRIPT_UNSET)) return { match: { 0: guardBranch }, else: 'false' }; + if (!isOwnRcptScriptExpression(value)) return null; + if (hasRcptGuard(value)) return value; + return { match: { 0: branchesOf(value)[0], 1: guardBranch }, else: 'false' }; +} + +/** 시스템 계정 거절에 쓰는 시스템 Sieve 스크립트(SYSTEM_RCPT_SCRIPT)를 만들거나 맞춘다 — 켜기(isActive)까지. 리로드 뒤에 반영된다 */ +async function ensureSystemRcptSieveScript(client) { const scriptLabel = `시스템 Sieve 스크립트 ${SYSTEM_RCPT_SCRIPT.name} — reject "${SYSTEM_RCPT_REPLY}"`; const scripts = await listAll(client, 'SieveSystemScript', ['name', 'description', 'isActive', 'contents']); const script = scripts.find((item) => item.name === SYSTEM_RCPT_SCRIPT.name); @@ -1835,19 +2118,45 @@ async function ensureSystemRecipientsRejected(client, addresses) { if (!script) { await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } }); log(`[생성] ${scriptLabel}`); - } else { - const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value)); - if (Object.keys(patch).length > 0) { - await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } }); - log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`); - } else { - log(`[유지] ${scriptLabel}`); - } + return; } + const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value)); + if (Object.keys(patch).length > 0) { + await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } }); + log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`); + } else { + log(`[유지] ${scriptLabel}`); + } +} + +/** + * 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true. + * 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고, + * 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다. + * pilot(시범 공존)이면 RCPT 가드 갈래를 함께 두고, 아니면 뺀다 — 리로드 뒤에 반영된다 (켤 때는 allowRelaying 보다 먼저, 끌 때는 나중에 — main). + * 빼기 직전에 allowRelaying 을 엔진에서 다시 읽어, 켜진 도메인이 있으면 가드를 남긴다 — 가드 없는 relay 를 setup 이 만들지 않는다 (2026-10-30 리뷰 H1). + * allowRelaying 이 꺼져 있으면 가드의 응답은 엔진 자체의 없는 주소 거절과 같은 글이라 남아 있어도 해가 없다. + */ +async function ensureSystemRecipientsRejected(client, addresses, pilot = null) { + const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address)); + if (unsafe.length > 0) { + problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`); + return false; + } + await ensureSystemRcptSieveScript(client); - const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`; - const expression = systemRcptScriptExpression(addresses); const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script; + let guard = Boolean(pilot); + let relaying = []; + if (!pilot && hasRcptGuard(current) && isOwnRcptScriptExpression(current)) { + relaying = await relayingDomainNames(client); + guard = relaying.length > 0; + } + const label = + `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)` + + (pilot ? ' · 시범 공존: 인증 없는 세션에서 이 엔진에 없는 사내 주소도 550 (루프 1차 막기)' : '') + + (!pilot && guard ? ` · 시범 공존의 RCPT 가드는 남긴다 — allowRelaying 이 켜져 있다(${relaying.join(', ')} — 꺼진 것을 확인한 뒤에만 뺀다)` : ''); + const expression = systemRcptScriptExpression(addresses, { guard }); if (stableJson(current) === stableJson(expression)) { log(`[유지] ${label}`); return true; @@ -1861,7 +2170,7 @@ async function ensureSystemRecipientsRejected(client, addresses) { return false; } await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } }); - log(`[변경] ${label}`); + log(`[변경] ${label}${!guard && hasRcptGuard(current) ? ' — 시범 공존의 가드 갈래를 뺐다 (allowRelaying 이 꺼진 것을 다시 읽어 확인)' : ''}`); return true; } @@ -1869,29 +2178,41 @@ async function ensureSystemRecipientsRejected(client, addresses) { * 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다. * 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다). * 한도 값(rate)·켜짐(enable)은 건드리지 않는다 — 운영에서 정할 것 (mail/README.md "엔진 한도"). + * pilot(시범 공존)이면 기존 서버 IP 도 뺀다 — 기존 서버가 넘기는 동료 전체의 사내 메일은 보낸 도메인이 모두 같아 한 묶음으로 세어진다 + * (개발에서 같은 IP · 같은 도메인 26번째부터 452 → 뺀 뒤 30/30 통과, 2026-10-29). */ -async function ensureRecipientThrottle(client) { +async function ensureRecipientThrottle(client, pilot = null) { const label = `받는 쪽 한도 "${RECIPIENT_THROTTLE.description}"(같은 보낸 도메인 → 한 받는 사람)`; - const scope = '인증 없는 세션에만 (로그인한 직원의 발송 제외)'; + const scope = + '인증 없는 세션에만 (로그인한 직원의 발송 제외)' + (pilot ? ` · 시범 공존: 기존 서버 IP(${pilot.ips.join(', ')}) 제외` : ''); + const desired = pilot ? { match: {}, else: `is_empty(authenticated_as) && !${legacyIpCondition(pilot.ips)}` } : UNAUTHENTICATED_SESSIONS_ONLY; const throttles = (await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match', 'enable'])).filter( (item) => item.description === RECIPIENT_THROTTLE.description && sameTrueSet(item.key, RECIPIENT_THROTTLE.key), ); if (throttles.length === 0) { warn( - `${label}을 찾지 못해 만들지 않았습니다. 이름을 바꿔 쓰고 있다면 그 한도의 match 가 ${JSON.stringify(UNAUTHENTICATED_SESSIONS_ONLY)} 인지 ` + + `${label}을 찾지 못해 만들지 않았습니다. 이름을 바꿔 쓰고 있다면 그 한도의 match 가 ${JSON.stringify(desired)} 인지 ` + '관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 — 아니면 로그인한 직원의 발송도 그 한도에 걸린다 (mail/README.md "엔진 한도").', ); return; } - const stale = throttles.filter((item) => stableJson(item.match) !== stableJson(UNAUTHENTICATED_SESSIONS_ONLY)); + // 시범이 꺼진 실행 — setup 의 기존 서버 IP 예외(표시로 가린다)는 allowRelaying 이 꺼진 것을 다시 읽어 확인한 뒤에만 뺀다 (2026-10-30 리뷰 H1) + let held = []; + if (!pilot && throttles.some((item) => recipientThrottleIps(item.match))) { + const owned = pilotMarkerSet(await listAll(client, 'AllowedIp', ['address', 'reason'])); + const ours = throttles.filter((item) => isPilotRecipientThrottleMatch(item.match, owned)); + if (ours.length > 0 && (await relayingDomainNames(client)).length > 0) held = ours; + } + const stale = throttles.filter((item) => !held.includes(item) && stableJson(item.match) !== stableJson(desired)); if (stale.length > 0) { await setObjects(client, 'MtaInboundThrottle', { - update: Object.fromEntries(stale.map((item) => [item.id, { match: UNAUTHENTICATED_SESSIONS_ONLY }])), + update: Object.fromEntries(stale.map((item) => [item.id, { match: desired }])), }); } for (const item of throttles) { const disabled = item.enable === false ? ' · 꺼져 있음(enable: false)' : ''; - if (stale.includes(item)) log(`[변경] ${label} — ${scope}${disabled} · 전 match ${JSON.stringify(item.match)}`); + if (held.includes(item)) log(`[유지] ${label} — 시범 공존의 기존 서버 IP 예외를 남긴다(allowRelaying 이 켜져 있다)${disabled}`); + else if (stale.includes(item)) log(`[변경] ${label} — ${scope}${disabled} · 전 match ${JSON.stringify(item.match)}`); else log(`[유지] ${label} — ${scope}${disabled}`); } } @@ -1980,10 +2301,11 @@ async function checkHttpEndpointPolicy(cfg) { } /** - * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null. - * 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다. + * 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄 }. curl 을 부르지 못하면 null. + * MAIL FROM 이 거절되거나 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다. + * sender 가 '<>' 면 빈 봉투(MAIL FROM:<>)다 (RCPT_CHECK_SENDER). */ -function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) { +function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) { const result = spawnSync( 'docker', [ @@ -2007,32 +2329,42 @@ function rcptRepliesInsideMail(cfg, mailIp, sender, recipients) { { input: '', encoding: 'utf8' }, ); if (result.error) return null; - const replies = new Map(); + const replies = { mailFrom: null, rcpt: new Map() }; let asked = null; for (const line of (result.stderr ?? '').split(/\r?\n/)) { + if (/^> MAIL FROM:/.test(line)) { + asked = { mailFrom: true }; + continue; + } const sent = /^> RCPT TO:<([^>]*)>/.exec(line); if (sent) { - asked = sent[1].toLowerCase(); + asked = { rcpt: sent[1].toLowerCase() }; continue; } const reply = /^< (\d{3}[ -].*)$/.exec(line); if (reply && asked) { - replies.set(asked, reply[1]); + if (asked.mailFrom) replies.mailFrom = reply[1]; + else replies.rcpt.set(asked.rcpt, reply[1]); asked = null; } } return replies; } +/** 메일 컨테이너의 앱 네트워크 주소 — docker 를 쓸 수 없거나 모르면 null */ +function mailContainerIp(cfg) { + const networks = cfg.docker ? containerNetworks(cfg.container) : null; + return networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean) ?? null) : null; +} + /** * 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로 - * 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라, - * 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다. + * 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 빈 봉투(RCPT_CHECK_SENDER) — 사내 도메인 밖이라 + * 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 생기지 않는다. * 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다). */ async function checkSystemRecipientsRejected(cfg, addresses) { - const networks = cfg.docker ? containerNetworks(cfg.container) : null; - const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null; + const mailIp = mailContainerIp(cfg); if (!mailIp) { const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.'; if (cfg.isProduction) problems.push(message); @@ -2043,17 +2375,18 @@ async function checkSystemRecipientsRejected(cfg, addresses) { const probes = [...addresses, last.replace('@', '+setup-check@')].slice(0, RCPT_CHECK_MAX_PROBES); let replies = null; for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { - replies = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes); - if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break; + replies = smtpRepliesInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, probes); + if (replies && probes.every((probe) => replies.rcpt.get(probe)?.startsWith('550'))) break; await sleep(POLL_INTERVAL_MS / 2); } - const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550')); + const notRejected = probes.filter((probe) => !replies?.rcpt.get(probe)?.startsWith('550')); if (notRejected.length === 0) { log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`); return; } + const mailFromRejected = replies?.mailFrom && !replies.mailFrom.startsWith('250') ? ` · MAIL FROM:<> → ${replies.mailFrom}` : ''; problems.push( - `시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` + + `시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.rcpt.get(probe) ?? '응답 없음'}`).join(' · ')}${mailFromRejected} (550 이어야 함). ` + 'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").', ); } @@ -2166,7 +2499,12 @@ function checkRecoveryDisabled(cfg) { } } -async function ensureDevRelay(client) { +/** + * 개발 — 사내 도메인 밖 메일을 Mailpit 으로 (발송 경로 mailpit + 발송 전략). 저장만 한다 — main 이 리로드한 뒤 verifyDevRelay 로 본다. + * keepPilotRoute(시범이 켜졌거나, 끄는 실행인데 allowRelaying 이 꺼지지 않아 시범 설정을 남길 때)이고 발송 전략이 이미 시범 꼴(else 'mailpit')이면 그대로 둔다 + * — 시범 경로는 리로드 뒤에 ensurePilotRouting 이 맞춘다. + */ +async function ensureDevRelay(client, keepPilotRoute = false) { const routes = await listAll(client, 'MtaRoute', ['name', 'address', 'port']); const route = routes.find((item) => item.name === DEV_RELAY.name); if (!route) { @@ -2191,6 +2529,7 @@ async function ensureDevRelay(client) { } const before = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0]; + if (keepPilotRoute && stableJson(before?.route) === stableJson(pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else))) return; if (stableJson(before?.route) !== stableJson(DEV_OUTBOUND_ROUTE)) { await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route: DEV_OUTBOUND_ROUTE } } }); log(`[변경] 외부 발송 전략 — 사내 도메인은 로컬, 나머지는 ${DEV_RELAY.name}`); @@ -2200,10 +2539,18 @@ async function ensureDevRelay(client) { // 저장만으로는 반영되지 않는다 — main 이 reloadSettings 뒤에 verifyDevRelay 로 확인한다 } -/** 개발 — 리로드 뒤 외부 발송 전략이 Mailpit 인지. 아니면 테스트 계정을 만들기 전에 멈춘다 */ -async function verifyDevRelay(client) { +/** + * 개발 — 리로드 뒤 외부 발송 전략이 Mailpit 인지(시범 공존이면 시범 꼴의 else 가 Mailpit). 아니면 테스트 계정을 만들기 전에 멈춘다. + * keepPilotRoute(시범 설정을 남기는 끄는 실행)면 시범 꼴 · 기본 꼴 어느 쪽이든 else 가 Mailpit 이면 된다 + */ +async function verifyDevRelay(client, pilot = null, keepPilotRoute = false) { const after = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0]; - if (stableJson(after?.route) !== stableJson(DEV_OUTBOUND_ROUTE)) { + const accepted = pilot + ? [pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else)] + : keepPilotRoute + ? [DEV_OUTBOUND_ROUTE, pilotOutboundRoute(DEV_OUTBOUND_ROUTE.else)] + : [DEV_OUTBOUND_ROUTE]; + if (!accepted.some((route) => stableJson(after?.route) === stableJson(route))) { throw new SetupError('외부 발송 전략이 Mailpit 으로 바뀌지 않았습니다. 테스트 계정을 만들지 않고 중단합니다.'); } log('[확인] 설정 리로드 완료 — 사내 도메인 밖 메일은 Mailpit 에만 쌓입니다.'); @@ -2236,15 +2583,1224 @@ async function printDnsZone(client, domain) { log('-----'); } +// ============================================================ +// 시범 공존 — 기존 서버(mailcow)와 함께 (PILOT_HOST_ENV_KEY 머리말 · mail/README.md "파일럿 공존") +// ============================================================ + +/** 기존 서버 IP 하나의 조건 — 연결 단계 변수(remote_ip)라 받는 쪽 한도에 써도 세는 단계가 옮겨지지 않는다 */ +const legacyIpTest = (ip) => `is_ip_in_cidr(remote_ip, '${ip}')`; + +/** 기존 서버 IP 목록 → 식의 조건 — 하나면 그대로, 여럿이면 괄호로 묶은 || (앞에 ! 를 붙여도 뜻이 같게) */ +function legacyIpCondition(ips) { + const tests = ips.map(legacyIpTest); + return tests.length === 1 ? tests[0] : `(${tests.join(' || ')})`; +} + +/** legacyIpCondition 이 만든 글이면 그 IP 목록, 아니면 null — IP 가 바뀌어도 그 꼴을 알아본다 */ +function parseLegacyIpCondition(text) { + if (typeof text !== 'string') return null; + const inner = text.startsWith('(') && text.endsWith(')') ? text.slice(1, -1) : text; + const ips = []; + for (const part of inner.split(' || ')) { + const match = /^is_ip_in_cidr\(remote_ip, '([0-9./]+)'\)$/.exec(part); + if (!match) return null; + ips.push(match[1]); + } + return legacyIpCondition(ips) === text ? ips : null; +} + +/** 식의 갈래 목록 (엔진은 { match: { "0": {if, then}, … }, else } 꼴로 준다) */ +function branchesOf(expression) { + return Object.values(expression?.match ?? {}); +} + +/** 엔진이 주는 식의 꼴({ match, else })인가 — 아니면 갈래를 붙이거나 빼지 않는다 */ +function isExpression(value) { + return Boolean(value) && typeof value === 'object' && typeof value.match === 'object' && 'else' in value; +} + +function withBranches(branches, elseValue) { + return { match: Object.fromEntries(branches.map((branch, index) => [String(index), branch])), else: elseValue }; +} + +/** isOwn 이 setup 의 것이라고 보는 갈래를 모두 뺀 식 — 자리와 상관없이(운영자가 앞에 갈래를 더했어도). 운영자의 갈래 · else 는 그대로 */ +function withoutPilotBranch(expression, isOwn) { + const branches = branchesOf(expression); + const kept = branches.filter((branch) => !isOwn(branch)); + return kept.length === branches.length ? expression : withBranches(kept, expression.else); +} + +/** + * 끄는 실행 — setup 의 갈래를 뺀 식. setup 은 한 칸에 하나만 넣으므로, setup 꼴의 갈래가 둘 이상이면 나머지는 운영자가 같은 글로 넣은 것이다 — + * 그때는 맨 앞 하나만 빼고 나머지는 남긴다(같은 글이라 운영자의 것이 그대로 남는 것과 같다 — 2차 리뷰 N4) + */ +function withoutOwnBranchForTeardown(expression, isOwn, label) { + const own = branchesOf(expression).filter((branch) => isOwn(branch)); + if (own.length <= 1) return withoutPilotBranch(expression, isOwn); + warn(`${label} 에 setup 의 시범 갈래와 같은 글의 갈래가 ${own.length}개 있습니다 — 맨 앞 하나만 빼고 나머지는 운영자 것으로 보고 남깁니다.`); + let dropped = false; + const kept = branchesOf(expression).filter((branch) => { + if (dropped || !isOwn(branch)) return true; + dropped = true; + return false; + }); + return withBranches(kept, expression.else); +} + +/** setup 의 갈래를 맨 앞에 하나 둔 식 — 전에 넣은 setup 의 갈래는(어디에 있든) 뺀다. 운영자의 갈래는 갈아 끼우지 않는다 */ +function withPilotBranch(expression, branch, isOwn) { + const base = withoutPilotBranch(expression, isOwn); + return withBranches([branch, ...branchesOf(base)], base.else); +} + +/* + * 식에는 설명 칸이 없다 — 시범 갈래의 주인은 setup 이 남긴 표시로 가린다: + * 설명이 PILOT_ALLOWED_IP_REASON 인 자동 차단 예외(x:AllowedIp)의 주소 집합. 갈래의 IP 가 모두 그 집합 안일 때만 setup 의 갈래다. + * 그 항목이 없으면 시범 갈래도 없다 — 꼴이 같아도 운영자가 넣은 갈래는 켤 때 · 끌 때 모두 건드리지 않는다(2026-10-30 리뷰 M2). + * 켤 때는 표시를 먼저 만들고 → 식을 고치고(이 실행을 시작할 때의 표시로 주인을 가린다) → 옛 표시를 지운다. 어디서 멈춰도 다음 실행이 자기 갈래를 알아본다. + */ +const NO_IPS = new Set(); + +/** ips 가 모두 owned(주소 집합) 안인가 — 비었거나 꼴이 아니면(null) false */ +function ipsWithin(ips, owned) { + return Array.isArray(ips) && ips.length > 0 && owned instanceof Set && ips.every((ip) => owned.has(ip)); +} + +/** 위조 막기 갈래의 IP — 그 꼴이 아니면 null */ +function senderGuardBranchIps(branch) { + if (branch?.then !== 'false' || typeof branch.if !== 'string' || !branch.if.startsWith(PILOT_SENDER_GUARD_PREFIX)) return null; + return parseLegacyIpCondition(branch.if.slice(PILOT_SENDER_GUARD_PREFIX.length)); +} + +/** 검사 예외 갈래의 IP — 그 꼴이 아니면 null */ +function trustBranchIps(branch) { + return branch?.then === 'disable' ? parseLegacyIpCondition(branch.if) : null; +} + +const RECIPIENT_THROTTLE_PILOT_PREFIX = 'is_empty(authenticated_as) && !'; + +/** 받는 사람 단위 한도의 시범 조건 IP — 그 꼴이 아니면 null */ +function recipientThrottleIps(match) { + if (branchesOf(match).length > 0 || typeof match?.else !== 'string' || !match.else.startsWith(RECIPIENT_THROTTLE_PILOT_PREFIX)) return null; + return parseLegacyIpCondition(match.else.slice(RECIPIENT_THROTTLE_PILOT_PREFIX.length)); +} + +/** 보낸 IP 한도의 시범 조건 IP — 그 꼴이 아니면 null */ +function ipThrottleIps(match) { + if (branchesOf(match).length > 0 || typeof match?.else !== 'string' || !match.else.startsWith('!')) return null; + return parseLegacyIpCondition(match.else.slice(1)); +} + +/** setup 의 시범 갈래 · 조건인가 — owned 는 표시의 주소 집합(없으면 빈 집합 = 아무것도 setup 의 것이 아니다) */ +const isSenderGuardBranch = (branch, owned = NO_IPS) => ipsWithin(senderGuardBranchIps(branch), owned); +const isTrustBranch = (branch, owned = NO_IPS) => ipsWithin(trustBranchIps(branch), owned); +const isPilotRecipientThrottleMatch = (match, owned = NO_IPS) => ipsWithin(recipientThrottleIps(match), owned); +const isPilotIpThrottleMatch = (match, owned = NO_IPS) => ipsWithin(ipThrottleIps(match), owned); +const isThrottle = (item, spec) => item.description === spec.description && sameTrueSet(item.key, spec.key); + +/** 자동 차단 예외 목록 가운데 setup 의 시범 표시 주소 */ +function pilotMarkerSet(entries) { + return new Set(entries.filter((entry) => entry.reason === PILOT_ALLOWED_IP_REASON).map((entry) => entry.address)); +} + +/** allowRelaying 이 켜진 도메인 — 시범 설정을 빼기 직전에 엔진에서 다시 읽는다 (relay 가 꺼진 것을 확인한 뒤에만 뺀다 — 2026-10-30 리뷰 H1) */ +async function relayingDomainNames(client) { + return (await listAll(client, 'Domain', ['name', 'allowRelaying'])).filter((item) => item.allowRelaying === true).map((item) => item.name); +} + +/** 시범 앞의 발송 전략 — 사내 도메인은 로컬, 나머지는 elseRoute (엔진 기본은 'mx', 개발은 DEV_OUTBOUND_ROUTE 의 'mailpit') */ +function baseOutboundRoute(elseRoute) { + return { match: { 0: { if: 'is_local_domain(rcpt_domain)', then: "'local'" } }, else: elseRoute }; +} + +/** + * 기존 서버(legacy)로 보낼 메일의 source — 허용 목록. 0.16.27 의 값은 authenticated(인증한 SMTP · 웹메일 제출 · 일정 알림) · unauthenticated · dmarc_pass + * (인증 없는 수신) · dsn(반송) · report(보고) · autogenerated(필터 전달 · 부재중 등 Sieve) · unknown(표시가 없는 메일) 이다 (smtp/src/queue/mod.rs). + * 인증 없이 들어온 것과 unknown 은 보내지 않는다 — 닫힌 쪽(2026-10-30 리뷰 L1). 엔진 마이너를 올리면 이 목록을 다시 본다. + */ +const PILOT_LEGACY_SOURCES = ['authenticated', 'autogenerated', 'dsn', 'report']; + +/** + * 시범 공존의 발송 전략(x:MtaOutboundStrategy 의 route) — 받는 사람마다 따로 평가된다. + * 0. 이 엔진의 계정 · 별칭 · 공용 주소 → 로컬 + * 1. 그 밖의 사내 주소 + PILOT_LEGACY_SOURCES 의 메일 → legacy(기존 서버) + * 2. 그 밖의 사내 주소 + 그 밖의 메일(인증 없이 들어온 것 등) → 로컬(배달 실패) — 루프 2차 막기: 기존 서버에서 온 것을 되돌려 보내지 않는다. + * 이 갈래가 없으면 else 로 가는데, 시범 중 MX 는 기존 서버라 운영의 'mx' 도 다시 기존 서버로 간다 + * else 사내 도메인 밖 → elseRoute (시범 앞의 값 그대로 — 바깥 발송은 이 엔진이 직접) + */ +function pilotOutboundRoute(elseRoute) { + const sources = PILOT_LEGACY_SOURCES.map((source) => `source == '${source}'`).join(' || '); + return { + match: { + 0: { if: 'is_local_address(rcpt)', then: "'local'" }, + 1: { if: `is_local_domain(rcpt_domain) && (${sources})`, then: `'${PILOT_ROUTE_NAME}'` }, + 2: { if: 'is_local_domain(rcpt_domain)', then: "'local'" }, + }, + else: elseRoute, + }; +} + +/** route 식이 builder 가 만드는 꼴이면 그 else 값, 아니면 null */ +function routeElseOf(route, builder) { + return typeof route?.else === 'string' && stableJson(route) === stableJson(builder(route.else)) ? route.else : null; +} + +/** + * 엔진에 있는 시범 공존 설정을 읽는다 (바꾸지 않는다). remnants — setup 이 넣는 시범 설정 가운데 지금 남아 있는 것의 이름. + * 발송 경로 · 자동 차단 예외는 설명의 [setup.mjs] 로, RCPT 가드 · 발송 전략은 setup 만 만드는 꼴로, IP 가 든 식은 표시(owned)로 알아본다. + * lookalikes — 시범 꼴이지만 표시가 없어 운영자 것으로 보는 갈래(건드리지 않는다) + */ +async function readPilotState(client, domain) { + const [domainInfo] = ( + await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying', 'catchAllAddress', 'subAddressing', 'directoryId'] }) + ).list; + const routes = await listAll(client, 'MtaRoute', ['name', 'description', 'address', 'port']); + const legacyRoute = routes.find((route) => route.name === PILOT_ROUTE_NAME) ?? null; + const ownRoute = legacyRoute?.description?.includes(MANAGED_MARK) ? legacyRoute : null; + const route = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'], properties: ['route'] })).list[0]?.route; + const rcpt = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script', 'waitOnFail'] })).list[0] ?? {}; + const senderAllowed = (await call(client, 'x:MtaStageMail/get', { ids: ['singleton'], properties: ['isSenderAllowed'] })).list[0] + ?.isSenderAllowed; + const senderAuth = (await call(client, 'x:SenderAuth/get', { ids: ['singleton'], properties: PILOT_SENDER_AUTH_FIELDS })).list[0] ?? {}; + const throttles = await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match']); + const allowedIps = await listAll(client, 'AllowedIp', ['address', 'reason']); + const owned = pilotMarkerSet(allowedIps); + + const remnants = []; + if (domainInfo?.allowRelaying === true && ownRoute) remnants.push('도메인 allowRelaying'); + if (ownRoute) remnants.push(`발송 경로 ${PILOT_ROUTE_NAME}`); + if (routeElseOf(route, pilotOutboundRoute) !== null) remnants.push('발송 전략'); + if (hasRcptGuard(rcpt.script)) remnants.push('RCPT 가드'); + if (branchesOf(senderAllowed).some((branch) => isSenderGuardBranch(branch, owned))) remnants.push('MAIL FROM 위조 막기'); + if (PILOT_SENDER_AUTH_FIELDS.some((field) => branchesOf(senderAuth[field]).some((branch) => isTrustBranch(branch, owned)))) { + remnants.push('SPF · 역방향 DNS · DMARC 예외'); + } + if (throttles.some((item) => isThrottle(item, RECIPIENT_THROTTLE) && isPilotRecipientThrottleMatch(item.match, owned))) { + remnants.push(`받는 쪽 한도 예외(${RECIPIENT_THROTTLE.description})`); + } + if (throttles.some((item) => isThrottle(item, IP_THROTTLE) && isPilotIpThrottleMatch(item.match, owned))) { + remnants.push(`받는 쪽 한도 예외(${IP_THROTTLE.description})`); + } + if (owned.size > 0) remnants.push('자동 차단 예외(기존 서버 IP)'); + + // lookalikeBranches — 시범 꼴이지만 표시가 없는 갈래(운영자 것)와 그 IP. ownDuplicates — 한 칸에 setup 꼴의 갈래가 둘 이상인 칸 + // (setup 은 한 칸에 하나만 넣는다 — 하나는 운영자가 같은 글로 넣은 것이다, 2차 리뷰 N4) + const lookalikeBranches = []; + const ownDuplicates = []; + const scan = (field, branches, ipsOf, isOwn) => { + if (branches.filter((branch) => isOwn(branch, owned)).length > 1) ownDuplicates.push(field); + for (const branch of branches) { + const ips = ipsOf(branch); + if (ips && !isOwn(branch, owned)) lookalikeBranches.push({ field, ips }); + } + }; + scan('x:MtaStageMail 의 isSenderAllowed', branchesOf(senderAllowed), senderGuardBranchIps, isSenderGuardBranch); + for (const field of PILOT_SENDER_AUTH_FIELDS) scan(`x:SenderAuth 의 ${field}`, branchesOf(senderAuth[field]), trustBranchIps, isTrustBranch); + for (const item of throttles.filter((throttle) => isThrottle(throttle, IP_THROTTLE))) { + const ips = ipThrottleIps(item.match); + if (ips && !isPilotIpThrottleMatch(item.match, owned)) lookalikeBranches.push({ field: `받는 쪽 한도 "${IP_THROTTLE.description}"`, ips }); + } + const lookalikes = [...new Set(lookalikeBranches.map((item) => item.field))]; + return { + domainInfo: domainInfo ?? {}, + legacyRoute, + ownRoute, + route, + rcptScript: rcpt.script, + waitOnFail: rcpt.waitOnFail, + allowedIps, + owned, + remnants, + lookalikes, + lookalikeBranches, + ownDuplicates, + }; +} + +/** 식의 기간 값(`5s` · `500ms` · `1m`)을 ms 로 — 갈래가 있거나 꼴이 다르면 null */ +function constantDurationMs(expression) { + if (!isExpression(expression) || branchesOf(expression).length > 0 || typeof expression.else !== 'string') return null; + const match = /^(\d+)(ms|s|m)$/.exec(expression.else.trim()); + if (!match) return null; + return Number(match[1]) * { ms: 1, s: 1000, m: 60_000 }[match[2]]; +} + +/** + * RCPT 가드를 응답 시간으로 가릴 수 있는가 — 엔진 자체의 없는 주소 거절은 x:MtaStageRcpt 의 waitOnFail(기본 5초)을 기다리고, 가드(Sieve)는 곧바로다. + * 그 대기가 문턱의 두 배보다 짧거나 식이 바뀌어 있으면 가를 수 없다 — 문제를 적은 글, 가를 수 있으면 null + */ +function guardTimingProblem(waitOnFail) { + const waitMs = constantDurationMs(waitOnFail); + if (waitMs !== null && waitMs >= PILOT_GUARD_FAST_REPLY_MS * 2) return null; + return ( + `엔진의 RCPT 실패 대기(x:MtaStageRcpt 의 waitOnFail = ${JSON.stringify(waitOnFail)})가 ${PILOT_GUARD_FAST_REPLY_MS * 2}ms 보다 짧거나 식이 바뀌어 있어, ` + + 'RCPT 가드의 즉시 거절과 엔진 자체 거절을 응답 시간으로 가를 수 없습니다 — 기본값(5s)으로 되돌린 뒤 다시 실행하세요.' + ); +} + +/** 시각 글(HH:MM:SS.ffffff) → 초 */ +function traceSeconds(stamp) { + const [hours, minutes, seconds] = stamp.split(':'); + return Number(hours) * 3600 + Number(minutes) * 60 + Number(seconds); +} + +/** + * 인증 없는 RCPT 하나 — 메일 컨테이너 안의 curl(--trace-time)로 자기 네트워크 주소의 25 에. { reply, ms }(RCPT 를 보낸 뒤 응답까지), + * 응답을 읽지 못하면 { reply: null, error }. 받는 사람이 거절되면 DATA 전에 끝난다 — 받아 버리면 빈 봉투라 반송이 생기지 않는다. + */ +function timedRcptInsideMail(cfg, mailIp, sender, recipient) { + const result = spawnSync( + 'docker', + [ + 'exec', + '-i', + cfg.container, + 'curl', + '-sS', + '-v', + '--trace-time', + '--max-time', + '20', + '--url', + `smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`, + '--mail-from', + sender, + '--mail-rcpt', + recipient, + '-T', + '-', + ], + { input: '', encoding: 'utf8' }, + ); + const lines = (result.stderr ?? '').split(/\r?\n/); + const sentAt = lines.findIndex((line) => /^[\d:.]+ > RCPT TO:= 0 ? lines.slice(sentAt + 1).find((line) => /^[\d:.]+ < \d{3}[ -]/.test(line)) : undefined; + if (!replyLine) { + return { reply: null, error: lines.find((line) => line.includes('curl: ('))?.trim() ?? result.error?.message ?? `종료 코드 ${result.status}` }; + } + let ms = (traceSeconds(replyLine.split(' ')[0]) - traceSeconds(lines[sentAt].split(' ')[0])) * 1000; + if (ms < 0) ms += 86_400_000; // 자정을 넘김 + return { reply: replyLine.slice(replyLine.indexOf(' < ') + 3), ms: Math.round(ms * 10) / 10 }; +} + +/** 확인용 주소 — 이 엔진에 없는 사내 주소 (난수라 겹치지 않는다) */ +function pilotProbeAddress(cfg) { + return `setup-check-${randomBytes(4).toString('hex')}@${cfg.domain}`; +} + +/** + * RCPT 가드가 실제로 답하는가 (2026-10-30 리뷰 M1) — 인증 없는 세션에서 엔진에 없는 사내 주소(난수)로 RCPT 를 보내 응답과 걸린 시간을 본다. + * 가드(시스템 Sieve 의 reject)는 곧바로 550 이고, 엔진 자체의 없는 주소 거절은 waitOnFail 을 기다린 뒤 같은 글의 550 이다 — + * 2026-10-30 개발 엔진: Sieve 거절 0.2~0.3ms, 엔진 자체 거절 4,999~5,003ms. 문턱 PILOT_GUARD_FAST_REPLY_MS 로 넉넉히 가른다. + * RCPT 단계 Sieve 는 받는 사람 해석보다 먼저 돌아 allowRelaying 과 상관없이 같은 답이다(켜기 전 확인도 같은 판별 — 2차 리뷰 N2). + * timing 이 false 면(waitOnFail 이 바뀌어 시간으로 가를 수 없다) 550 은 가르지 않고 'unclear' — 250 은 시간과 상관없이 가드가 없다는 뜻이다(N3). + * 돌려주는 값: { status, ok, detail } — status 는 'guard'(빠른 550 — ok) · 'accepted'(250 — 가드 없이 받음) · 'slow'(느린 550 — 엔진 자체의 거절) · + * 'unclear'(550 이지만 가를 수 없음) · 'other'(그 밖의 응답) · 'none'(응답 없음) + */ +async function probePilotGuard(cfg, { timing = true } = {}) { + const mailIp = mailContainerIp(cfg); + if (!mailIp) return { status: 'none', ok: false, detail: 'mail 컨테이너 주소를 몰라 확인하지 못함' }; + let last = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + last = timedRcptInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, pilotProbeAddress(cfg)); + if (last.reply?.startsWith('550') && !timing) { + return { status: 'unclear', ok: false, detail: `${last.reply.trim()} · ${last.ms}ms — waitOnFail 이 바뀌어 가드의 거절인지 시간으로 가를 수 없다` }; + } + if (last.reply?.startsWith('550') && last.ms < PILOT_GUARD_FAST_REPLY_MS) { + return { status: 'guard', ok: true, detail: `${last.reply.trim()} · ${last.ms}ms (메일 컨테이너 ${mailIp}:25)` }; + } + // 받아 버렸으면 기다려도 소용없다 — 곧바로 알린다 + if (last.reply?.startsWith('250')) break; + await sleep(POLL_INTERVAL_MS / 2); + } + if (!last?.reply) return { status: 'none', ok: false, detail: `응답 없음 — ${last?.error}` }; + if (last.reply.startsWith('250')) { + return { status: 'accepted', ok: false, detail: `인증 없는 세션이 엔진에 없는 사내 주소를 받았다(${last.reply.trim()})` }; + } + if (last.reply.startsWith('550')) { + return { status: 'slow', ok: false, detail: `${last.reply.trim()} 이지만 ${last.ms}ms — 가드가 아니라 엔진 자체의 거절이다(문턱 ${PILOT_GUARD_FAST_REPLY_MS}ms)` }; + } + return { status: 'other', ok: false, detail: `${last.reply.trim()} · ${last.ms}ms — 가드의 550 이 아니다` }; +} + +/** + * allowRelaying 을 끄고 엔진에서 다시 읽어 확인한다 (안전한 쪽으로 바꾸는 것이라 [변경] 으로 알린다). + * 돌려주는 값: { closed: true } 또는 { closed: false, detail } — 저장이 거절되거나 · 엔진에 닿지 않거나 · 다시 읽어도 켜져 있으면 false. + * 끄지 못했으면 부르는 쪽이 relayOpenError 로 "지금 가드 없이 열려 있을 수 있다"고 알리고 멈춘다 (2026-10-30 2차 리뷰 N1) + */ +async function closePilotRelay(client, domain, why) { + let after; + try { + await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: false } } }); + after = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying; + } catch (error) { + return { closed: false, detail: error.message }; + } + if (after === false) { + log(`[변경] 시범 공존 — ${why} → 도메인 ${domain.name} 의 allowRelaying 을 껐다(다시 읽어 확인) — 엔진에 없는 사내 주소는 다시 550`); + return { closed: true }; + } + return { closed: false, detail: `저장은 받았지만 다시 읽어도 allowRelaying 이 ${JSON.stringify(after)} 입니다` }; +} + +/** + * 0단계에서 relay 를 끄지 못했을 때 — 멈추기 전에 RCPT 가드만 다시 건다 (2026-10-30 3차 리뷰 R1). + * RCPT 식이 setup 의 것이거나 기본값(식 없음)일 때만 저장하고(운영자의 식이면 건드리지 않는다), 시스템 Sieve 스크립트를 맞춰 켠 뒤 ReloadSettings → probePilotGuard. + * 경로 · 발송 전략 · allowRelaying 등 그 밖의 설정은 바꾸지 않는다. 가드는 relay 가 꺼진 엔진에서도 엔진 자체의 없는 주소 거절과 같은 글이라 걸어 두어 해가 없다. + * timing 이 false 면(waitOnFail 이 바뀜) 시간으로 가르지 않는다 — relay 가 켜진 엔진의 550 은 가드가 거절한 것이다(엔진 자체는 relay 가 켜져 있으면 받는다). + * 돌려주는 값: { state, detail } — state 는 'armed'(지금 550 으로 거절) · 'open'(그래도 받음 — 250) · 'unknown'(확인하지 못함) · 'failed'(저장 · 리로드 실패) · 'foreign'(운영자의 식) + */ +async function rearmPilotGuard(client, cfg, rcptScript, { timing = true } = {}) { + const expression = rcptScriptWithGuard(rcptScript); + if (expression === null) return { state: 'foreign', detail: JSON.stringify(rcptScript) }; + try { + await ensureSystemRcptSieveScript(client); + if (stableJson(expression) !== stableJson(rcptScript)) { + await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } }); + log('[변경] RCPT 단계 — 시범 공존의 RCPT 가드를 다시 걸었다 (allowRelaying 을 끄지 못해 멈추기 전에 — 경로 · 전략 · allowRelaying 은 그대로)'); + } + await reloadSettings(client); + } catch (error) { + return { state: 'failed', detail: error.message }; + } + const guard = await probePilotGuard(cfg, { timing }); + if (guard.ok || guard.status === 'unclear') return { state: 'armed', detail: guard.detail }; + if (guard.status === 'accepted') return { state: 'open', detail: guard.detail }; + return { state: 'unknown', detail: guard.detail }; +} + +/** + * 가드가 답하지 않는데 relay 를 끄지 못했을 때의 멈춤 (2차 리뷰 N1). 그 밖의 설정은 더 바꾸지 않는다. + * rearm(0단계 — 멈추기 전에 가드만 다시 건 결과, 3차 R1)이 있으면 그 결과를 싣는다 — 가드가 답하면 relay 는 켜져 있어도 지금 열린 relay 는 아니다. + */ +function relayOpenError(domain, why, detail, rearm = null) { + const head = `${why} allowRelaying 을 끄려 했지만 끄지 못했습니다 (${detail})`; + const closeIt = `관리 화면에서 도메인 ${domain.name} 의 relay 허용(관리 API x:Domain/set 의 allowRelaying: false)을`; + const after = '꺼진 것을 확인한 뒤 같은 명령으로 다시 실행하세요 (mail/README.md "파일럿 공존").'; + const urgent = { hint: `${closeIt} 바로 끄고, ${after}` }; + const backscatter = '엔진에 없는 사내 주소 앞 메일을 받아 놓고 반송한다'; + switch (rearm?.state) { + case 'armed': + return new SetupError( + `${head} — 대신 RCPT 가드를 다시 걸어 지금은 가드가 답합니다(${rearm.detail}). relay 는 여전히 켜져 있습니다.`, + { hint: `${closeIt} 끄고, ${after} 그동안 인증 없는 세션의 엔진에 없는 사내 주소는 가드가 550 으로 거절한다.` }, + ); + case 'open': + return new SetupError(`${head} — RCPT 가드도 다시 걸지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있습니다(${backscatter}).`, urgent); + case 'foreign': + return new SetupError( + `${head} — RCPT 단계 식(x:MtaStageRcpt 의 script)을 다른 설정이 쓰고 있어(${rearm.detail}) 가드를 다시 걸지 않았습니다 — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, + urgent, + ); + case 'failed': + return new SetupError(`${head} — RCPT 가드도 다시 걸지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, urgent); + case 'unknown': + return new SetupError( + `${head} — RCPT 가드는 다시 걸었지만 답하는지 확인하지 못했습니다(${rearm.detail}) — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, + urgent, + ); + default: + return new SetupError(`${head} — 지금 가드 없이 열려 있을 수 있습니다(${backscatter}).`, urgent); + } +} + +/** + * 기존 서버에 닿는가 — 메일 컨테이너 안의 curl 로 EHLO · NOOP 까지만 (메일은 보내지 않는다). EHLO 이름은 이 엔진이 쓰는 서버 이름. + * 운영은 STARTTLS 를 요구하고(--ssl-reqd) 인증서도 본다 — 주소가 IPv4 면 이름을 맞출 수 없어 인증서는 보지 않는다(발송 경로의 allowInvalidCerts 와 같다). + * 돌려주는 값: { ok: true, summary, greetingName } 또는 { ok: false, detail } + */ +function smtpNoopInsideMail(cfg, pilot, ehloName) { + const args = ['exec', cfg.container, 'curl', '-sS', '-v', '--max-time', String(PILOT_REACH_TIMEOUT_SECONDS)]; + if (cfg.isProduction) { + args.push('--ssl-reqd'); + if (pilot.allowInvalidCerts) args.push('--insecure'); + } + args.push('-X', 'NOOP', `smtp://${pilot.host}:${pilot.port}/${ehloName}`); + const result = runDocker(args); + const lines = (result.stderr ?? '').split(/\r?\n/); + const greeting = lines.find((line) => /^< 220[ -]/.test(line))?.slice(2); + const noopAt = lines.indexOf('> NOOP'); + const noopReply = noopAt >= 0 ? lines.slice(noopAt + 1).find((line) => /^< \d{3}[ -]/.test(line))?.slice(2) : undefined; + const startTls = lines.some((line) => line.startsWith('> STARTTLS')); + if (result.status === 0 && greeting && noopReply?.startsWith('250')) { + const tls = startTls ? ` · STARTTLS${pilot.allowInvalidCerts ? '(인증서 이름 확인 안 함)' : cfg.isProduction ? ' · 인증서 확인' : ''}` : ''; + return { + ok: true, + summary: `인사 "${greeting.slice(0, 80)}" · NOOP ${noopReply.slice(0, 3)}${tls} · EHLO ${ehloName}`, + greetingName: /^220[ -](\S+)/.exec(greeting)?.[1]?.toLowerCase() ?? '', + }; + } + const curlError = lines.find((line) => line.startsWith('curl: ('))?.trim() ?? (result.error ? result.error.message : `종료 코드 ${result.status}`); + return { ok: false, detail: `${curlError}${greeting ? ` (인사 "${greeting.slice(0, 80)}")` : ''}` }; +} + +/** 이 호스트(setup 을 돌리는 곳)의 IPv4 주소 — 운영의 메일 호스트면 공인 주소가 들 수 있다 (자기 자신 판정을 DNS 밖에서도 본다) */ +function hostIpv4Addresses() { + return Object.values(networkInterfaces()) + .flat() + .filter((entry) => entry && entry.family === 'IPv4' && !entry.internal) + .map((entry) => entry.address); +} + +/** + * 시범 공존 — 동료(기존 서버) 앞 메일을 서명하는 DKIM 키를 알리고, 운영은 DNS 와 견준다 (C 전에 — 어긋나면 relay 를 켜지 않는다, 2026-10-30 리뷰 L3). + * 기존 서버(rspamd)는 서명이 DNS 의 키와 맞지 않으면(키는 있는데 다름) 위조로 보고 거절한다. + * 0.16.27 의 자동 교체(x:Domain 의 dkimManagement — rotateAfter 90일 · retireAfter 7일 · deleteAfter 30일)는 엔진이 DNS 를 직접 고칠 때 + * (dnsManagement Automatic + DKIM 게시)만 돈다 — 그때는 새 키를 게시하고 전파를 확인한 뒤에 쓴다. DNS 를 손으로 관리하면(Manual) 활성 키에 교체 일정이 + * 생기지 않고(nextTransitionAt 없음), 키가 없는 알고리즘만 새로 만들어 곧바로 쓴다 — 게시하기 전에 (소스 services/src/task_manager/dkim.rs). + * 운영은 이 호스트의 리졸버로 selector 의 TXT 를 읽어 엔진의 공개 키(publicKey)와 견준다. 개발 도메인(.test)은 DNS 가 없어 하지 않는다. + * TXT 가 확실히 없으면(ENOTFOUND · ENODATA) 처음 켤 때 멈추는 것은 키가 다를 때와 같다 (2026-10-30 2차 리뷰 N8) — 까닭은 다르다(3차 R2 · mailcow 조사 3.2): + * 키가 다르면 서명이 맞지 않아 R_DKIM_REJECT → SPOOFED_UNAUTH 로 거절되고, 키가 없으면 R_DKIM_PERMFAIL(0점)이라 DMARC 가 SPF 정렬에만 기댄다 — + * 반송(빈 봉투 — SPF 는 HELO 이름으로 본다)이나 SPF 가 빗나간 메일(IPv6 · DNS 캐시)이 거절된다. + * 리졸버 오류 · 엔진이 공개 키를 주지 않음은 견줄 수 없어 [주의] 만. + * 돌려주는 값: { mismatched, missing } — 키가 다른(TXT 는 있지만 p= 가 다르거나 없음) · TXT 가 없는 selector 의 TXT 이름 목록 + */ +async function checkPilotDkim(client, cfg, domain) { + const [info] = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['dkimManagement', 'dnsManagement'] })).list; + const signatures = (await listAll(client, 'DkimSignature', ['selector', 'stage', 'nextTransitionAt', 'domainId', 'publicKey'])).filter( + (signature) => signature.domainId === domain.id, + ); + const active = signatures.filter((signature) => signature.stage === 'active'); + if (active.length === 0) { + warn(`도메인 ${domain.name} 에 활성 DKIM 서명 키가 없습니다 — 동료(기존 서버) 앞 메일이 서명 없이 나가 기존 서버의 DMARC 가 SPF 정렬에만 기댄다.`); + return { mismatched: [], missing: [] }; + } + const moving = signatures.filter((signature) => signature.nextTransitionAt || signature.stage !== 'active'); + log( + `[확인] 시범 공존 — DKIM 서명 키(활성) ${active.map((signature) => signature.selector).join(' · ')} — 동료(기존 서버) 앞 메일은 이 selector 로 서명된다, DNS 에 TXT 가 있어야 한다 ` + + `(dkimManagement ${info?.dkimManagement?.['@type'] ?? '?'} · dnsManagement ${info?.dnsManagement?.['@type'] ?? '?'}${moving.length === 0 ? ' · 교체 일정 없음' : ''})`, + ); + if (moving.length > 0) { + warn( + `DKIM 키 교체가 예정되었거나 진행 중입니다: ${moving.map((signature) => `${signature.selector}(${signature.stage}${signature.nextTransitionAt ? ` → ${signature.nextTransitionAt}` : ''})`).join(', ')} — ` + + '새 selector 의 TXT 가 DNS 에 있는지 확인하세요 (mail/README.md "파일럿 공존").', + ); + } + if (!cfg.isProduction) return { mismatched: [], missing: [] }; + const mismatched = []; + const missing = []; + for (const signature of active) { + const name = `${signature.selector}._domainkey.${domain.name}`; + if (typeof signature.publicKey !== 'string' || signature.publicKey.trim() === '') { + warn(`엔진이 ${signature.selector} 의 공개 키를 주지 않아 DNS 의 ${name} 와 견줄 수 없습니다 — setup --print-dns 의 값과 DNS 를 손으로 견주세요.`); + continue; + } + // published: 문자열(TXT 의 p= — 비었거나 p= 가 없으면 '') · null(TXT 가 확실히 없음) · { error }(리졸버 오류 — 견줄 수 없음) + let published; + try { + published = + (await resolveTxt(name)) + .map((chunks) => chunks.join('')) + .map((text) => /(?:^|;)\s*p=([^;]*)/.exec(text)?.[1]?.replace(/\s+/g, '')) + .find(Boolean) ?? ''; + } catch (error) { + published = error.code === 'ENOTFOUND' || error.code === 'ENODATA' ? null : { error: error.code ?? error.message }; + } + if (published === null) { + missing.push(name); + } else if (typeof published === 'object') { + warn(`DKIM TXT ${name} 를 읽지 못했습니다 (${published.error}) — 바깥에서 dig TXT ${name} 로 확인하세요.`); + } else if (published !== signature.publicKey.replace(/\s+/g, '')) { + mismatched.push(name); + } else { + log(`[확인] 시범 공존 — DKIM TXT ${name} 가 엔진의 키와 같다`); + } + } + return { mismatched, missing }; +} + +/** + * 시범 공존을 켜기 전의 확인 — 하나라도 어긋나면 그 밖에는 아무것도 바꾸지 않고 중단한다. + * 다만 가드 없이 relay 가 열려 있으면(엔진에 없는 주소 앞 메일을 받아 놓고 반송하는 상태) 멈추기 전에 먼저 닫는다 (2026-10-30 리뷰 M1) — + * 그때 멈추면 머리글이 "allowRelaying 만 닫고 중단합니다"이고, 닫지 못했으면 다른 확인 없이 "끄지 못했다 — 지금 열려 있을 수 있다"로 멈춘다 (2차 리뷰 N1 · N6). + * waitOnFail 이 바뀌어 시간으로 가를 수 없어도 확인은 보낸다 — 250 이면 닫는다 (N3). + * 이미 켜진 시범(setup 의 경로 + allowRelaying)이 같은 기존 서버에 잠깐 닿지 않거나 이름을 풀지 못하면 [주의] 만 하고 이어 간다 — 다른 정비까지 막지 않는다 (L2). + * 돌려주는 값: 맞출 때 쓰는 pilot { host, port, ips, allowInvalidCerts, condition, elseRoute } + */ +async function preparePilot(client, cfg, domain, state) { + if (!cfg.docker) { + throw new SetupError( + '시범 공존은 docker 를 쓸 수 있어야 합니다 — 기존 서버에 닿는지 · 자기 자신이 아닌지 · 반영됐는지를 메일 컨테이너 안에서 본다. 아무것도 바꾸지 않았습니다.', + ); + } + const { host, port } = cfg.pilot; + const issues = []; + const info = state.domainInfo; + const relayAtStart = info.allowRelaying === true; + const alreadyOn = Boolean(state.ownRoute) && relayAtStart; + const sameTarget = alreadyOn && state.ownRoute.address === host && state.ownRoute.port === port; + + // 0) 가드를 응답 시간으로 가릴 수 있는가 · 가드 없이 relay 가 열려 있지 않은가 — 열려 있으면 무엇보다 먼저 닫는다 + const timingProblem = guardTimingProblem(state.waitOnFail); + if (timingProblem) issues.push(timingProblem); + let closedRelay = false; + if (info.allowRelaying === true) { + let why = null; + if (!hasRcptGuard(state.rcptScript)) { + why = 'RCPT 가드 없이 allowRelaying 이 켜져 있어'; + } else { + // 시간으로 가를 수 없어도(waitOnFail 이 바뀜) 보낸다 — 250 은 시간과 상관없이 가드가 없다는 뜻이다 (2차 리뷰 N3) + const guard = await probePilotGuard(cfg, { timing: !timingProblem }); + if (guard.ok) { + log(`[확인] 시범 공존 — RCPT 가드가 답한다 (${guard.detail})`); + } else if (guard.status === 'unclear') { + warn(`RCPT 가드가 550 으로 답합니다(${guard.detail}) — 지금 열려 있지는 않아 allowRelaying 은 그대로 두고, 아래 waitOnFail 문제로 멈춥니다.`); + } else { + why = `RCPT 가드가 답하지 않아(${guard.detail})`; + } + } + if (why) { + const closed = await closePilotRelay(client, domain, why); + if (!closed.closed) { + // 끄지 못했으면 멈추기 전에 RCPT 가드만 다시 건다 — 경로 · 전략 · C 는 하지 않는다 (3차 리뷰 R1) + throw relayOpenError(domain, why, closed.detail, await rearmPilotGuard(client, cfg, state.rcptScript, { timing: !timingProblem })); + } + info.allowRelaying = false; + closedRelay = true; + } + } + + // 1) 도메인 — catch-all 은 allowRelaying 보다 먼저라 동료 앞 메일이 catch-all 함으로 가고, 가드는 + 하위 주소를 가정하고, 바깥 디렉터리는 쓰지 않는다 + const domainIssues = []; + if (info.catchAllAddress != null) { + domainIssues.push(`도메인 ${domain.name} 에 catch-all(${info.catchAllAddress})이 있습니다 — 동료 앞 메일이 기존 서버가 아니라 catch-all 함으로 간다. 지운 뒤 다시 실행하세요.`); + } + if (stableJson(info.subAddressing) !== stableJson({ '@type': 'Enabled' })) { + domainIssues.push(`도메인 ${domain.name} 의 하위 주소(subAddressing)가 기본(Enabled — 구분자 +)이 아닙니다 (${JSON.stringify(info.subAddressing)}) — RCPT 가드가 + 를 가정한다.`); + } + if (info.directoryId != null) domainIssues.push(`도메인 ${domain.name} 이 바깥 디렉터리(directoryId)를 씁니다 — 시범 공존은 엔진의 계정만 로컬로 본다.`); + if (domainIssues.length === 0) log(`[확인] 시범 공존 — 도메인 ${domain.name}: catch-all 없음 · 하위 주소(+태그) 기본 · 바깥 디렉터리 없음`); + issues.push(...domainIssues); + + // 2) 서버 이름 — 기존 서버로 갈 때의 EHLO 이름이고, 자기 자신으로 보내지 않는지 견준다 + const system = (await call(client, 'x:SystemSettings/get', { ids: ['singleton'], properties: ['defaultHostname'] })).list[0]; + const defaultHostname = (system?.defaultHostname ?? cfg.hostname).toLowerCase(); + const selfNames = [...new Set([defaultHostname, cfg.hostname])]; + if (selfNames.includes(host)) { + issues.push(`기존 서버 주소(${host})가 이 엔진의 서버 이름(${selfNames.join(' · ')})과 같습니다 — 자기 자신으로 보내면 메일이 돈다.`); + } + + // 3) setup 이 고칠 수 있는 꼴인가 — 다른 설정을 덮어쓰지 않는다 + if (state.legacyRoute && !state.ownRoute) { + issues.push( + `발송 경로 이름 ${PILOT_ROUTE_NAME} 을 setup 이 만들지 않은 경로가 쓰고 있습니다 (${state.legacyRoute.address}:${state.legacyRoute.port}) — ` + + '그 경로를 지우거나 이름을 바꾼 뒤 다시 실행하세요.', + ); + } + if (stableJson(state.rcptScript) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(state.rcptScript)) { + issues.push(`RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 RCPT 가드를 넣을 수 없습니다 (${JSON.stringify(state.rcptScript)}).`); + } + let elseRoute = DEV_OUTBOUND_ROUTE.else; + if (cfg.isProduction) { + elseRoute = routeElseOf(state.route, baseOutboundRoute) ?? routeElseOf(state.route, pilotOutboundRoute); + if (elseRoute === null) { + issues.push( + `발송 전략(x:MtaOutboundStrategy 의 route)이 setup 이 아는 꼴(사내 도메인은 로컬, 나머지는 경로 하나)이 아닙니다 (${JSON.stringify(state.route)}) — ` + + '시범 경로를 합치지 않았습니다. 엔진 기본(나머지는 mx)이나 외부 릴레이 하나로 되돌린 뒤 다시 실행하세요.', + ); + } else if (elseRoute === `'${DEV_RELAY.name}'` || elseRoute === `'${PILOT_ROUTE_NAME}'`) { + issues.push(`발송 전략의 바깥 경로가 ${elseRoute} 입니다 — 운영의 바깥 발송은 'mx'(또는 외부 릴레이)여야 한다.`); + } + } + + // 4) 기존 서버 IP — 개발은 비우면 백엔드 컨테이너 주소 (개발의 시험 SMTP 는 그 컨테이너에서 나간다 — 앱 네트워크 전체는 쓰지 않는다) + let { ips } = cfg.pilot; + let ipsFromBackend = false; + if (ips.length === 0) { + const backendIp = containerNetworks(cfg.backendContainer)?.[cfg.networkName]; + if (backendIp) { + ips = [backendIp]; + ipsFromBackend = true; + } else { + issues.push(`${PILOT_IPS_ENV_KEY} 가 비어 백엔드 컨테이너 주소를 쓰려 했지만 ${cfg.backendContainer} 가 앱 네트워크에 없습니다 — 백엔드를 띄우거나 값을 주세요.`); + } + } + const mailIps = Object.values(containerNetworks(cfg.container) ?? {}).filter(Boolean); + const mailInside = mailIps.filter((ip) => ips.some((cidr) => cidrContains(cidr, ip))); + if (mailInside.length > 0) { + issues.push(`기존 서버 IP(${ips.join(', ')})에 메일 컨테이너 주소(${mailInside.join(', ')})가 들어 있습니다 — MAIL FROM 위조 막기를 메일 컨테이너에서 확인할 수 없다.`); + } + // 시범 설정의 주인 표시는 setup 이 만든 자동 차단 예외다 — 같은 주소를 사람이 만든 항목이 쓰고 있으면 표시를 만들 수 없다(주소가 겹치면 엔진이 거절한다) + const foreignAllowed = state.allowedIps.filter((entry) => ips.includes(entry.address) && entry.reason !== PILOT_ALLOWED_IP_REASON); + if (foreignAllowed.length > 0) { + issues.push( + `기존 서버 IP ${foreignAllowed.map((entry) => entry.address).join(', ')} 에 setup 이 만들지 않은 자동 차단 예외가 이미 있습니다 — ` + + '시범 설정의 주인 표시로 setup 의 항목이 있어야 한다(같은 주소의 항목은 둘일 수 없다). 그 항목을 지운 뒤 다시 실행하면 setup 이 같은 주소로 다시 만든다.', + ); + } + // 같은 원칙 — 기존 서버 IP 로만 된 시범 꼴의 운영자 갈래(표시 없음)는 표시를 만든 다음 실행부터 setup 의 갈래와 가를 수 없다(합쳐지고, 끝낼 때 지워진다). + // 한 칸에 setup 꼴의 갈래가 둘 이상이면 하나는 운영자 것이다. 둘 다 켜기 전에 멈춘다 — 켤 때도 끝낼 때도 운영자 갈래를 합치거나 지우지 않는다 (2차 리뷰 N4) + const ownedAfter = new Set([...state.owned, ...ips]); + const clashing = state.lookalikeBranches.filter((item) => ipsWithin(item.ips, ownedAfter)); + if (clashing.length > 0) { + issues.push( + `${[...new Set(clashing.map((item) => item.field))].join(', ')} 에 기존 서버 IP(${[...new Set(clashing.flatMap((item) => item.ips))].join(', ')})로 된 갈래가 ` + + `setup 의 표시 없이 있습니다 — 운영자가 넣은 것이거나, setup 의 시범 표시(자동 차단 예외 — 설명 "${PILOT_ALLOWED_IP_REASON}")가 지워진 것입니다. ` + + '운영자가 넣은 것이면: 그대로 켜면 다음 실행부터 setup 의 갈래와 가를 수 없어 합쳐지고 시범을 끝낼 때 함께 지워지므로, 그 갈래를 지운 뒤' + + '(IP 한도는 엔진 기본 — 모든 IP — 으로 되돌린 뒤) 다시 실행하세요(시범 동안은 setup 의 갈래가 같은 일을 하고, 끝낼 때 뺀다). ' + + '표시가 지워진 것이면: 그 IP 로 같은 설명의 자동 차단 예외를 다시 만든 뒤 실행해도 된다.', + ); + } + if (state.ownDuplicates.length > 0) { + issues.push( + `${state.ownDuplicates.join(', ')} 에 setup 의 시범 갈래와 같은 글의 갈래가 둘 이상 있습니다 — setup 은 한 칸에 하나만 넣으므로 나머지는 운영자가 넣은 것이다. ` + + '하나만 남기고 지운 뒤 다시 실행하세요.', + ); + } + + // 5) 기존 서버가 이 엔진 자신이 아닌가 — 메일 컨테이너 안에서 이름을 풀고(엔진이 쓰는 리졸버), DNS 밖의 주소(이 호스트 · 메일 컨테이너)와도 견준다 + const selfAddresses = new Set([...mailIps, ...hostIpv4Addresses()]); + for (const name of selfNames) for (const ip of resolveIpv4InsideMail(cfg, name) ?? []) selfAddresses.add(ip); + const legacyAddresses = resolveIpv4InsideMail(cfg, host); + let resolvable = false; + if (legacyAddresses === null) { + issues.push('메일 컨테이너 안에서 기존 서버 이름을 풀지 못했습니다 (docker exec … getent 실패).'); + } else if (legacyAddresses.length === 0) { + const message = `기존 서버 이름 ${host} 를 메일 컨테이너 안에서 풀 수 없습니다 (getent ahostsv4) — DNS 를 확인하세요.`; + if (sameTarget) warn(`${message} 이미 켜진 시범이라 이어 간다 — 그동안 동료 앞 메일은 새 서버 대기열에서 기다린다. 풀리면 다시 실행하세요.`); + else issues.push(message); + } else { + resolvable = true; + const loops = legacyAddresses.filter((ip) => selfAddresses.has(ip) || cidrContains('127.0.0.0/8', ip)); + const nonPublic = legacyAddresses.filter((ip) => NON_PUBLIC_IPV4_RANGES.some((range) => cidrContains(range, ip))); + if (loops.length > 0) { + issues.push(`기존 서버 ${host} 가 이 엔진 자신(${loops.join(', ')} — 메일 컨테이너 · 이 호스트 · ${selfNames.join(' · ')})으로 풀립니다 — 자기 자신으로 보내면 메일이 돈다.`); + } else if (cfg.isProduction && nonPublic.length > 0) { + issues.push(`운영의 기존 서버 ${host} 가 사설 · 특수 주소(${nonPublic.join(', ')})로 풀립니다 — 도커 내부 이름이나 잘못된 DNS 일 수 있다. 기존 서버의 공인 이름을 적습니다.`); + } else { + log(`[확인] 시범 공존 — 기존 서버 ${host} → ${legacyAddresses.join(', ')} (메일 컨테이너에서 풀림 — 이 엔진 자신이 아니다)`); + } + if (cfg.isProduction && !legacyAddresses.some((ip) => ips.some((cidr) => cidrContains(cidr, ip)))) { + warn( + `기존 서버 ${host} 의 주소(${legacyAddresses.join(', ')})가 ${PILOT_IPS_ENV_KEY}(${ips.join(', ')})에 없습니다 — 기존 서버가 이 서버로 보낼 때 ` + + '다른 주소를 쓰는 것이 아니라면 넣는다 (빠지면 기존 서버가 넘기는 동료 메일이 550 5.7.1).', + ); + } + } + if (cfg.isProduction) { + const own = [...selfAddresses].filter((ip) => ips.some((cidr) => cidrContains(cidr, ip))); + if (own.length > 0) issues.push(`${PILOT_IPS_ENV_KEY} 에 이 서버 자신의 주소(${own.join(', ')} — 이 호스트 · ${selfNames.join(' · ')})가 들어 있습니다.`); + } + + // 6) 기존 서버에 닿는가 — 앞의 확인이 틀렸으면 기다리지 않는다. 인사의 이름이 이 엔진의 이름이면 DNS 와 상관없이 자기 자신이다 + if (issues.length === 0 && resolvable) { + const reach = smtpNoopInsideMail(cfg, cfg.pilot, defaultHostname); + if (reach.ok && selfNames.includes(reach.greetingName)) { + issues.push(`기존 서버 ${host}:${port} 가 이 엔진의 이름(${reach.greetingName})으로 인사합니다 — 자기 자신으로 보내면 메일이 돈다.`); + } else if (reach.ok) { + log(`[확인] 시범 공존 — 기존 서버 ${host}:${port} 에 닿음 (${reach.summary})`); + } else { + const message = + `기존 서버 ${host}:${port} 에 닿지 않습니다 — ${reach.detail}. 이 호스트에서 바깥 ${port} 로 나가는 연결(클라우드는 25 를 막는 곳이 많다)` + + (cfg.isProduction ? ' · 기존 서버의 STARTTLS · 인증서 이름' : '') + + '을 확인하세요.'; + if (sameTarget) warn(`${message} 이미 켜진 시범이라 이어 간다 — 그동안 동료 앞 메일은 새 서버 대기열에서 기다린다(3일). 고친 뒤 다시 실행하세요.`); + else issues.push(message); + } + } + + // 7) DKIM — 운영은 DNS 의 키와 견준다. 키가 다르거나 TXT 가 확실히 없으면(2차 리뷰 N8) 처음 켤 때는 relay 를 켜지 않는다(이미 켜져 있으면 실패로 보고만) + if (issues.length === 0) { + const dkim = await checkPilotDkim(client, cfg, domain); + const messages = []; + if (dkim.mismatched.length > 0) { + messages.push( + `DKIM TXT ${dkim.mismatched.join(', ')} 의 공개 키가 엔진의 키와 다릅니다 — 기존 서버(rspamd)가 시범 사용자의 메일을 위조로 거절한다(R_DKIM_REJECT → SPOOFED_UNAUTH). ` + + 'setup --print-dns 가 보여 주는 값으로 TXT 를 고치세요.', + ); + } + if (dkim.missing.length > 0) { + messages.push( + `DKIM TXT ${dkim.missing.join(', ')} 가 DNS 에 없습니다 — DKIM 이 검증되지 않아 기존 서버(rspamd)의 DMARC 가 SPF 정렬에만 기대게 되어, ` + + '반송(빈 봉투)이나 SPF 가 빗나간 메일이 거절된다. ' + + 'setup --print-dns 가 보여 주는 값으로 TXT 를 넣고, 퍼진 뒤(바깥에서 dig TXT 로 보이면) 다시 실행하세요.', + ); + } + for (const message of messages) (alreadyOn ? problems : issues).push(message); + } + + if (issues.length > 0) { + let hint; + if (closedRelay) { + hint = + '가드 없이 열려 있던 relay 를 닫았습니다 — 엔진에 없는 사내 주소는 다시 550 입니다(시범 사용자가 동료에게 보내는 메일도 not-found). ' + + '원인을 고친 뒤 같은 명령으로 다시 실행하면 RCPT 가드를 건 뒤에 다시 켠다 (mail/README.md "파일럿 공존").'; + } else if (alreadyOn) { + hint = '시범은 켜진 채로 두었습니다. 원인을 고친 뒤 같은 명령으로 다시 실행하세요 (mail/README.md "파일럿 공존").'; + } else { + hint = `시범을 켜지 않으려면 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비웁니다 (mail/README.md "파일럿 공존").`; + } + throw new SetupError( + closedRelay + ? '시범 공존 사전 확인에 실패해 allowRelaying 만 닫고 중단합니다.' + : '시범 공존 사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.', + { detail: issues.map((message) => ` - ${message}`).join('\n'), hint }, + ); + } + log( + `[확인] 시범 공존 — 기존 서버 IP ${ips.join(', ')}` + + (ipsFromBackend ? ` (${PILOT_IPS_ENV_KEY} 비움 → 개발 기본값: 백엔드 컨테이너 주소 — 개발의 시험 SMTP 는 그 컨테이너에서 나간다)` : ''), + ); + if (cfg.pilot.allowInvalidCerts) { + log(`[확인] 시범 공존 — 기존 서버 주소가 IPv4(${host})라 발송 경로의 인증서 이름 확인을 끈다 (allowInvalidCerts: true — TLS 는 있으면 그대로 쓴다)`); + } + if (relayAtStart && !state.ownRoute) { + warn( + `도메인 ${domain.name} 의 allowRelaying 이 시범 설정 전부터 켜져 있었습니다` + + (closedRelay ? ' — RCPT 가드 없이 열려 있어 위에서 닫았고, 이제 setup 이 맡아 가드를 건 뒤에 다시 켜며 시범을 끝낼 때 끈다.' : ' — 이제 setup 이 맡고, 시범을 끝낼 때 끈다.'), + ); + } + return { host, port, ips, allowInvalidCerts: cfg.pilot.allowInvalidCerts, condition: legacyIpCondition(ips), elseRoute }; +} + +/** + * 시범 공존 값이 비었을 때 — 엔진에 시범 설정이 남았으면 끝낸다 (운영은 --end-pilot 이 있어야 한다 — 없으면 아무것도 바꾸지 않고 멈춘다). + * 끄는 순서: 도메인 allowRelaying 먼저(저장 즉시 먹는다) → **다시 읽어 꺼진 것을 확인** → 발송 전략 → (main: 나머지 · 리로드 → 발송 경로 legacy 지우기). + * relay 가 꺼진 것을 확인하지 못하면(누가 켰는지 모르는 allowRelaying 포함) 아무것도 빼지 않는다 — RCPT 가드 · 위조 막기 · 예외를 남긴다 (2026-10-30 리뷰 H1). + * 돌려주는 값: { relayOff, routeToDestroy, nothingLeft? } — routeToDestroy 는 리로드 뒤에 지울 legacy 경로 (발송 전략을 되돌린 것이 먼저 걸린 다음에, L5), + * nothingLeft 는 시범 설정이 하나도 없을 때(남의 allowRelaying 만 켜져 있으면 relayOff 가 false — 요약이 "시범 설정 없음"이라고 말한다) + */ +async function endPilotIfLeft(client, cfg, domain, state) { + const relayingAtStart = state.domainInfo.allowRelaying === true; + const foreignRelay = relayingAtStart && !state.ownRoute; + if (foreignRelay) { + problems.push( + state.remnants.length === 0 + ? `도메인 ${domain.name} 의 allowRelaying 이 켜져 있습니다 — 시범 설정은 없고(setup 이 켠 것이 아니다) RCPT 가드도 없어, 엔진에 없는 사내 주소 앞 메일을 ` + + '받아 놓고 반송할 수 있습니다. setup 은 남의 설정이라 끄지 않았습니다 — 의도한 것이 아니면 관리 화면에서 그 도메인의 relay 허용' + + '(관리 API x:Domain/set 의 allowRelaying: false)을 끄세요 (mail/README.md "파일럿 공존").' + : `도메인 ${domain.name} 의 allowRelaying 이 켜져 있는데 setup 이 만든 시범 발송 경로(${PILOT_ROUTE_NAME})가 없습니다 — setup 이 켠 것인지 알 수 없어 끄지 않았고, ` + + '그래서 RCPT 가드 · 위조 막기 · 예외도 빼지 않았습니다(있으면 남긴다). 의도한 것이 아니면 관리 API x:Domain/set 으로 allowRelaying 을 false 로 한 뒤 다시 실행하세요 (mail/README.md "파일럿 공존").', + ); + } + if (state.remnants.length === 0) { + log(`[유지] 시범 공존 — 꺼짐 (${PILOT_HOST_ENV_KEY} 비어 있음)`); + // nothingLeft — 시범 설정이 하나도 없다(남의 allowRelaying 만 켜져 있을 수 있다 — 요약이 그렇게 말한다, 2차 리뷰 N6) + return { relayOff: !relayingAtStart, nothingLeft: true, routeToDestroy: null }; + } + if (cfg.isProduction && !cfg.endPilot) { + throw new SetupError( + `${PILOT_HOST_ENV_KEY} 가 비어 있는데 메일 엔진에 시범 공존 설정이 남아 있습니다 (${state.remnants.join(' · ')}) — 아무것도 바꾸지 않고 중단합니다.`, + { + hint: + `시범을 이어 가려면 .env.production 에 ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 다시 넣고 실행하세요. ` + + `끝내려면(전면 전환 — MX 를 새 서버로 옮긴 뒤) 값을 비운 채 ${END_PILOT_FLAG} 를 붙여 실행하세요 (mail/README.md "파일럿 공존").`, + }, + ); + } + if (relayingAtStart && state.ownRoute) { + await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: false } } }); + } + const relaying = await relayingDomainNames(client); + if (relaying.length > 0) { + // 이 도메인을 끄지 못했는가 · 다른 도메인이 켜 두었는가를 가른다 — setup 은 다른 도메인을 끄려 하지 않는다 (2차 리뷰 N6) + const others = relaying.filter((name) => name !== domain.name); + if (relaying.includes(domain.name) && !foreignRelay) { + problems.push( + `도메인 ${domain.name} 의 allowRelaying 을 끄려 했지만 다시 읽어도 켜져 있습니다 — 시범 설정(RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로)을 그대로 두었습니다. ` + + '관리 화면에서 그 도메인의 relay 허용(관리 API x:Domain/set 의 allowRelaying: false)을 끈 뒤 다시 실행하세요.', + ); + } + if (others.length > 0) { + problems.push( + `다른 도메인 ${others.join(', ')} 이 allowRelaying 을 켜 두어 시범 설정(RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로)을 빼지 않았습니다 — ` + + 'RCPT 가드는 모든 사내 도메인을 덮고, setup 은 그 도메인을 끄지 않는다. 의도한 것이 아니면 그 도메인의 relay 허용을 끈 뒤 다시 실행하세요.', + ); + } + log(`[유지] 시범 공존 — allowRelaying 이 켜져 있어(${relaying.join(', ')}) 끝내지 않는다: RCPT 가드 · 위조 막기 · 예외 · 발송 전략 · 경로를 남긴다 (꺼진 것을 확인한 뒤에만 뺀다)`); + return { relayOff: false, routeToDestroy: null }; + } + log(`[변경] 시범 공존 끝 (${cfg.isProduction ? END_PILOT_FLAG : '개발 — 값이 비면 바로 끈다'}) — 남은 것: ${state.remnants.join(' · ')}`); + if (relayingAtStart) log(`[변경] 시범 공존 끝 — 도메인 ${domain.name}: 엔진에 없는 사내 주소는 다시 550 (allowRelaying false — 저장 즉시, 다시 읽어 확인)`); + const pilotElse = routeElseOf(state.route, pilotOutboundRoute); + if (pilotElse !== null) { + await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route: baseOutboundRoute(pilotElse) } } }); + log( + `[변경] 시범 공존 끝 — 발송 전략: 사내 도메인은 로컬, 나머지는 ${pilotElse} (리로드 뒤 반영${state.ownRoute ? ` — 발송 경로 ${PILOT_ROUTE_NAME} 는 그 뒤에 지운다` : ''})`, + ); + } + let routeToDestroy = null; + if (state.ownRoute) { + if (pilotElse === null && JSON.stringify(state.route ?? null).includes(`'${PILOT_ROUTE_NAME}'`)) { + problems.push( + `발송 전략이 setup 의 시범 꼴이 아닌데 ${PILOT_ROUTE_NAME} 경로를 고릅니다 (${JSON.stringify(state.route)}) — 발송 경로 ${PILOT_ROUTE_NAME} 을 지우지 않았습니다. ` + + '발송 전략을 고친 뒤 다시 실행하세요.', + ); + } else { + routeToDestroy = state.ownRoute; + } + } + return { relayOff: true, routeToDestroy }; +} + +/** 끄는 실행 — 리로드로 발송 전략이 걸린 뒤 setup 의 legacy 경로를 지운다 (2026-10-30 리뷰 L5) */ +async function destroyPilotRoute(client, route) { + await setObjects(client, 'MtaRoute', { destroy: [route.id] }); + log(`[삭제] 시범 공존 끝 — 발송 경로 ${PILOT_ROUTE_NAME} (${route.address}:${route.port}, 발송 전략을 되돌린 리로드 뒤)`); +} + +/** + * A 의 맨 앞 — 시범 설정의 주인 표시(설명이 PILOT_ALLOWED_IP_REASON 인 자동 차단 예외)를 기존 서버 IP 마다 만든다 (2차 리뷰 N5). + * RCPT 가드 · 받는 사람 한도 · 위조 막기 · 검사 예외 · IP 한도보다 먼저 두어, 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다 + * (끄는 실행이 relay 가 켜져 있어 남기는 판정도 이 표시로 한다). 옛 IP 의 표시는 식을 모두 고친 뒤 ensurePilotInboundTrust 가 지운다. + * 차단 · 예외 목록은 캐시되므로 만들었으면 ReloadBlockedIps 로 바로 반영한다. + */ +async function ensurePilotMarkers(client, pilot) { + const entries = await listAll(client, 'AllowedIp', ['address', 'reason']); + const create = {}; + pilot.ips.forEach((address, index) => { + if (entries.some((entry) => entry.address === address)) log(`[유지] 자동 차단 예외 ${address} (시범 공존 — 기존 서버)`); + else create[`pilot${index}`] = { address, reason: PILOT_ALLOWED_IP_REASON }; + }); + if (Object.keys(create).length === 0) return; + await setObjects(client, 'AllowedIp', { create }); + for (const entry of Object.values(create)) log(`[생성] 자동 차단 예외 ${entry.address} (시범 공존 — 기존 서버)`); + await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } }); +} + +/** + * A — 기존 서버 IP 에서 온 세션을 믿는 설정. pilot 이면 맞추고, null 이면 setup 의 것만 뺀다 (리로드 뒤 반영 — 켤 때는 allowRelaying 보다 먼저). + * - MAIL FROM 위조 막기: 인증 없는 세션의 사내 도메인 MAIL FROM 은 기존 서버 IP 에서만 (x:MtaStageMail 의 isSenderAllowed 맨 앞 갈래 → 550 5.7.1) + * - SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사: 기존 서버 IP 면 끈다 (x:SenderAuth 네 칸의 맨 앞 갈래). DKIM 검증은 그대로 — + * 기존 서버를 거친 바깥 메일은 이 엔진에서 SPF 가 늘 실패하고(연결 IP = 기존 서버), DMARC 를 남기면 서명 없는 p=reject 발신이 스팸함으로 간다 + * (개발: 11.0점 → 3.5점). DMARC 를 끄면 기존 서버 경유 메일로 실패 · 집계 보고를 만들지도 않는다 + * - 받는 쪽 한도 "Sender IP throttle": 기존 서버 IP 는 세지 않는다 (엔진 기본값이거나 setup 의 것일 때만 — 다른 값이면 [주의]) + * - 자동 차단 예외(x:AllowedIp) 의 옛 표시: 식을 모두 고친 뒤에 지운다 (새 표시는 A 의 맨 앞 ensurePilotMarkers 가 먼저 만든다) + * 주인은 표시(owned)로 가린다 — 표시가 없으면 setup 의 것도 없다(운영자 갈래는 그대로). 기존 서버 IP 로만 된 운영자 갈래가 있으면 켜기 전에 멈추므로(preparePilot — N4) + * 켤 때 이 실행이 만든 표시가 섞여도 가르는 결과가 같다. 끌 때는 relay 가 꺼진 것을 다시 읽어 확인한 뒤에만 뺀다. + */ +async function ensurePilotInboundTrust(client, pilot) { + const entries = await listAll(client, 'AllowedIp', ['address', 'reason']); + const owned = pilotMarkerSet(entries); + if (!pilot) { + // 표시가 없으면 setup 의 것도 없다 — 꼴이 같은 운영자 갈래는 건드리지 않는다 + if (owned.size === 0) return; + const relaying = await relayingDomainNames(client); + if (relaying.length > 0) { + log(`[유지] 시범 공존 — allowRelaying 이 켜져 있어(${relaying.join(', ')}) 위조 막기 · 검사 예외 · 한도 예외 · 자동 차단 예외를 남긴다`); + return; + } + } + const isOwnSender = (branch) => isSenderGuardBranch(branch, owned); + const isOwnTrust = (branch) => isTrustBranch(branch, owned); + const ipsLabel = pilot ? `기존 서버 IP(${pilot.ips.join(', ')})` : ''; + let blockedIpsChanged = false; + + // 1) MAIL FROM 위조 막기 + const senderAllowed = (await call(client, 'x:MtaStageMail/get', { ids: ['singleton'], properties: ['isSenderAllowed'] })).list[0] + ?.isSenderAllowed; + const senderLabel = `시범 공존 — 인증 없는 세션의 사내 도메인 MAIL FROM 은 ${ipsLabel}에서만 (다른 IP 는 550 5.7.1 · 빈 봉투 <> 는 그대로)`; + if (!isExpression(senderAllowed)) { + problems.push(`x:MtaStageMail 의 isSenderAllowed 를 식으로 읽지 못해 시범 공존의 MAIL FROM 위조 막기를 맞추지 않았습니다 (${JSON.stringify(senderAllowed)}).`); + } else { + const senderDesired = pilot + ? withPilotBranch(senderAllowed, { if: `${PILOT_SENDER_GUARD_PREFIX}${pilot.condition}`, then: 'false' }, isOwnSender) + : withoutOwnBranchForTeardown(senderAllowed, isOwnSender, 'x:MtaStageMail 의 isSenderAllowed'); + if (stableJson(senderDesired) !== stableJson(senderAllowed)) { + await setObjects(client, 'MtaStageMail', { update: { singleton: { isSenderAllowed: senderDesired } } }); + log(pilot ? `[변경] ${senderLabel}` : '[변경] 시범 공존 끝 — MAIL FROM 위조 막기 갈래를 뺐다 (x:MtaStageMail 의 isSenderAllowed)'); + } else if (pilot) { + log(`[유지] ${senderLabel}`); + } + } + + // 2) 기존 서버 IP 의 SPF · 역방향 DNS · DMARC 검사 + const senderAuth = (await call(client, 'x:SenderAuth/get', { ids: ['singleton'], properties: PILOT_SENDER_AUTH_FIELDS })).list[0] ?? {}; + const authPatch = {}; + for (const field of PILOT_SENDER_AUTH_FIELDS) { + if (!isExpression(senderAuth[field])) { + problems.push(`x:SenderAuth 의 ${field} 를 식으로 읽지 못해 시범 공존의 검사 예외를 맞추지 않았습니다 (${JSON.stringify(senderAuth[field])}).`); + continue; + } + const desired = pilot + ? withPilotBranch(senderAuth[field], { if: pilot.condition, then: 'disable' }, isOwnTrust) + : withoutOwnBranchForTeardown(senderAuth[field], isOwnTrust, `x:SenderAuth 의 ${field}`); + if (stableJson(desired) !== stableJson(senderAuth[field])) authPatch[field] = desired; + } + const authLabel = `시범 공존 — ${ipsLabel}에서 온 메일은 SPF(EHLO · MAIL FROM) · 역방향 DNS · DMARC 검사를 하지 않는다 (DKIM 검증 · 스팸 판정은 그대로)`; + if (Object.keys(authPatch).length > 0) { + await setObjects(client, 'SenderAuth', { update: { singleton: authPatch } }); + log(pilot ? `[변경] ${authLabel} (${Object.keys(authPatch).join(', ')})` : `[변경] 시범 공존 끝 — 기존 서버 IP 의 검사 예외를 뺐다 (${Object.keys(authPatch).join(', ')})`); + } else if (pilot) { + log(`[유지] ${authLabel}`); + } + + // 3) 받는 쪽 한도 — 보낸 IP 마다 연결 수 + const ipThrottleLabel = `받는 쪽 한도 "${IP_THROTTLE.description}"(보낸 IP 마다 연결 수)`; + const ipThrottles = (await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match'])).filter((item) => isThrottle(item, IP_THROTTLE)); + if (pilot && ipThrottles.length === 0) { + warn( + `${ipThrottleLabel}을 찾지 못해 기존 서버 IP 예외를 넣지 않았습니다 — 이름을 바꿔 쓰고 있다면 그 한도가 기존 서버 IP 를 세지 않는지 ` + + '관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 (mail/README.md "파일럿 공존").', + ); + } + for (const item of ipThrottles) { + const ours = isPilotIpThrottleMatch(item.match, owned); + if (pilot) { + const desired = { match: {}, else: `!${pilot.condition}` }; + if (stableJson(item.match) === stableJson(desired)) { + log(`[유지] ${ipThrottleLabel} — 시범 공존: ${ipsLabel} 는 세지 않는다`); + } else if (ours || stableJson(item.match) === stableJson(IP_THROTTLE_DEFAULT_MATCH)) { + await setObjects(client, 'MtaInboundThrottle', { update: { [item.id]: { match: desired } } }); + log(`[변경] ${ipThrottleLabel} — 시범 공존: ${ipsLabel} 는 세지 않는다 · 전 match ${JSON.stringify(item.match)}`); + } else { + warn( + `${ipThrottleLabel}의 조건을 따로 바꿔 두어(${JSON.stringify(item.match)}) 기존 서버 IP 예외를 넣지 않았습니다 — ` + + '기존 서버가 한꺼번에 많이 넘기면 연결이 끊기고 기존 서버가 나중에 다시 보낸다(늦게 받는다).', + ); + } + } else if (ours) { + await setObjects(client, 'MtaInboundThrottle', { update: { [item.id]: { match: IP_THROTTLE_DEFAULT_MATCH } } }); + log(`[변경] 시범 공존 끝 — ${ipThrottleLabel}: 엔진 기본(모든 IP)으로`); + } + } + + // 4) 옛 표시 지우기 — 식을 모두 고친 뒤에 (켤 때는 IP 가 빠진 것만, 끌 때는 모두) + const wanted = pilot ? pilot.ips : []; + const stale = entries.filter((entry) => entry.reason === PILOT_ALLOWED_IP_REASON && !wanted.includes(entry.address)); + for (const entry of stale) log(`[삭제] 자동 차단 예외 ${entry.address} (시범 공존 — ${pilot ? `${PILOT_IPS_ENV_KEY} 에서 빠짐` : '끝'})`); + if (stale.length > 0) { + await setObjects(client, 'AllowedIp', { destroy: stale.map((entry) => entry.id) }); + blockedIpsChanged = true; + } + // 차단 · 예외 목록은 캐시된다 — 바로 반영되게 다시 읽힌다 + if (blockedIpsChanged) await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } }); +} + +/** B — 기존 서버로 가는 발송 경로(Relay legacy)와 시범 공존의 발송 전략 (A 의 리로드 뒤). 바꿨으면 true — main 이 다시 리로드한다 */ +async function ensurePilotRouting(client, pilot) { + let changed = false; + const routes = await listAll(client, 'MtaRoute', [ + 'name', + 'description', + 'address', + 'port', + 'protocol', + 'implicitTls', + 'allowInvalidCerts', + 'authSecret', + 'authUsername', + ]); + const existing = routes.find((route) => route.name === PILOT_ROUTE_NAME); + const desired = { + description: PILOT_ROUTE_DESCRIPTION, + address: pilot.host, + port: pilot.port, + protocol: 'smtp', + implicitTls: false, + allowInvalidCerts: pilot.allowInvalidCerts, + authSecret: { '@type': 'None' }, + }; + const tls = pilot.allowInvalidCerts ? 'STARTTLS 가 있으면 쓴다 · 인증서 이름 확인 안 함' : 'STARTTLS 가 있으면 쓴다'; + const label = `시범 공존 — 발송 경로 ${PILOT_ROUTE_NAME} → ${pilot.host}:${pilot.port} (${tls})`; + if (!existing) { + await setObjects(client, 'MtaRoute', { create: { legacy: { '@type': 'Relay', name: PILOT_ROUTE_NAME, ...desired } } }); + changed = true; + log(`[생성] ${label}`); + } else if (!existing.description?.includes(MANAGED_MARK)) { + // 사전 확인(preparePilot)이 이미 막는다 — 그사이 누가 바꿨다 + throw new SetupError(`발송 경로 ${PILOT_ROUTE_NAME} 을 setup 이 만들지 않은 경로가 쓰고 있습니다 — 시범 경로를 넣지 않고 중단합니다.`); + } else { + const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => stableJson(existing[key]) !== stableJson(value))); + if (existing.authUsername != null) patch.authUsername = null; + if (Object.keys(patch).length > 0) { + await setObjects(client, 'MtaRoute', { update: { [existing.id]: patch } }); + changed = true; + log(`[변경] ${label} (${Object.keys(patch).join(', ')})`); + } else { + log(`[유지] ${label}`); + } + } + + const route = pilotOutboundRoute(pilot.elseRoute); + const current = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'], properties: ['route'] })).list[0]?.route; + const routeLabel = + `시범 공존 — 발송 전략: 이 엔진의 주소는 로컬 · 그 밖의 사내 주소는 ${PILOT_ROUTE_NAME}(source ${PILOT_LEGACY_SOURCES.join(' · ')} 만) · ` + + `그 밖(인증 없이 들어온 메일 등)은 로컬(루프 2차 막기) · 사내 도메인 밖은 ${pilot.elseRoute}`; + if (stableJson(current) !== stableJson(route)) { + await setObjects(client, 'MtaOutboundStrategy', { update: { singleton: { route } } }); + changed = true; + log(`[변경] ${routeLabel}`); + } else { + log(`[유지] ${routeLabel}`); + } + return changed; +} + +/** C — 도메인의 allowRelaying (엔진에 없는 사내 주소도 받는다). 저장 즉시 먹으므로 A · B 의 리로드 뒤에 둔다 */ +async function ensurePilotRelaying(client, domain) { + const current = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying === true; + const label = `시범 공존 — 도메인 ${domain.name}: 이 엔진에 없는 사내 주소도 받는다 (allowRelaying — 인증 없는 세션은 RCPT 가드가 550)`; + if (current) { + log(`[유지] ${label}`); + return; + } + await setObjects(client, 'Domain', { update: { [domain.id]: { allowRelaying: true } } }); + log(`[변경] ${label} — 저장 즉시 반영`); +} + +/** + * RCPT 가드가 실제로 답하는지 (probePilotGuard) — 두 번 본다: stage 'before' 는 A 의 리로드 뒤 · B · C 앞(2차 리뷰 N2), 'after' 는 C 바로 뒤(M1). + * RCPT 단계 Sieve 는 받는 사람 해석보다 먼저라 relay 가 꺼져 있어도 같은 판별이다(가드면 빠른 550). + * 빠른 550 이 아니면 종료 1 — relay 가 꺼져 있으면 켜지 않고, 켜져 있으면 곧바로 끄고(다시 읽어 확인) 멈춘다. 끄지 못했으면 그렇게 알린다(N1). + */ +async function verifyPilotGuardOrClose(client, cfg, domain, { stage = 'after' } = {}) { + const guard = await probePilotGuard(cfg); + // relay 상태를 읽지 못하면 켜진 것으로 본다(닫는 쪽으로) + let relayOn = true; + try { + relayOn = (await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['allowRelaying'] })).list[0]?.allowRelaying !== false; + } catch { + relayOn = true; + } + const when = stage === 'before' ? `켜기 전 확인(allowRelaying ${relayOn ? '이미 켜짐' : '꺼짐'})` : '켠 뒤 확인'; + if (guard.ok) { + log(`[확인] 시범 공존 — ${when}: 인증 없는 세션의 엔진에 없는 사내 주소 → RCPT 가드가 곧바로 거절 (${guard.detail})`); + return; + } + const why = `RCPT 가드가 답하지 않아(${guard.detail})`; + const hint = + 'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트 arasystem-system-recipients(isActive)를 관리 API 로 본 뒤 다시 실행하세요 ' + + '(mail/README.md "파일럿 공존").'; + if (!relayOn) { + throw new SetupError( + `${why} 시범 공존의 allowRelaying 을 켜지 않고 중단합니다 — 가드가 걸리지 않은 채 켜면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.`, + { hint }, + ); + } + const closed = await closePilotRelay(client, domain, why); + if (!closed.closed) throw relayOpenError(domain, why, closed.detail); + throw new SetupError('RCPT 가드가 걸리지 않아 시범 공존의 allowRelaying 을 다시 끄고 중단합니다 — 그대로 두면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.', { hint }); +} + +/** + * 위조 막기가 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션)로 사내 도메인 MAIL FROM 을 보내 550 5.7.1 인지 본다 + * (메일 컨테이너는 기존 서버 IP 가 아니다 — 받는 사람은 거절될 게이트웨이 주소라 DATA 로 가지 않는다) + */ +async function checkPilotSenderGuard(cfg) { + const mailIp = mailContainerIp(cfg); + if (!mailIp) { + problems.push('mail 컨테이너 주소를 몰라 시범 공존의 MAIL FROM 위조 막기를 실제로 확인하지 못했습니다.'); + return; + } + const spoof = pilotProbeAddress(cfg); + let replies = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + replies = smtpRepliesInsideMail(cfg, mailIp, spoof, [cfg.gatewayAddress]); + if (replies?.mailFrom?.startsWith('550 5.7.1')) break; + await sleep(POLL_INTERVAL_MS / 2); + } + if (replies?.mailFrom?.startsWith('550 5.7.1')) { + log(`[확인] 시범 공존 — 기존 서버 IP 가 아닌 곳(메일 컨테이너 ${mailIp})의 사내 도메인 MAIL FROM <${spoof}> → ${replies.mailFrom}`); + return; + } + problems.push( + `시범 공존 확인 실패 — 메일 컨테이너 ${mailIp} 의 MAIL FROM <${spoof}>: ${replies?.mailFrom ?? '응답 없음'} (550 5.7.1 이어야 함 — 위조 막기). ` + + 'x:MtaStageMail 의 isSenderAllowed 와 리로드를 확인하세요.', + ); +} + +/** + * 개발 — 인증한 세션의 받는 사람 확인에 쓰는 작은 SMTP 클라이언트 (백엔드 컨테이너의 node 로 돈다 — 메일 컨테이너에는 node 가 없고, + * curl 은 RCPT 가 받아지면 DATA 를 보낸다). 465(암묵적 TLS) · AUTH PLAIN · MAIL FROM · RCPT 뒤 RSET — 아무것도 보내지 않는다. + * 계정 · 비밀번호는 이 글 앞에 붙는 한 줄(PROBE_CREDENTIALS)로 표준 입력에만 실린다 — 명령줄 · 환경변수(docker exec 요청)에 두지 않는다 (2026-10-30 리뷰 L8). + * 결과에는 담지 않는다. + */ +const SMTP_RCPT_PROBE_SCRIPT = String.raw` +const tls = require('node:tls'); +const opts = JSON.parse(process.argv[process.argv.length - 1]); +const out = {}; +let buffer = ''; +let waiting = null; +let done = false; +const lines = []; +const finish = () => { if (done) return; done = true; console.log(JSON.stringify(out)); socket.destroy(); }; +const socket = tls.connect({ host: opts.host, port: opts.port, rejectUnauthorized: false }); +socket.setTimeout(20000, () => { out.error = 'timeout'; finish(); }); +socket.on('error', (error) => { out.error = error.message; finish(); }); +socket.on('data', (data) => { + buffer += data.toString('latin1'); + let index; + while ((index = buffer.indexOf('\r\n')) >= 0) { lines.push(buffer.slice(0, index)); buffer = buffer.slice(index + 2); } + flush(); +}); +function flush() { + if (!waiting) return; + const end = lines.findIndex((line) => /^\d{3}( |$)/.test(line)); + if (end < 0) return; + const reply = lines.splice(0, end + 1).pop(); + const resolve = waiting; + waiting = null; + resolve(reply); +} +const read = () => new Promise((resolve) => { waiting = resolve; flush(); }); +const send = (text) => { socket.write(text + '\r\n'); return read(); }; +(async () => { + out.greeting = (await read()).slice(0, 3); + out.ehlo = (await send('EHLO ' + opts.ehlo)).slice(0, 3); + const token = Buffer.from('\0' + PROBE_CREDENTIALS.user + '\0' + PROBE_CREDENTIALS.password).toString('base64'); + out.auth = await send('AUTH PLAIN ' + token); + if (!out.auth.startsWith('235')) return finish(); + out.mail = await send('MAIL FROM:<' + PROBE_CREDENTIALS.user + '>'); + out.rcpt = await send('RCPT TO:<' + opts.rcpt + '>'); + out.rset = await send('RSET'); + await send('QUIT'); + finish(); +})().catch((error) => { out.error = error.message; finish(); }); +`; + +/** 백엔드 컨테이너에서 인증한 세션(465)으로 RCPT 까지 — { greeting, ehlo, auth, mail, rcpt, rset } 또는 { error } */ +function authenticatedRcptFromBackend(cfg, user, recipient) { + const credentials = `const PROBE_CREDENTIALS = ${JSON.stringify({ user, password: cfg.devUserPassword })};\n`; + const result = spawnSync( + 'docker', + ['exec', '-i', cfg.backendContainer, 'node', '-', JSON.stringify({ host: 'mail', port: DEV_SUBMISSION_PORT, ehlo: RCPT_CHECK_EHLO, rcpt: recipient })], + { input: credentials + SMTP_RCPT_PROBE_SCRIPT, encoding: 'utf8', timeout: 30_000 }, + ); + try { + return JSON.parse((result.stdout ?? '').trim().split(/\r?\n/).pop()); + } catch { + // 표준 오류는 옮기지 않는다 — 스크립트의 첫 줄(계정)이 오류 글에 섞일 수 있다 + return { error: result.error?.message ?? `종료 코드 ${result.status}` }; + } +} + +/** + * 개발 — 인증한 세션(465, 테스트 직원)은 이 엔진에 없는 사내 주소를 받는가 (RCPT 250 뒤 RSET — 보내지 않는다). + * 웹메일(JMAP 제출)도 같은 RCPT 를 지나 기존 서버로 간다. 운영은 직원 비밀번호가 없어 하지 않는다 (mail/README.md "파일럿 공존" 의 확인 명령으로 본다). + */ +async function checkPilotAuthenticatedRelay(cfg, user) { + if (!containerNetworks(cfg.backendContainer)) { + warn(`${cfg.backendContainer} 가 없어 인증한 세션의 확인(465 RCPT)을 건너뜁니다 — 백엔드를 띄운 뒤 다시 실행하면 확인한다.`); + return; + } + const probe = pilotProbeAddress(cfg); + let reply = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + reply = authenticatedRcptFromBackend(cfg, user, probe); + if (reply?.rcpt?.startsWith('250')) break; + await sleep(POLL_INTERVAL_MS / 2); + } + if (reply?.rcpt?.startsWith('250')) { + log(`[확인] 시범 공존 — 인증한 세션(${DEV_SUBMISSION_PORT}, ${user})의 엔진에 없는 사내 주소 ${probe} → ${reply.rcpt} (RSET — 보내지 않았다)`); + return; + } + problems.push( + `시범 공존 확인 실패 — 인증한 세션(${DEV_SUBMISSION_PORT}, ${user})의 ${probe}: ${reply?.rcpt ?? reply?.auth ?? reply?.error ?? '응답 없음'} ` + + '(250 이어야 함 — 도메인 allowRelaying). 이대로면 웹메일에서 동료에게 보낸 메일이 not-found 로 거절된다.', + ); +} + // ============================================================ // 실행 // ============================================================ function printHelp() { - log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]'); + log(`사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery] [${END_PILOT_FLAG}]`); log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md'); log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).'); log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`); + log(` ${END_PILOT_FLAG} 운영의 시범 공존 끝내기(전면 전환) — ${PILOT_HOST_ENV_KEY} · ${PILOT_IPS_ENV_KEY} 를 비우고 함께 준다 (mail/README.md "파일럿 공존")`); } /** 후속 조치가 필요하다 (종료 코드 3) — 안내를 출력하고 끝낸다 */ @@ -2267,6 +3823,10 @@ async function main() { const cfg = loadConfig(argv); log(`메일 엔진 설정 — ${cfg.appEnv} · 도메인 ${cfg.domain} · 관리 API ${cfg.adminUrl}`); for (const message of cfg.warnings) warn(message); + if (cfg.pilotShellKeys.length > 0) { + // 운영은 loadConfig 가 이미 막았다 — 여기는 개발뿐이다 + log(`[참고] 시범 공존 값(${cfg.pilotShellKeys.join(', ')})을 셸에서 받았습니다 — 개발에서만 받는다, env 파일에 없으면 이번 실행에만 쓰인다`); + } // 운영은 확인을 건너뛰지 않는다 — docker 가 없으면 아무것도 바꾸기 전에 멈춘다 if (cfg.isProduction && !cfg.docker) { @@ -2282,6 +3842,20 @@ async function main() { const admin = await ensureAdmin(cfg); const domain = await findDomain(admin, cfg); + // 시범 공존 — 엔진의 시범 설정을 먼저 읽는다. 켤 때는 아무것도 바꾸기 전에 확인하고(preparePilot — 가드 없이 열린 relay 만은 먼저 닫는다), + // 값이 비었는데 남아 있으면 allowRelaying 부터 끄고 다시 읽어 확인한다(운영은 --end-pilot 이 있어야 — 없으면 여기서 멈춘다). + // relay 가 꺼진 것을 확인하지 못하면 가드 · 위조 막기 · 예외 · 경로를 남긴다(teardown.relayOff false) + const pilotState = await readPilotState(admin, domain); + if ((cfg.pilot || pilotState.remnants.length > 0) && pilotState.lookalikes.length > 0) { + warn( + `시범 꼴과 같은 기존 서버 IP 갈래가 있지만 setup 의 표시(자동 차단 예외 "${PILOT_ALLOWED_IP_REASON}")에 없는 IP 라 운영자 것으로 보고 건드리지 않습니다: ` + + `${pilotState.lookalikes.join(', ')} — setup 이 넣은 것이라면 관리 화면에서 지우세요 (mail/README.md "파일럿 공존").`, + ); + } + const pilot = cfg.pilot ? await preparePilot(admin, cfg, domain, pilotState) : null; + const teardown = pilot ? null : await endPilotIfLeft(admin, cfg, domain, pilotState); + const keepPilotRoute = Boolean(pilot) || teardown?.relayOff === false; + await ensureAllowedIps(admin, cfg); const roleId = await ensureGatewayRole(admin); await ensureAccount(admin, cfg, domain, { @@ -2298,18 +3872,40 @@ async function main() { await ensureHttpEndpointPolicy(admin); // 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의]) const directory = await ensureDirectoryAccount(admin, cfg, domain); - // 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영) + // 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영). + // 시범 공존이면 같은 식에 RCPT 가드(인증 없는 세션의 엔진에 없는 사내 주소)를 함께 둔다 — 시범 A 의 첫째 const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory); - const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients); - // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다), + // 시범 공존 A 의 맨 앞 — 주인 표시(자동 차단 예외)부터. 그 뒤 어디서 멈춰도 다음 실행이 setup 의 갈래 · 한도 예외를 알아본다 (2차 리뷰 N5) + if (pilot) await ensurePilotMarkers(admin, pilot); + const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients, pilot); + if (pilot && !systemRecipientsRejected) { + throw new SetupError('RCPT 가드를 넣지 못해 시범 공존을 켜지 않고 중단합니다 — 가드 없이 allowRelaying 을 켜면 엔진에 없는 주소 앞 메일을 받아 놓고 반송한다.', { + hint: '위 [주의] · [실패] 의 RCPT 단계 스크립트를 고친 뒤 다시 실행하세요 (mail/README.md "파일럿 공존").', + }); + } + // 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다 · 시범 공존이면 기존 서버 IP 도 뺀다), // 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영) - await ensureRecipientThrottle(admin); + await ensureRecipientThrottle(admin, pilot); + // 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다) + await ensurePilotInboundTrust(admin, pilot); await ensureUploadQuota(admin, cfg); // 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다 - if (!cfg.isProduction) await ensureDevRelay(admin); + if (!cfg.isProduction) await ensureDevRelay(admin, keepPilotRoute); await reloadSettings(admin); + // 시범 공존 끝 — 발송 전략을 되돌린 것이 위 리로드로 걸린 뒤에 legacy 경로를 지운다 + if (teardown?.routeToDestroy) await destroyPilotRoute(admin, teardown.routeToDestroy); + if (pilot) { + // 막는 것(A)이 위 리로드로 걸렸는지 — 열기 전에 RCPT 가드가 곧바로 답하는지 본다. 빠른 550 이 아니면 B · C 없이 종료 1 (2차 리뷰 N2) + await verifyPilotGuardOrClose(admin, cfg, domain, { stage: 'before' }); + // 시범 공존 B(경로 → 리로드) → C(allowRelaying — 저장 즉시). + // 연 바로 뒤에도 RCPT 가드가 실제로 답하는지 본다 — 답하지 않으면 곧바로 닫고 종료 1 (닫힌 쪽으로 실패한다 — M1) + if (await ensurePilotRouting(admin, pilot)) await reloadSettings(admin); + await ensurePilotRelaying(admin, domain); + await verifyPilotGuardOrClose(admin, cfg, domain, { stage: 'after' }); + } await checkHttpEndpointPolicy(cfg); if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients); + if (pilot) await checkPilotSenderGuard(cfg); const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg); const devUsers = []; @@ -2317,7 +3913,7 @@ async function main() { await checkPublicEndpoint(cfg); await checkNoDevRelay(admin); } else { - await verifyDevRelay(admin); + await verifyDevRelay(admin, pilot, keepPilotRoute); for (const user of DEV_USERS) { const address = await ensureAccount(admin, cfg, domain, { label: '테스트 직원', @@ -2330,6 +3926,8 @@ async function main() { } await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`); await checkPrincipalHidden(cfg, `${DEV_USERS[0].name}@${domain.name}`); + // 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에) + if (pilot) await checkPilotAuthenticatedRelay(cfg, `${DEV_USERS[0].name}@${domain.name}`); } if (cfg.isProduction || cfg.printDns) await printDnsZone(admin, domain); @@ -2364,6 +3962,22 @@ async function main() { if (gatewayReachesAdmin) { log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")'); } + if (pilot) { + log( + ` 시범 공존 켜짐 — 엔진에 없는 사내 주소는 기존 서버 ${pilot.host}:${pilot.port} 로(인증한 · 엔진이 만든 메일만) · ` + + `기존 서버 IP ${pilot.ips.join(', ')} 에서 인증 없이 온 메일은 SPF · 역방향 DNS · DMARC 검사 없이 받는다 · ` + + `사내 도메인 MAIL FROM 은 그 IP 에서만 · 바깥 발송은 ${pilot.elseRoute} (mail/README.md "파일럿 공존")`, + ); + } else if (teardown?.relayOff === false && teardown.nothingLeft) { + log(' 시범 공존 시범 설정 없음 · allowRelaying 켜짐(setup 이 켠 것이 아님 — 위 [실패], mail/README.md "파일럿 공존")'); + } else if (teardown?.relayOff === false) { + log( + ` 시범 공존 끝내지 못함 — ${PILOT_HOST_ENV_KEY} 는 비었지만 allowRelaying 이 켜져 있어 RCPT 가드 · 위조 막기 · 예외 · 발송 전략을 남겼다 ` + + '(위 [실패] — allowRelaying 을 끈 뒤 다시 실행, mail/README.md "파일럿 공존")', + ); + } else { + log(` 시범 공존 꺼짐 (${PILOT_HOST_ENV_KEY} 비어 있음)`); + } if (devUsers.length > 0) { log(` 테스트 직원 ${devUsers.join(', ')} — 비밀번호 MAIL_DEV_USER_PASSWORD`); log(` Mailpit 사내 도메인 밖으로 보낸 메일 — http://localhost:${cfg.mailpitUiPort ?? 'MAILPIT_UI_PORT'}`); @@ -2373,9 +3987,11 @@ async function main() { } if (problems.length > 0) { - throw new SetupError('설정은 끝났지만 확인에 실패한 항목이 있습니다.', { + const error = new SetupError('설정은 끝났지만 확인에 실패한 항목이 있습니다.', { detail: problems.map((message) => ` - ${message}`).join('\n'), }); + error.problemsIncluded = true; + throw error; } if (cfg.isProduction && cfg.recoveryFlag && recoveryActive) { throw new FollowUpRequired('설정은 끝났습니다. 복구 관리자는 일회성이므로 지금 끕니다 (mail/README.md "운영 배포" 4·5).', [ @@ -2403,6 +4019,11 @@ main().catch((error) => { console.error(`\n[실패] ${error.message}`); if (error.detail) console.error(error.detail); if (error.hint) console.error(` → ${error.hint}`); + // 멈추기 전에 쌓인 확인 실패도 함께 — 끝까지 가지 못해도 앞의 [실패] 가 사라지지 않게 (2차 리뷰 N1) + if (!error.problemsIncluded && problems.length > 0) { + console.error(' 그 전에 이 실행에서 확인에 실패한 항목:'); + for (const message of problems) console.error(` - ${message}`); + } if (!(error instanceof SetupError)) console.error(error.stack); process.exit(EXIT_ERROR); });