generated from ttipo/project-base
6dc2777834
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Failing after 8m39s
CI / Frontend (lint · type-check · test · build) (pull_request) Failing after 8m5s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 1m56s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m32s
개발 엔진 실측(2026-11-03, 0.16.27): 큰 메일 한 통을 받으면 엔진 메모리가 그 크기의 5.5배쯤 는다(IMAP APPEND · SMTP 받기 같다). SMTP 받는 한도(x:MtaStageData 의 maxMessageSize, 기본 100MiB)는 x:Email 한도와 따로라, 기본값 · 1g 에서 바깥 104MB 한 통이 984/1024MiB 였다(운영은 106.4MB IMAP APPEND 로 OOM). 사용자 결정 — 메일 컨테이너 메모리는 1g 그대로 두고, setup 이 받는 한도를 50MiB 로 맞춘 뒤 리로드 뒤 EHLO 의 SIZE 로 실제로 걸렸는지 본다(저장 때 줄만으로는 리로드 실패를 못 거른다 — 리뷰). IMAP · 메일 크기 한도는 imapsync 이전 동안 운영자가 잠시 올리므로 setup 이 바꾸지 않고 기본보다 크면 [주의] 만 낸다. README 에 잰 값 · 정책 · 이전 뒤 되돌리는 차례(세 값 → setup [확인] → 1g)와 문제 해결 줄, docker 스킬에 한 항목. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5639 lines
346 KiB
JavaScript
5639 lines
346 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* 메일 엔진(Stalwart) 설정 — 여러 번 실행해도 안전하다 (멱등).
|
||
*
|
||
* 사용법 (저장소 루트에서, Node 20.6 이상 — --env-file · 권장 24 LTS · 의존성 없음):
|
||
* node --env-file=.env.development mail/scripts/setup.mjs
|
||
* 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).
|
||
* 비밀번호는 어떤 경우에도 출력하지 않는다.
|
||
*
|
||
* ⚠️ 운영에서는 docker 를 쓸 수 있어야 한다 (docker 그룹 또는 sudo — mail/README.md "운영 배포").
|
||
* 네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 하기 때문이다. 없으면 아무것도 바꾸지 않고 실패한다.
|
||
*
|
||
* 하는 일 (순서가 중요하다)
|
||
* 0. 사전 확인 앱 네트워크 서브넷 · 앱 네트워크에 붙은 컨테이너 · 백엔드 주소, 메일 전용 DNS 리졸버(mail-resolver — 네트워크 · 주소 ·
|
||
* 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증하는지) — 하나라도 어긋나면 아무것도 바꾸지 않고 중단한다
|
||
* (신뢰 대역을 바꾼 뒤에 알면 늦다 · 닿지 않는 리졸버에 엔진의 DNS 를 맡기면 바깥 메일이 멈춘다).
|
||
* 리졸버의 답 확인만은 개발에서 [주의] 뒤 이어 간다(개발 메일은 Mailpit — checkResolver).
|
||
* Spamhaus 키(MAIL_SPAMHAUS_DQS_KEY)가 있으면 그 키로 시험 항목이 나오는지도 본다
|
||
* 1. 모드 감지 bootstrap(첫 기동) / bootstrap 완료·재시작 대기 / 정상
|
||
* 2. bootstrap 서버 호스트명·기본 도메인·DKIM 키 → 컨테이너 재시작
|
||
* ⚠️ Bootstrap/set 은 원자적이지 않다. 두 번 호출하면 도메인 중복으로 거부되면서도
|
||
* 일부 설정(tracer 등)은 한 번 더 만들어진다. 그래서
|
||
* - 부르기 전에 임시 비밀번호 파일을 쓸 수 있는지 실제로 써 보고, 안 되면 아무것도 바꾸지 않고 중단한다
|
||
* - 응답(무작위 관리자 비밀번호)을 받자마자 파일에 보관한다
|
||
* - 재실행 때는 mail 컨테이너의 config.json(또는 보관 파일)으로 "이미 끝났음"을 알아보고 재시작만 한다
|
||
* 3. 관리자 영구 관리자(<MAIL_ADMIN_LOCAL_PART>@도메인, 기본 admin) 비밀번호를 MAIL_ADMIN_PASSWORD 로 맞춘다.
|
||
* bootstrap 은 늘 admin@ 을 만들므로, 갓 bootstrap 한 엔진이면 그 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾼다.
|
||
* 이미 쓰던 엔진의 관리자와 env 아이디가 다르면(bootstrap 뒤에 값만 바꿈) 아무것도 바꾸지 않고 중단한다
|
||
* 4. 차단 예외 AllowedIp — 모든 로그인이 백엔드 IP 를 거치므로 필수
|
||
* (기본 보안 설정은 IP 당 인증 실패가 하루 100회면 그 IP 를 차단한다)
|
||
* 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능)
|
||
* 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게
|
||
* 관리자 역할에서 대리 접근(impersonate)을 빼고 관리자 비밀번호의 허용 IP 를 앱 네트워크로 묶는다(13단계 — 관리자 비밀번호 하나로
|
||
* 바깥 993 에서 <직원>%<관리자> 로 아무 메일함에나 들어가지 못하게 · ADMIN_DISABLED_PERMISSION 머리말). 리로드 뒤 관리자의 실제 권한으로 확인한다
|
||
* 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다)
|
||
* 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 —
|
||
* 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다)
|
||
* 남은 위험 표시 — 게이트웨이로 관리자를 대리 접근하면 관리 API 가 열리는지 매번 확인해 [주의] 로 보여 준다
|
||
* (Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다 — 막는 일은 백엔드가 한다, mail/README.md "보안")
|
||
* 7. 사내 목록 계정 목록(Principal) 읽기 전용 역할 + 비밀번호 없는 계정(<MAIL_DIRECTORY_LOCAL_PART>@도메인) — 백엔드가 게이트웨이
|
||
* 대리 접근으로 직원 이름 · 주소만 읽는다 (계약 docs/mail/api-contract.md 3절 "사내 목록").
|
||
* 엔진 관리자는 이 권한을 내줄 수 없어 복구 관리자가 있을 때(개발 · 운영 --recovery)만 만들고 맞춘다.
|
||
* 없으면 관리자로 되는 것(설명 · 저장 한도 · 비밀번호 빼기)만 맞추고 [주의] — 실패로 끝내지 않는다
|
||
* 8. 엔진 한도 받는 사람 단위 받는 쪽 한도(같은 보낸 도메인 → 한 받는 사람)를 인증 없는 세션에만 건다 (설정 리로드 뒤 반영)
|
||
* — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도")
|
||
* 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영)
|
||
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
|
||
* SMTP 로 받는 메일 한 통의 크기(x:MtaStageData 의 maxMessageSize)를 50MiB 이하로 낮춘다 (설정 리로드 뒤 반영 · SMTP_MESSAGE_SIZE_LIMIT 머리말)
|
||
* — 더 작게 둔 값은 올리지 않고, 갈래가 있거나 else 가 숫자 하나가 아닌 식 · 0 이면 [주의]. 리로드 뒤 메일 컨테이너 안에서 EHLO 의 SIZE 로
|
||
* 걸렸는지 본다. IMAP 요청 · 메일 크기 한도(x:Imap · x:Email)는 바꾸지 않고 엔진 기본보다 크면 [주의] 만
|
||
* 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30
|
||
* (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말)
|
||
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
|
||
* Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||
* 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영).
|
||
* 리로드 뒤 메일 컨테이너 안에서 서명한 빈 묶음을 보내 받는 곳까지 닿는지 · 키가 맞는지 본다. 키가 비면 운영은 중단, 개발은 [주의] 뒤 웹훅을 끈다
|
||
* (mail/README.md "엔진 웹훅")
|
||
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
|
||
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 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 { createHmac, randomBytes, scryptSync } 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';
|
||
|
||
// ============================================================
|
||
// 상수
|
||
// ============================================================
|
||
|
||
const MIN_NODE_MAJOR = 20;
|
||
const JMAP_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
|
||
const STALWART_CAPABILITY = 'urn:stalwart:jmap';
|
||
const MAIL_CAPABILITY = 'urn:ietf:params:jmap:mail';
|
||
|
||
/** compose 내부망에서 실행할 때의 관리 API 주소 — 운영은 8080 을 호스트에 공개하지 않는다 */
|
||
const INTERNAL_ADMIN_URL = 'http://mail:8080';
|
||
/**
|
||
* 앱 네트워크(app-network) 서브넷 기본값 — docker-compose.yml 의 `${APP_NETWORK_SUBNET:-…}` 와 같아야 한다.
|
||
* 메일 엔진은 이 대역(또는 그 안의 더 좁은 MAIL_TRUSTED_NETWORKS)만 신뢰한다.
|
||
*/
|
||
const DEFAULT_APP_NETWORK_SUBNET = '172.16.71.0/24';
|
||
/**
|
||
* 앱 네트워크 서브넷의 접두 길이 범위 — /24 보다 넓지도, /28 보다 좁지도 않게.
|
||
* - 넓은 쪽(/24): 이 대역 전체가 신뢰 대역이다(자동 차단 예외 + 게이트웨이 로그인 허용).
|
||
* 게시 포트(25·465·993·443)로 들어온 접속은 원래 주소가 그대로 보이므로, 대역이 넓을수록
|
||
* 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹쳐 그 접속까지 믿게 될 위험이 커진다.
|
||
* 이 compose 의 앱 네트워크에는 컨테이너 몇 개(backend·db·redis·mail·mailpit)만 붙으므로 /24(254개)면 충분하다.
|
||
* - 좁은 쪽(/28): 게이트웨이 1 + 위 5개 + 재생성 여유. /29(사용 가능 6개)는 게이트웨이를 빼면 5개라 여유가 없다.
|
||
*/
|
||
const APP_NETWORK_PREFIX_RANGE = { widest: 24, narrowest: 28 };
|
||
/** 사설 대역(RFC 1918) — 공인 대역을 앱 네트워크로 쓰면 그 대역의 실제 인터넷 주소를 믿게 된다 */
|
||
const PRIVATE_IPV4_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
|
||
/** 운영에서는 env 파일에 두지 않고 셸에서 일회성으로만 주는 키 (--recovery 실행) */
|
||
const RECOVERY_ENV_KEY = 'MAIL_RECOVERY_ADMIN';
|
||
/**
|
||
* Bootstrap/set 이 쓰는 설정 파일 (mail 컨테이너 안, mail-config 볼륨). 재시작 전에도 이미 있다 —
|
||
* bootstrap 모드인데 이 파일이 있으면 Bootstrap/set 은 끝났고 재시작만 남은 것이다 (Stalwart 0.16 에서 확인).
|
||
*/
|
||
const MAIL_CONFIG_FILE = '/etc/stalwart/config.json';
|
||
/** 앱 네트워크(신뢰 대역)에 붙어도 되는 서비스 — 그 밖의 컨테이너(특히 인터넷을 향한 frontend)는 붙이지 않는다 */
|
||
const APP_NETWORK_SERVICES = ['backend', 'db', 'redis', 'mail', 'mailpit'];
|
||
|
||
/**
|
||
* 공개 443(https 리스너)에서 허용할 HTTP 경로 — 나머지(JMAP·관리 API·웹 포털·OAuth)는 403.
|
||
* 백엔드는 내부 http(8080) 리스너만 쓰므로 영향이 없다. 관리 화면은 SSH 터널로 8080 에 붙는다 (mail/README.md).
|
||
* 식의 변수 이름(listener·path)은 Stalwart 0.16 에서 검증했다 (url_path 등은 거부된다).
|
||
*/
|
||
const HTTP_ENDPOINT_POLICY = {
|
||
match: { 0: { if: "listener == 'https' && path != '/.well-known/mta-sts.txt'", then: '403' } },
|
||
else: '200',
|
||
};
|
||
/** 기본 직원 역할 — 계정의 roles {"@type":"User"} 가 가리키는 기본 제공 역할 */
|
||
const USER_ROLE_DESCRIPTION = 'User';
|
||
/** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */
|
||
const ADMIN_ROLE_DESCRIPTION = 'System Administrator';
|
||
/**
|
||
* 관리자 계정 단단히 (13단계 — mail/README.md "관리자 계정 단단히"). 둘을 맞춘다:
|
||
* 1. 관리자 역할(ADMIN_ROLE_DESCRIPTION)의 빼는 권한(disabledPermissions)에 impersonate — 그대로면 관리자 비밀번호 하나로 바깥 993 IMAP · JMAP 에서
|
||
* <직원>%<관리자> 로 아무 직원 메일함에나 들어가고(엔진 로그에는 관리자 로그인만 남는다), 관리 API 로 accountId=<직원> 메일함도 바로 연다.
|
||
* 빼면 둘 다 막히고(대리 접근 403 · IMAP 끊김 · 남의 메일함 forbidden) 관리 API(계정 · 설정)는 그대로다.
|
||
* 엔진은 역할을 바꿀 때 "바꾼 뒤의 권한 전체"를 바꾸는 쪽이 가졌는지만 본다(0.16.27 jmap/src/registry/mapping/principal.rs 의 validate_role) —
|
||
* 빼는 것은 관리자로 되고, 되돌리기(빼는 권한 지우기 · impersonate 를 주는 역할 · 계정에 게이트웨이 역할)는 관리자로 안 된다(복구 관리자만).
|
||
* 켜는 권한(enabledPermissions)에서 지우지 않고 빼는 권한에 두는 까닭 — 엔진은 역할 · 계정 권한의 켜는 것끼리 · 빼는 것끼리 합친 뒤 뺀다
|
||
* (common/src/auth/permissions.rs 의 union · finalize). 계정에 Merge 로 impersonate 를 켜도 이 역할을 가진 계정은 여전히 못 한다(2026-10-31 임시 엔진).
|
||
* 게이트웨이(자기 역할의 impersonate — 백엔드)와 복구 관리자(역할을 쓰지 않는 슈퍼유저)의 대리 접근은 그대로다
|
||
* 2. 영구 관리자(<MAIL_ADMIN_LOCAL_PART>@도메인) 비밀번호 자격의 허용 IP = 앱 네트워크(APP_NETWORK_SUBNET) — 바깥(게시 포트 993 · 465 · 443 은 원래 주소 그대로 보인다)에서
|
||
* 관리자 비밀번호로 로그인하지 못하게. 이 스크립트(호스트 → 컨테이너 주소 · 개발 포트 매핑)와 SSH 터널(호스트의 sshd → 컨테이너 주소)은
|
||
* 앱 네트워크의 게이트웨이 주소(<서브넷>.1)로 보이고, backend 컨테이너의 node 로 하는 관리 API 는 그 컨테이너 주소다 — 둘 다 대역 안이다.
|
||
* 밖이면 엔진은 401 이 아니라 403(IMAP 은 끊김) — security.ip-unauthorized "IP address not allowed." 로 남는다 (2026-10-31 임시 엔진 · 개발 엔진).
|
||
* MAIL_TRUSTED_NETWORKS(게이트웨이 대역 — 더 좁힐 수 있다)가 아니라 앱 네트워크 전체인 까닭 — 좁히면 호스트(<서브넷>.1)가 빠져 이 스크립트가 막힐 수 있다.
|
||
* 운영자가 대역 안으로 더 좁힌 값(/32 등)은 그대로 둔다. 대역 밖 항목이 있으면(없음 · 옛 대역 · 0.0.0.0/0) 대역 하나로 바꾼다.
|
||
* 둘 다 관리자로 된다. 다만 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 건다(--recovery) — 걸자마자 막히면 되돌리는 길이
|
||
* 복구 관리자(허용 IP · 역할을 보지 않는다 — mail/README.md "운영 배포" 1 · 4 · 5)뿐이다. 걸지 못하면 운영은 종료 1, 개발은 [주의].
|
||
* APP_NETWORK_SUBNET 을 바꿔 관리자 로그인이 403 이면 복구 관리자로 지금 대역으로 고친다(ensureAdmin · resetAdminAllowedIps)
|
||
*/
|
||
const ADMIN_DISABLED_PERMISSION = 'impersonate';
|
||
/** 관리자 허용 IP 를 걸어도 되는 이 실행의 관리 API 주소 — 루프백(개발 포트 매핑) · compose 이름 mail(앱 네트워크 안의 컨테이너) · 앱 네트워크 안의 IPv4 */
|
||
const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]'];
|
||
/**
|
||
* 휴지통 · 스팸메일함 자동 비우기 (13단계 — mail/README.md "휴지통 · 스팸메일함 자동 비우기") — 엔진의 x:DataRetention 싱글톤.
|
||
* expungeTrashAfter — 휴지통 · 스팸메일함에 **들어온 때부터** 이만큼 지나면 다음 계정 정리에서 메일째 영구 삭제(받은 시각이 아니다 —
|
||
* 0.16.27 email/src/message/delete.rs 가 내부 칸 DeletedAt 으로 고른다. 받은편지함에도 함께 든 메일은 통째로 지워진다). 엔진 기본값이 30일이지만 기대지 않고 맞춘다.
|
||
* 정리 시각 셋 — 엔진 기본(00:00 · 02:00 · 04:00)은 메일 컨테이너의 현지 시각(chrono Local — utils/src/cron.rs)이고 컨테이너에 TZ 가 없어 UTC 다 → KST 09:00 · 11:00 · 13:00(일하는 시간).
|
||
* 새벽으로 옮긴다: 계정 정리(휴지통 · 스팸 30일) 03:00 → 백업 03:30(ops/backup — 엔진을 몇 초 멈춘다) → 데이터 정리 04:00 → 원문 정리 04:30 (KST).
|
||
* setup 은 컨테이너의 UTC 차이(date +%z)로 바꿔 넣는다(UTC 컨테이너면 18:00 · 19:00 · 19:30).
|
||
* 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 스케줄러가 다음 시각을 기동 때와 실행 직후에만 정한다
|
||
* (services/src/task_manager/scheduler.rs — 2026-10-31 임시 엔진: 저장 · 리로드 · 재시작 뒤 task-scheduled due 17:59:59Z · 18:59:59Z · 19:29:59Z)
|
||
*/
|
||
const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||
const KST_OFFSET_MINUTES = 9 * 60;
|
||
const RETENTION_SCHEDULES_KST = [
|
||
{ field: 'expungeSchedule', label: '계정 정리(휴지통 · 스팸 30일)', hour: 3, minute: 0 },
|
||
{ field: 'dataCleanupSchedule', label: '데이터 정리', hour: 4, minute: 0 },
|
||
{ field: 'blobCleanupSchedule', label: '원문(blob) 정리', hour: 4, minute: 30 },
|
||
];
|
||
const MINUTES_PER_DAY = 24 * 60;
|
||
/** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */
|
||
const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/;
|
||
/**
|
||
* 엔진 기본 받는 쪽 한도 가운데 받는 사람 단위의 것 (x:MtaInboundThrottle — 같은 보낸 도메인 → 한 받는 사람, 기본 시간당 25).
|
||
* id 는 설치마다 다르므로 설명·key 로 찾는다. 기본 조건은 모든 세션이라, 보낸 도메인이 모두 같은 사내 메일은 동료 모두 합쳐 한 사람에게
|
||
* 시간당 25통을 넘으면 웹메일 발송(JMAP 제출)의 그 받는 사람이 452 4.4.5 로 거절됐다 — 엔진은 다시 보내지도 반송하지도 않는다 (2026-10-28 확인).
|
||
*/
|
||
const RECIPIENT_THROTTLE = { description: 'Sender address to recipient throttle', key: ['senderDomain', 'rcpt'] };
|
||
/**
|
||
* 그 한도를 걸 세션 — 인증 없는 세션(바깥에서 들어오는 메일)만. 엔진 기본값이 스팸 필터에 쓰는 식(x:MtaStageData 의 enableSpamFilter)과 같다.
|
||
* authenticated_as 는 인증한 계정 이름이고 인증 없는 세션이면 빈 값이다 — 웹메일 발송은 엔진 안에서 그 직원으로 인증된 세션이라 빠진다.
|
||
* 엔진은 저장할 때 식을 검사한다 (모르는 변수·이 맥락에 없는 변수는 거부 — Stalwart 0.16.27 에서 확인).
|
||
* ⚠️ 다른 기본 한도 "Sender IP throttle"(보낸 IP 마다 초당 연결 5개)은 그대로 둔다. 연결할 때 세는 한도라 웹메일 발송에는 걸리지 않고,
|
||
* 식에 authenticated_as 를 쓰면 엔진이 그 한도를 MAIL FROM 단계로 옮겨(식의 변수를 아는 첫 단계) 바깥 접속의 연결 수를 더는 세지 않는다.
|
||
*/
|
||
const UNAUTHENTICATED_SESSIONS_ONLY = { match: {}, else: 'is_empty(authenticated_as)' };
|
||
/**
|
||
* 계정마다 임시 업로드 크기 합계(x:Jmap 싱글톤의 uploadQuota)를 첨부 합계 한도의 이만큼 배 이상으로 둔다 — 한 시간에 합계 한도를 꽉 채운 메일 20통
|
||
* (계약 docs/mail/api-contract.md 2절 "첨부 업로드 한도").
|
||
* 엔진은 계정마다 uploadTtl(기본 1시간) 동안 올린 것의 크기 합계(uploadQuota, 기본 50,000,000바이트)와 개수(maxUploadCount, 기본 1,000)를 세고,
|
||
* 넘으면 올린 것이 만료될 때까지 그 계정의 업로드를 모두 429 로 막는다 — 첨부 올리기뿐 아니라 필터 · 부재중 저장(백엔드가 Sieve 스크립트를 올린다)과
|
||
* 서명 그림이 든 발송도. 2026-10-29 개발 엔진(0.16.27): 1,000개를 넘기자 필터 저장이 막혔고, 20MiB 두 개 뒤 세 번째가 429 "Quota exceeded" 였다
|
||
* (기본 크기 합계면 20MB 첨부 세 개). 바꾼 값은 ReloadSettings 뒤에 반영된다 — 저장만 하면 옛 한도로 계속 막는다 (같은 날 확인).
|
||
*/
|
||
const UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL = 20;
|
||
/**
|
||
* 첨부 한도 env 의 기본값 · 단위 · 숫자 표기 — 백엔드 backend/src/modules/mail/mail.settings.ts(DEFAULT_ATTACHMENT_*·MEGABYTE)와
|
||
* backend/src/shared/utils/env.ts(10진 숫자만, 앞자리 0 없이)와 같아야 한다. 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
|
||
*/
|
||
const DEFAULT_ATTACHMENT_MAX_SIZE_MB = 20;
|
||
const DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB = 25;
|
||
const MEGABYTE = 1024 * 1024;
|
||
const POSITIVE_INT_PATTERN = /^[1-9]\d*$/;
|
||
/**
|
||
* SMTP 로 받는 메일 한 통의 상한 — x:MtaStageData 싱글톤의 maxMessageSize(25 · 465 — 465 는 시험하지 않았다 · EHLO 의 SIZE 로 알린다 ·
|
||
* 엔진 기본 {"match":{},"else":"104857600"} = 100MiB)를 이 값 이하로 둔다 (mail/README.md "엔진 한도" 「받는 메일 크기」 · ensureMessageSizeLimits ·
|
||
* 리로드 뒤 확인 checkMessageSizeLimit). 2026-11-03 개발 엔진(0.16.27)에서 잰 것:
|
||
* - 큰 메일 한 통을 받으면 엔진 메모리(cgroup anon)가 그 크기의 5.5배쯤 는다 — IMAP APPEND 와 SMTP 받기가 같다(100MB IMAP +540MiB · 104MB SMTP +557MiB · 50MB SMTP +224~270MiB).
|
||
* 받은 뒤 기준선이 127MiB 에서 408MiB 까지 쌓이고 몇 분 안에는 내려오지 않았다. 기본값 · 메모리 1g(스왑 없음)에서 바깥 104MB 한 통이 984/1024MiB 였고,
|
||
* 운영은 1g 에서 106.4MB IMAP APPEND 로 OOM 이 났다 — 메일 컨테이너 메모리 1g 는 그대로 두고 받는 한 통을 줄인다(2026-11-03 사용자 결정).
|
||
* - 넘는 메일은 552 5.3.4 "Message too big for system." — 보낸 쪽이 SIZE 를 알리면 MAIL FROM 에서, 알리지 않으면 넘는 순간에(받아 두지 않고) 거절한다.
|
||
* 값은 저장한 뒤 ReloadSettings 로 걸린다(EHLO 의 SIZE 가 52428800 으로 바뀐다).
|
||
* - x:Email 의 maxMessageSize · maxAttachmentSize 와 x:Imap 의 maxRequestSize 는 SMTP 배달에 걸리지 않는다(Email.maxMessageSize 75MB 에서 104MB SMTP 메일이 배달됐다) —
|
||
* SMTP 로 받는 크기는 이 값 하나로만 막는다
|
||
*/
|
||
const SMTP_MESSAGE_SIZE_LIMIT = 50 * MEGABYTE;
|
||
/**
|
||
* IMAP 요청 · 메일 · 첨부 크기의 엔진 기본값 (0.16.27 — 2026-11-03 개발 엔진에서 관리 API 로 읽은 값). setup 은 이 셋을 바꾸지 않는다 —
|
||
* imapsync 이전 동안 운영자가 큰 메일 때문에 잠시 올리므로(mail/README.md "큰 메일") 이전 도중에 setup 을 돌려도 이전이 깨지지 않게.
|
||
* 기본보다 크면 [주의] 만 낸다 — 큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다(SMTP_MESSAGE_SIZE_LIMIT 머리말).
|
||
* IMAP 은 maxRequestSize 를 넘는 APPEND 를 리터럴을 받기 전에 BAD [PARSE] "Literal exceeds the maximum request size of …" 로 거절한다.
|
||
* 0.16.27 의 값이다 — 엔진 판을 올리면 다시 읽어 맞춘다
|
||
*/
|
||
const ENGINE_DEFAULT_SIZE_LIMITS = {
|
||
Imap: { maxRequestSize: 52_428_800 },
|
||
Email: { maxMessageSize: 75_000_000, maxAttachmentSize: 50_000_000 },
|
||
};
|
||
/**
|
||
* 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시 (소문자로 맞춰 검사).
|
||
* ⚠️ 백엔드 backend/src/modules/auth/employee-address.ts 의 LOCAL_PART_PATTERN(isLocalPart)과 같은 규칙이다 —
|
||
* MAIL_ADMIN_LOCAL_PART 를 두 쪽이 함께 읽으므로, 한쪽을 바꾸면 다른 쪽도 함께 바꾼다.
|
||
*/
|
||
const LOCAL_PART_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/;
|
||
/** 영구 관리자 아이디 기본값 — 루트 env MAIL_ADMIN_LOCAL_PART (백엔드 mail.settings.ts 의 DEFAULT_MAIL_ADMIN_LOCAL_PART 와 같다) */
|
||
const DEFAULT_ADMIN_LOCAL_PART = 'admin';
|
||
/**
|
||
* bootstrap 이 만드는 영구 관리자의 아이디 — Bootstrap/set 은 아이디를 받지 않고 늘 admin@<기본 도메인> 을 만든다
|
||
* (응답 username, Stalwart 0.16.27 에서 확인). MAIL_ADMIN_LOCAL_PART 가 다르면 bootstrap 직후 이 계정의 아이디를 바꾼다.
|
||
*/
|
||
const BOOTSTRAP_ADMIN_LOCAL_PART = 'admin';
|
||
/**
|
||
* bootstrap 응답의 무작위 관리자 비밀번호를 "비밀번호 교체 때까지만" 보관하는 곳.
|
||
* 저장소 루트의 data/ 는 내용이 git 에 올라가지 않고(.gitignore), .gitkeep 이 있어 클론한 사용자 소유로 생긴다.
|
||
* 호스트 Node 로 돌리든 컨테이너에 저장소를 붙여 돌리든 같은 자리라 여기에 둔다. 교체가 끝나면 바로 지운다.
|
||
*/
|
||
const STATE_FILE = fileURLToPath(new URL('../../data/mail/bootstrap-admin.json', import.meta.url));
|
||
|
||
const REQUEST_TIMEOUT_MS = 15_000;
|
||
const STARTUP_TIMEOUT_MS = 30_000;
|
||
const RESTART_TIMEOUT_MS = 120_000;
|
||
const POLL_INTERVAL_MS = 2_000;
|
||
const QUERY_PAGE_SIZE = 200;
|
||
const GET_CHUNK_SIZE = 100;
|
||
/** bootstrap → 재시작 → 비밀번호 교체 단계의 반복 한도 (무한 재시작 방지) */
|
||
const MAX_ADMIN_STEPS = 6;
|
||
|
||
const EXIT_ERROR = 1;
|
||
/** 후속 조치가 필요하다 — 안내대로 한 뒤 같은 명령(운영 --recovery 뒤에는 --recovery 없이)으로 다시 실행 */
|
||
const EXIT_FOLLOW_UP = 3;
|
||
|
||
/** 이 스크립트가 만든 객체 표시 — 재실행 때 자기가 만든 것만 정리하기 위해 */
|
||
const MANAGED_MARK = '[setup.mjs]';
|
||
const GATEWAY_ROLE_DESCRIPTION = `메일 게이트웨이 — 대리 접근 전용 ${MANAGED_MARK}`;
|
||
/** 게이트웨이는 로그인과 대리 접근만 한다. 메일함 권한은 대리 접근 대상 직원의 것을 쓴다 */
|
||
const GATEWAY_PERMISSIONS = { authenticate: true, impersonate: true };
|
||
const GATEWAY_DESCRIPTION = `메일 게이트웨이 (백엔드 대리 접근 전용) ${MANAGED_MARK}`;
|
||
const ALLOWED_IP_REASON = `${MANAGED_MARK} 도커 내부망 — 백엔드를 거친 로그인이 자동 차단되지 않게`;
|
||
|
||
/**
|
||
* 사내 목록 계정 아이디 기본값 — 루트 env MAIL_DIRECTORY_LOCAL_PART. 백엔드도 이 값 하나를 읽으므로 기본값이 같아야 한다
|
||
* (형식은 위 LOCAL_PART_PATTERN — MAIL_ADMIN_LOCAL_PART 와 같은 규칙).
|
||
*/
|
||
const DEFAULT_DIRECTORY_LOCAL_PART = 'mail-directory';
|
||
/**
|
||
* 사내 목록 계정 — 백엔드가 엔진의 계정 목록(JMAP Principal)에서 직원 이름 · 주소만 읽는 전용 계정
|
||
* (계약 docs/mail/api-contract.md 3절 "사내 목록"). 역할은 로그인 + 계정 목록 읽기뿐이고 비밀번호가 없다 —
|
||
* 백엔드가 게이트웨이 대리 접근(<사내 목록 주소>%<게이트웨이 주소>)으로만 쓴다. authenticate 가 없으면 대리 접근이 403 이다.
|
||
* ⚠️ 엔진 관리자 역할(System Administrator)에는 jmapPrincipal* 이 없고, 엔진은 역할 · 계정의 권한을 바꿀 때 **바꾼 뒤의 권한 전체**를
|
||
* 바꾸는 쪽이 가졌는지 본다 — 관리자로는 이 역할을 만들 수도, 계정에 줄 수도, 이 역할에서 다른 권한을 뺄 수도 없다
|
||
* ("You are not authorized to grant permissions: jmapPrincipalQuery, jmapPrincipalGet"). 그래서 복구 관리자로만 만들고 맞춘다.
|
||
* 관리자로 되는 것: 계정의 설명 · 저장 한도 고치기, 비밀번호 빼기, 계정 지우기 (2026-10-29 Stalwart 0.16.27 에서 확인 — ensureDirectoryAccount).
|
||
*/
|
||
const DIRECTORY_ROLE_DESCRIPTION = `사내 목록 — 계정 목록 읽기 전용 ${MANAGED_MARK}`;
|
||
const DIRECTORY_PERMISSIONS = { authenticate: true, jmapPrincipalGet: true, jmapPrincipalQuery: true };
|
||
const DIRECTORY_DESCRIPTION = `사내 목록 (백엔드 계정 목록 읽기 전용) ${MANAGED_MARK}`;
|
||
/**
|
||
* 사내 목록 계정의 저장 한도(x:Account 의 quotas — maxDiskQuota, 바이트). 주소가 있으니 메일이 쌓이지 않게 작게 둔다.
|
||
* 역할에 emailReceive 가 없어 엔진은 이 주소로 온 메일을 배달하지 않고 보낸 사람에게 반송한다(security.unauthorized "emailReceive",
|
||
* 2026-10-29 확인) — 한도는 그 위의 두 번째 막이다. 0 은 받기는 하지만 뜻(제한 없음인지)을 확인하지 않아 쓰지 않는다.
|
||
*/
|
||
const DIRECTORY_QUOTAS = { maxDiskQuota: 1024 * 1024 };
|
||
/** 계정의 권한 설정 기본값 — 역할에서만 물려받는다 (계정에 직접 권한을 더하지 않았다) */
|
||
const INHERIT_PERMISSIONS = { '@type': 'Inherit' };
|
||
/** JMAP 계정 목록(Principal) — 메일 쪽 세션의 이 능력 계정으로 Principal/query · get 을 부른다 */
|
||
const PRINCIPALS_CAPABILITY = 'urn:ietf:params:jmap:principals';
|
||
|
||
/**
|
||
* 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)은 받는 쪽 RCPT 단계에서 없는 주소처럼 거절한다.
|
||
* 세 계정의 역할에는 emailReceive 가 없어(관리자의 System Administrator 역할도) 엔진은 RCPT 를 250 으로 받아 놓고, 배달할 때
|
||
* security.unauthorized "emailReceive" 로 거절해 보낸 사람에게 반송(DSN)한다 — 보낸 사람을 위조한 메일이면 모르는 사람에게 반송이 나가
|
||
* 서버 평판이 떨어진다(backscatter). 없는 주소는 처음부터 RCPT 에서 550 이다 (2026-10-29 Stalwart 0.16.27 에서 확인).
|
||
* 방법: x:MtaStageRcpt 의 script 식이 받는 주소가 이 계정들일 때만 아래 시스템 Sieve 스크립트를 고르고, 스크립트는 reject 한다
|
||
* (reject 의 글이 그대로 SMTP 응답이 된다). 직원 · 공용 주소는 식에 걸리지 않아 스크립트가 돌지 않는다.
|
||
* 엔진은 식의 rcpt 를 소문자로 넘기지만 +태그(부주소)는 떼지 않는다 — 그래서 '<아이디>+…@<도메인>' 꼴도 함께 본다.
|
||
* 반영은 ReloadSettings 뒤다 (main 의 reloadSettings — 그 전에는 옛 규칙으로 받는다).
|
||
*/
|
||
/** 엔진이 없는 주소에 주는 응답과 같은 글 — 바깥에서 이 계정들이 있는지 알아볼 수 없게 */
|
||
const SYSTEM_RCPT_REPLY = '550 5.1.2 Mailbox does not exist.';
|
||
const SYSTEM_RCPT_SCRIPT = {
|
||
name: 'arasystem-system-recipients',
|
||
description: `메일을 받지 않는 시스템 계정 — RCPT 에서 거절 ${MANAGED_MARK}`,
|
||
contents: ['require ["reject"];', `reject "${SYSTEM_RCPT_REPLY}";`].join('\r\n'),
|
||
};
|
||
/** x:MtaStageRcpt 의 script 기본값 — RCPT 단계 스크립트 없음 */
|
||
const RCPT_SCRIPT_UNSET = { match: {}, else: 'false' };
|
||
/** 식에 그대로 넣는 주소의 꼴 — 따옴표 · 공백 · 식 문법이 들어갈 수 없게 (아이디는 loadConfig 가 이미 LOCAL_PART_PATTERN 으로 봤다) */
|
||
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;
|
||
|
||
/** 개발 전용 — 사내 도메인 밖 메일을 받는 Mailpit (docker-compose.dev.yml 의 mailpit 서비스) */
|
||
const DEV_RELAY = {
|
||
name: 'mailpit',
|
||
description: `개발 전용 — 사내 도메인 밖 메일을 Mailpit 으로 ${MANAGED_MARK}`,
|
||
address: 'mailpit',
|
||
port: 1025,
|
||
};
|
||
/** 개발 전용 — 사내 도메인은 로컬 배달, 나머지는 전부 Mailpit 으로 (= baseOutboundRoute 의 else 가 'mailpit' 인 꼴) */
|
||
const DEV_OUTBOUND_ROUTE = {
|
||
match: { 0: { if: 'is_local_domain(rcpt_domain)', then: "'local'" } },
|
||
else: `'${DEV_RELAY.name}'`,
|
||
};
|
||
/** 개발 전용 테스트 직원 계정 — 비밀번호는 MAIL_DEV_USER_PASSWORD 공통 */
|
||
const DEV_USERS = [
|
||
{ name: 'ara.kim', description: '김아라' },
|
||
{ name: 'doyun.lee', description: '이도윤' },
|
||
{ 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' };
|
||
|
||
/*
|
||
* 메일 엔진 전용 DNS 리졸버 (docker-compose.yml 의 mail-resolver — unbound · mail/README.md "DNS 리졸버와 스팸 차단 목록")
|
||
* 엔진의 DNS 조회(DNSBL · SPF · DKIM · DMARC · MX · PTR · TLSA)를 공용 · ISP DNS 가 아니라 이 서버에서 직접 푼다 — Spamhaus 는 공용 · ISP DNS 를
|
||
* 거친 조회를 거부한다(127.255.255.254). 엔진 쪽 설정은 x:DnsResolver 싱글톤 하나다 — 0.16.27 의 @type 은 System · Custom · Cloudflare · Quad9 · Google,
|
||
* Custom 은 servers { "<n>": { protocol udp|tcp|tls, address(IP 만), port } } 와 공통 칸(attempts · timeout · enableEdns …). @type 만 바꾸는 부분 갱신을 받고
|
||
* 공통 칸은 그대로 둔다 (2026-10-30 개발 엔진에서 읽고 바꿔 확인 — 주소가 IP 가 아니면 invalidPatch). 바뀐 값은 ReloadSettings 뒤에 반영된다
|
||
* (리로드가 엔진 설정 전체를 다시 만든다 — 소스 common/src/cache/reload.rs).
|
||
* 운영 Custom — 리졸버 주소의 UDP + TCP. 엔진은 TCP 연결이 있어야 그 리졸버로 DNSSEC 을 믿는다(없으면 DANE 을 끈다 — 소스
|
||
* common/src/config/smtp/resolver.rs 의 ensure_dnssec). 도커 DNS 를 거치지 않으므로 도커 내부 이름(backend · db …)은 엔진에 풀리지 않는다 —
|
||
* 바깥 도메인의 MX 가 내부 이름을 가리켜도 닿지 않는다(사설 주소로 풀리는 바깥 이름은 리졸버가 버린다 — unbound.conf 의 private-address)
|
||
* 개발 System — 컨테이너의 resolv.conf(도커 DNS 127.0.0.11). Mailpit 발송 경로(mailpit) 같은 도커 내부 이름은 도커 DNS 가 풀고, 바깥 이름은 compose 의
|
||
* dns(= 이 리졸버)로 넘긴다 — DNSSEC 자료(RRSIG)도 UDP · TCP 그대로 넘긴다 (2026-10-30 Docker 28.5 에서 확인)
|
||
*/
|
||
const DEFAULT_RESOLVER_SUBNET = '172.16.73.0/24';
|
||
const DEFAULT_RESOLVER_IP = '172.16.73.254';
|
||
/** 리졸버 네트워크의 접두 길이 — /29 면 도커 게이트웨이 · 리졸버 · mail 이 들어간다. 넓게 잡을 까닭이 없다 (앱 네트워크와 같은 상한) */
|
||
const RESOLVER_SUBNET_PREFIX_RANGE = { widest: 24, narrowest: 29 };
|
||
const RESOLVER_PORT = 53;
|
||
/** 리졸버 컨테이너 · 네트워크 이름의 뒷부분 — `${PROJECT_NAME}-<서비스>` · `${PROJECT_NAME}_<네트워크>` (docker-compose.yml) */
|
||
const RESOLVER_SERVICE = 'mail-resolver';
|
||
const RESOLVER_NETWORK = 'mail-dns';
|
||
const RESOLVER_EGRESS_NETWORK = 'mail-dns-egress';
|
||
/**
|
||
* 리졸버 확인(메일 컨테이너 안에서 묻기) 한 번의 기다림(초) — unbound 는 답하지 않는 상대에게 몇 번 다시 물은 뒤 SERVFAIL 을 준다
|
||
* (DQS 의 틀린 키는 5초 남짓 뒤 SERVFAIL — 2026-10-30)
|
||
*/
|
||
const RESOLVER_PROBE_TIMEOUT_SECONDS = 12;
|
||
/**
|
||
* 리졸버 확인이 DNSSEC 자료를 청할 때 알리는 UDP 응답 크기(EDNS) — unbound.conf 의 edns-buffer-size 와 같은 1232(DNS Flag Day 2020 권장값).
|
||
* 이보다 큰 UDP 응답은 잘린다(TC) — 그래서 UDP 로는 크기가 작은 질문만 한다 (RESOLVER_DNSSEC_PROBES)
|
||
*/
|
||
const RESOLVER_PROBE_EDNS_SIZE = 1232;
|
||
/**
|
||
* 리졸버가 DNSSEC 을 검증하는지 메일 컨테이너에서 묻는 질문 — 루트(.)에 DNSSEC 자료(DO)와 함께. 검증한 답(ad)이고 서명(RRSIG)이 와야 한다.
|
||
* UDP 루트 SOA — 389B 안팎(2026-10-30). 루트 DNSKEY(1,139B)는 키 교체(ZSK 미리 올리기 · KSK 폐기) 동안 1,400B 를 넘어 UDP 로는 잘린다
|
||
* (TC — 답 0 · 서명 0). 엔진은 그때 TCP 로 다시 묻는다 — UDP 로 DNSKEY 를 물으면 멀쩡한 리졸버를 막게 된다
|
||
* TCP 루트 DNSKEY — 엔진의 기동 확인과 같은 질문(소스 common/src/config/smtp/resolver.rs 의 ensure_dnssec — TCP 연결이 있고 루트 DNSKEY 가
|
||
* 검증되어야 DANE 을 켠다). TCP 는 크기 한도가 없어 키 교체와 무관하다
|
||
*/
|
||
const RESOLVER_DNSSEC_PROBES = [
|
||
{ protocol: 'udp', type: 'SOA' },
|
||
{ protocol: 'tcp', type: 'DNSKEY' },
|
||
];
|
||
/** 엔진 기동 로그에서 찾는 경고 — 리졸버로 DNSSEC 을 믿지 못해 DANE 을 껐다 (소스 common/src/config/smtp/mod.rs, registry.build-warning) */
|
||
const DNSSEC_UNAVAILABLE_LOG = 'cannot validate DNSSEC';
|
||
/** 엔진 기동 로그의 첫 줄(info) — 기동 로그 범위에 이것이 없으면 그 기동의 로그가 남아 있지 않다(로그 회전 등). 경고가 없다고 말할 수 없다 */
|
||
const ENGINE_STARTUP_LOG = '(server.startup)';
|
||
/** 기동 로그를 볼 범위 — 컨테이너가 뜬 뒤 이만큼 (설정을 만들 때 바로 남는다) */
|
||
const STARTUP_LOG_WINDOW_MS = 120_000;
|
||
|
||
/*
|
||
* Spamhaus — 스팸 필터의 DNSBL 항목 둘(엔진 기본값 — 이름으로 찾는다). 공개 미러(zen.spamhaus.org · dbl.spamhaus.org)는 소규모 · 비상업 용도라
|
||
* 이 서버는 Spamhaus 의 "Blocklists via DNS Query"(옛 Data Query Service — DQS) 키로 묻거나 두 항목을 끈다 (mail/README.md).
|
||
* DQS 조회 이름은 <거꾸로 쓴 IP 또는 도메인>.<키>.<zen|dbl>.dq.spamhaus.net 이고 응답 코드는 공개 미러와 같다 — tag 식은 그대로 둔다
|
||
* (Spamhaus 문서 "Migration from Spamhaus Legacy Public Mirrors to Blocklists via DNS Query" · "Testing", 2026-10-30 확인).
|
||
* 키가 있으면 메일 컨테이너에서 리졸버로 시험 항목이 나오는지 먼저 본 뒤에만 켠다 — 안 나오면 끄고 실패로 알린다(닫힌 쪽).
|
||
* 키가 없으면 끈다(운영 [주의] · 개발 [참고] — 개발도 운영처럼 공개 미러를 쓰지 않는다). 끌 때는 조회 이름 식도 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게.
|
||
* ⚠️ 키는 엔진 설정(zone 식)에 그대로 들어간다 — 관리 화면 · 관리 API(x:SpamDnsblServer/get) · 메일 볼륨 백업으로 보인다. setup 은 키를 출력하지 않는다(식 · 옛 식도 —
|
||
* 엔진 응답 · 오류 글에 실려 와도 출력 한 곳에서 가린다, hiddenInOutput).
|
||
* 엔진은 DNSBL 조회 이름(키 포함)을 debug 수준에서만 남긴다 — 답(spam.dnsbl)도, 실패(spam.dnsbl-error — Hostname 에 조회 이름 전체 · CausedBy 에 리졸버 오류)도
|
||
* 따로 정한 수준이 없어 기본값 debug 다. 그래서 키가 멈춰 조회가 모두 실패해도 setup 이 둔 info 로그에는 남지 않는다
|
||
* (0.16.27 소스 crates/trc/src/event/enums_impl.rs 의 EventType::level 마지막 갈래 `_ => Level::Debug` · crates/spam-filter/src/modules/dnsbl.rs, 2026-10-30 확인).
|
||
*/
|
||
const SPAMHAUS_DQS_KEY_ENV = 'MAIL_SPAMHAUS_DQS_KEY';
|
||
/** Spamhaus 가 주는 키 — 26자 영문 · 숫자(소문자로 맞춘다). 엔진의 식에 그대로 넣으므로 그 밖의 글자는 받지 않는다 */
|
||
const SPAMHAUS_DQS_KEY_PATTERN = /^[a-z0-9]{26}$/;
|
||
/**
|
||
* 엔진 기본값(0.16.27)의 두 항목 — subject 는 식에서 조회할 값(IP 는 거꾸로 쓴 꼴), test 는 늘 등록된 시험 항목과 그 응답
|
||
* (2.0.0.127 → 127.0.0.2(SBL) · 127.0.0.4 · 127.0.0.10, dbltest.com → 127.0.1.2 — 2026-10-30 이 리졸버로 공개 미러에서 확인)
|
||
*/
|
||
const SPAMHAUS_DNSBL = [
|
||
{
|
||
name: 'STWT_RBL_SPAMHAUS_IP',
|
||
label: 'Spamhaus ZEN(IP)',
|
||
short: 'ZEN',
|
||
subject: 'ip_reverse',
|
||
publicZone: 'zen.spamhaus.org',
|
||
dqsZone: 'zen.dq.spamhaus.net',
|
||
test: { name: '2.0.0.127', expect: '127.0.0.2' },
|
||
},
|
||
{
|
||
name: 'STWT_DBL_SPAMHAUS_DOMAIN',
|
||
label: 'Spamhaus DBL(도메인)',
|
||
short: 'DBL',
|
||
subject: 'value',
|
||
publicZone: 'dbl.spamhaus.org',
|
||
dqsZone: 'dbl.dq.spamhaus.net',
|
||
test: { name: 'dbltest.com', expect: '127.0.1.2' },
|
||
},
|
||
];
|
||
|
||
/*
|
||
* 엔진 웹훅 — 메일 프로그램(IMAP · SMTP) 로그인 기록 (12단계 — 계약 docs/mail/api-contract.md 5절 "엔진 웹훅 받기" · "엔진 설정", mail/README.md "엔진 웹훅")
|
||
* 공개판 엔진에는 계정별 로그인 기록이 없어(x:Trace 는 엔터프라이즈 전용) 엔진이 인증 이벤트를 백엔드로 보내고 백엔드가 남긴다. 백엔드는 접속 주소(소켓 —
|
||
* 앱 네트워크 대역)와 본문 원문의 HMAC-SHA256(X-Signature — 키 MAIL_WEBHOOK_SECRET)을 보고 받는다.
|
||
* 엔진 쪽은 x:WebHook 하나다. 0.16.27 의 칸은 url · signatureKey(None · Value · EnvironmentVariable · File) · httpAuth · httpHeaders · throttle · timeout ·
|
||
* discardAfter · lossy · level · events · eventsPolicy · enable · allowInvalidCerts 이고 **이름 · 설명 칸이 없다**(소스 crates/registry/src/schema/structs.rs 의 WebHook) —
|
||
* setup 표시는 httpHeaders 의 머리글 하나(WEBHOOK_MARKER_HEADER)로 둔다. 읽으면 서명 키는 "****" 로만 온다(소스 schema/prelude.rs 의 secret_value) —
|
||
* 키가 바뀌었는지 알려고 그 머리글에 키의 지문(scrypt)을 함께 적는다. 칸에 객체를 통째로 보내면 events · httpHeaders 는 통째로 바뀐다(소스 types/map.rs · jmap/patch.rs).
|
||
* 엔진 기본값은 eventsPolicy exclude + 빈 목록(= 모든 이벤트) · level info · timeout 30초다 — 셋 다 바꾼다. level 은 이 판의 웹훅이 쓰지 않지만
|
||
* (소스 crates/common/src/config/telemetry.rs — apply_events 만) 새 판 문서는 쓴다(info 면 Debug 인 auth.failed · Trace 인 auth.mfa-required 가 빠진다).
|
||
* 바뀐 값은 ReloadSettings 뒤에 반영된다(2026-10-31 개발 엔진 실험 — 계약 5절의 근거).
|
||
* 웹훅의 HTTP 는 엔진의 x:DnsResolver 가 아니라 메일 컨테이너의 시스템 DNS(도커 DNS 127.0.0.11)로 이름을 푼다 — reqwest 의 기본 리졸버(hickory 기능을 켜지 않았다,
|
||
* crates/utils/Cargo.toml · src/http.rs). 그래서 운영처럼 엔진 DNS 가 Custom(리졸버에 바로 — 도커 이름을 모른다)이어도 backend 가 풀린다.
|
||
*/
|
||
const WEBHOOK_SECRET_ENV = 'MAIL_WEBHOOK_SECRET';
|
||
/** 서명 키 — 영문 · 숫자 32자 이상. Node --env-file 은 # 부터 자르고 compose 는 그대로 읽어 setup 과 backend 의 값이 어긋나지 않게 (계약 5절 "환경 변수") */
|
||
const WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
|
||
/** 받는 곳 — compose 의 서비스 이름 backend. mail 과 backend 가 함께 붙는 네트워크는 app-network 하나라 엔진의 접속 주소가 그 대역이다(백엔드가 소켓 주소로 확인한다) */
|
||
const WEBHOOK_HOST = 'backend';
|
||
const WEBHOOK_PATH = '/api/internal/mail-engine-events';
|
||
/** 보내는 이벤트 — 엔진 기본(exclude + 빈 목록)은 모든 이벤트다 */
|
||
const WEBHOOK_EVENTS = ['auth.success', 'auth.failed', 'auth.mfa-required'];
|
||
/** 그 밖의 칸 — 묶음 1초 · 기다림 5초(백엔드는 곧바로 200) · 못 보낸 것은 5분까지 다시 보내고 버린다 · 대기열이 넘쳐도 버리지 않는다 · 수준 trace(위 머리말) */
|
||
const WEBHOOK_SETTINGS = { throttle: 1000, timeout: 5000, discardAfter: 300000, lossy: false, level: 'trace', eventsPolicy: 'include', allowInvalidCerts: false };
|
||
const WEBHOOK_HTTP_AUTH = { '@type': 'Unauthenticated' };
|
||
/** setup 표시 머리글 — 값은 `[setup.mjs] key=<소금>.<키 지문>`. 엔진이 보내는 요청마다 실린다 — 받는 쪽(백엔드)은 쓰지 않는다 */
|
||
const WEBHOOK_MARKER_HEADER = 'X-Arasystem-Setup';
|
||
/**
|
||
* 키 지문 — scrypt(키, 소금)의 12바이트 hex. 소금은 setup 이 웹훅을 쓸 때마다 새로 뽑는 12바이트(배포마다 다르다)이고 지문과 함께 머리글에 싣는다.
|
||
* 지문은 엔진 관리 화면 · 관리 API 에 보이고 백엔드로도 간다(백엔드는 키를 이미 안다). 키는 무작위(openssl rand -hex 24)여야 지문으로 되짚을 수 없다 —
|
||
* 사람이 지은 키를 쓰지 않는다. 짧은 키라도 한 번 견주는 데 수십 ms 가 들게 평범한 해시가 아니라 scrypt 이고, 소금이 배포마다 달라 미리 만든 표로 맞춰 볼 수 없다
|
||
*/
|
||
const WEBHOOK_KEY_SALT_BYTES = 12;
|
||
const WEBHOOK_KEY_FINGERPRINT_BYTES = 12;
|
||
/** 표시 머리글의 모양 — 소금 · 지문 모두 24자 hex. 옛 모양(`key=<지문>` — 소금 없음)은 맞지 않아 키가 바뀐 것으로 보고 한 번 맞춘다 */
|
||
const WEBHOOK_MARKER_PATTERN = /^\[setup\.mjs\] key=([0-9a-f]{24})\.([0-9a-f]{24})$/;
|
||
/** setup 이 받는 곳까지 닿는지 볼 때 보내는 묶음 — 엔진의 본문과 같은 꼴에 이벤트만 없다. 서명은 setup 이 같은 키로 만든다(받는 쪽은 200 으로 답한다 — 남길 것이 없다) */
|
||
const WEBHOOK_PROBE_BODY = '{"events":[]}';
|
||
/** 엔진이 맞는 비밀번호에 2단계 인증 코드를 더 요구할 때의 응답 — 402 "MFA code required"(틀린 비밀번호는 401 — 2026-10-31 개발 엔진 0.16.27, 계약 5절) */
|
||
const HTTP_MFA_REQUIRED = 402;
|
||
|
||
/** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */
|
||
const PLACEHOLDER_PATTERN = /change[-_]?me/i;
|
||
|
||
// ============================================================
|
||
// 오류 · 출력
|
||
// ============================================================
|
||
|
||
class SetupError extends Error {
|
||
constructor(message, { detail, hint } = {}) {
|
||
super(message);
|
||
this.detail = detail;
|
||
this.hint = hint;
|
||
}
|
||
}
|
||
|
||
class JmapMethodError extends SetupError {
|
||
constructor(method, error) {
|
||
super(`${method} 실패: ${error.type}${error.description ? ` — ${error.description}` : ''}`);
|
||
this.type = error.type;
|
||
this.description = error.description ?? '';
|
||
}
|
||
}
|
||
|
||
/** 재시작이 필요한데 이 환경에서 docker 를 쓸 수 없다 */
|
||
class RestartRequired extends Error {
|
||
constructor(reason, container) {
|
||
super(reason);
|
||
this.container = container;
|
||
}
|
||
}
|
||
|
||
/** 끝까지 진행한 뒤 실패로 보고할 문제 (예: 백엔드 주소가 신뢰 대역 밖) */
|
||
const problems = [];
|
||
|
||
/**
|
||
* 출력에서 가릴 비밀 — [찾을 식, 대신 찍을 글]. loadConfig 가 Spamhaus 키를 넣는다.
|
||
* setup 이 만든 글에는 키를 넣지 않지만, 엔진 응답(오류 본문 · 거절 사유의 description)에 실려 올 수 있어 출력 한 곳에서 한 번 더 가린다 —
|
||
* log · warn · main 의 오류 출력이 모두 redact 를 거친다
|
||
*/
|
||
const hiddenInOutput = [];
|
||
|
||
/** 이 값을 출력에서 placeholder 로 바꿔 찍는다 (대소문자 무시 — 엔진에는 소문자로 들어간다) */
|
||
function hideInOutput(secret, placeholder) {
|
||
hiddenInOutput.push([new RegExp(secret.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'), placeholder]);
|
||
}
|
||
|
||
function redact(text) {
|
||
return hiddenInOutput.reduce((out, [pattern, placeholder]) => out.replace(pattern, placeholder), String(text));
|
||
}
|
||
|
||
const log = (message) => console.log(redact(message));
|
||
const warn = (message) => console.warn(redact(`[주의] ${message}`));
|
||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||
|
||
// ============================================================
|
||
// 설정 (루트 .env.{APP_ENV})
|
||
// ============================================================
|
||
|
||
function readEnv(key) {
|
||
const value = process.env[key]?.trim();
|
||
return value ? value : undefined;
|
||
}
|
||
|
||
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 (BACKEND_PORT — 엔진 웹훅의 주소) */
|
||
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|BACKEND_PORT|MAIL_\w+|MAILPIT_\w+)$/;
|
||
|
||
/** Node --env-file 과 같은 규칙으로 읽는다 (따옴표 없는 값은 # 부터 주석 — 공백이 없어도) */
|
||
function parseEnvText(text) {
|
||
const entries = new Map();
|
||
for (const raw of text.split(/\r?\n/)) {
|
||
const line = raw.trim();
|
||
if (!line || line.startsWith('#')) continue;
|
||
const match = /^(?:export\s+)?([\w.-]+)\s*=\s*(.*)$/.exec(line);
|
||
if (!match) continue;
|
||
let value = match[2].trim();
|
||
const quote = value[0];
|
||
if ((quote === '"' || quote === "'" || quote === '`') && value.length >= 2 && value.endsWith(quote)) {
|
||
value = value.slice(1, -1);
|
||
} else {
|
||
value = value.replace(/#.*$/, '').trim();
|
||
}
|
||
entries.set(match[1], value);
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
/** --env-file 로 받은 파일들의 값 (뒤 파일이 이긴다) */
|
||
function readEnvFiles() {
|
||
const files = process.execArgv.flatMap((arg, index, all) => {
|
||
if (arg.startsWith('--env-file=')) return [arg.slice('--env-file='.length)];
|
||
if (arg === '--env-file' && all[index + 1]) return [all[index + 1]];
|
||
return [];
|
||
});
|
||
const fromFiles = new Map();
|
||
for (const file of files) {
|
||
try {
|
||
for (const [key, value] of parseEnvText(readFileSync(file, 'utf8'))) fromFiles.set(key, value);
|
||
} catch {
|
||
// 읽을 수 없는 파일은 Node 가 이미 오류를 냈을 것이다
|
||
}
|
||
}
|
||
return fromFiles;
|
||
}
|
||
|
||
/**
|
||
* Node 의 --env-file 은 셸에 이미 있는 환경변수를 덮어쓰지 않는다.
|
||
* 셸에 APP_ENV=development 가 남은 채 --env-file=.env.production 으로 실행하면
|
||
* 운영 서버에 개발용 설정(Mailpit 발송 경로 등)을 넣게 된다 → 값이 어긋나면 중단한다.
|
||
* 복구 관리자(MAIL_RECOVERY_ADMIN)는 원래 셸에서 일회성으로 주는 값이라 검사하지 않는다.
|
||
* exempt — 그 밖에 검사하지 않을 키 (개발의 시범 공존 값 — loadConfig)
|
||
*/
|
||
function findShellOverrides(fromFiles, exempt = []) {
|
||
return [...fromFiles]
|
||
.filter(
|
||
([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();
|
||
// 개발의 시범 공존 값은 셸에서 일회성으로 받아도 된다 (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:
|
||
'Node 의 --env-file 은 이미 있는 환경변수를 덮어쓰지 않아 의도와 다른 설정으로 실행됩니다. ' +
|
||
`해당 변수를 지우고 다시 실행하세요 (bash: unset ${overridden.join(' ')} · PowerShell: Remove-Item Env:${overridden[0]}).`,
|
||
});
|
||
}
|
||
|
||
const errors = [];
|
||
const warnings = [];
|
||
const need = (key) => {
|
||
const value = readEnv(key);
|
||
if (value === undefined) errors.push(`${key} 가 설정되지 않았습니다.`);
|
||
return value ?? '';
|
||
};
|
||
|
||
const appEnv = readEnv('APP_ENV');
|
||
if (appEnv !== 'development' && appEnv !== 'production') {
|
||
errors.push(
|
||
`APP_ENV 는 development 또는 production 이어야 합니다 (현재: ${appEnv ?? '없음'}). ` +
|
||
'--env-file=.env.{APP_ENV} 를 빠뜨리지 않았는지 확인하세요.',
|
||
);
|
||
}
|
||
const isProduction = appEnv === 'production';
|
||
|
||
const projectName = need('PROJECT_NAME');
|
||
const domain = need('MAIL_DOMAIN').toLowerCase();
|
||
const hostname = need('MAIL_HOSTNAME').toLowerCase();
|
||
const adminPassword = need('MAIL_ADMIN_PASSWORD');
|
||
const gatewayAddress = need('MAIL_GATEWAY_USER').toLowerCase();
|
||
const gatewayPassword = need('MAIL_GATEWAY_PASSWORD');
|
||
const devUserPassword = isProduction ? undefined : need('MAIL_DEV_USER_PASSWORD');
|
||
|
||
const at = gatewayAddress.indexOf('@');
|
||
const gatewayLocal = at > 0 ? gatewayAddress.slice(0, at) : '';
|
||
if (gatewayAddress && (!LOCAL_PART_PATTERN.test(gatewayLocal) || gatewayAddress.slice(at + 1) !== domain)) {
|
||
errors.push(`MAIL_GATEWAY_USER 는 <이름>@${domain || 'MAIL_DOMAIN'} 형식의 주소여야 합니다 (현재: ${gatewayAddress}).`);
|
||
}
|
||
|
||
// 영구 관리자 아이디 — 백엔드가 같은 값으로 웹메일 로그인·대리 접근을 막는다. bootstrap 전에만 정할 수 있다
|
||
const adminLocalRaw = readEnv('MAIL_ADMIN_LOCAL_PART') ?? DEFAULT_ADMIN_LOCAL_PART;
|
||
const adminLocalPart = adminLocalRaw.toLowerCase();
|
||
if (!LOCAL_PART_PATTERN.test(adminLocalPart)) {
|
||
errors.push(
|
||
`MAIL_ADMIN_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${adminLocalRaw}").`,
|
||
);
|
||
} else if (adminLocalPart === gatewayLocal) {
|
||
errors.push('MAIL_ADMIN_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.');
|
||
} else if (!isProduction && DEV_USERS.some((user) => user.name === adminLocalPart)) {
|
||
errors.push(`MAIL_ADMIN_LOCAL_PART(${adminLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
|
||
}
|
||
|
||
// 사내 목록 계정 아이디 — 백엔드가 같은 값으로 그 계정을 대리 접근해 계정 목록을 읽는다 (ensureDirectoryAccount)
|
||
const directoryLocalRaw = readEnv('MAIL_DIRECTORY_LOCAL_PART') ?? DEFAULT_DIRECTORY_LOCAL_PART;
|
||
const directoryLocalPart = directoryLocalRaw.toLowerCase();
|
||
if (!LOCAL_PART_PATTERN.test(directoryLocalPart)) {
|
||
errors.push(
|
||
`MAIL_DIRECTORY_LOCAL_PART 는 영문·숫자·. _ - 만, 앞뒤는 영문·숫자, 64자 이하인 아이디여야 합니다 — @·%·:·공백 불가 (현재: "${directoryLocalRaw}").`,
|
||
);
|
||
} else if (directoryLocalPart === adminLocalPart) {
|
||
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 관리자 아이디(MAIL_ADMIN_LOCAL_PART)와 같습니다. 서로 다른 아이디를 쓰세요.');
|
||
} else if (directoryLocalPart === gatewayLocal) {
|
||
errors.push('MAIL_DIRECTORY_LOCAL_PART 가 게이트웨이 아이디(MAIL_GATEWAY_USER)와 같습니다. 서로 다른 아이디를 쓰세요.');
|
||
} else if (!isProduction && DEV_USERS.some((user) => user.name === directoryLocalPart)) {
|
||
errors.push(`MAIL_DIRECTORY_LOCAL_PART(${directoryLocalPart}) 가 개발용 테스트 직원 아이디와 겹칩니다.`);
|
||
}
|
||
|
||
// 백엔드가 로그인·대리 접근을 막는 계정 (관리자 외) — 관리자 권한 계정이 빠지지 않았는지 확인하는 데만 쓴다
|
||
const blockedAccounts = (readEnv('MAIL_BLOCKED_ACCOUNTS') ?? '')
|
||
.split(',')
|
||
.map((item) => item.trim().toLowerCase())
|
||
.filter(Boolean);
|
||
|
||
// bootstrap/recovery 전용 임시 관리자 "아이디:비밀번호".
|
||
// 값이 있는 동안은 정상 모드에서도 관리자로 로그인되므로, 운영은 env 파일에 두지 않고 --recovery 실행 때만 셸에서 준다
|
||
const recoveryFlag = argv.includes('--recovery');
|
||
let recovery = null;
|
||
const recoveryRaw = readEnv(RECOVERY_ENV_KEY);
|
||
if (recoveryRaw) {
|
||
const sep = recoveryRaw.indexOf(':');
|
||
if (sep <= 0 || sep === recoveryRaw.length - 1) {
|
||
errors.push(`${RECOVERY_ENV_KEY} 는 "아이디:비밀번호" 형식이어야 합니다.`);
|
||
} else {
|
||
recovery = { user: recoveryRaw.slice(0, sep), password: recoveryRaw.slice(sep + 1) };
|
||
}
|
||
}
|
||
if (isProduction) {
|
||
if (fromFiles.get(RECOVERY_ENV_KEY)?.trim()) {
|
||
errors.push(
|
||
`운영 env 파일에 ${RECOVERY_ENV_KEY} 가 들어 있습니다. 파일에서 지우고, 최초 설정·관리자 복구 때만 ` +
|
||
'셸에서 일회성으로 주고 --recovery 로 실행하세요 (mail/README.md "운영 배포").',
|
||
);
|
||
} else if (recoveryRaw && !recoveryFlag) {
|
||
errors.push(
|
||
`${RECOVERY_ENV_KEY} 가 설정되어 있습니다. 최초 설정·관리자 복구라면 --recovery 를 붙여 실행하고, ` +
|
||
`아니면 셸에서 지우세요 (unset ${RECOVERY_ENV_KEY}).`,
|
||
);
|
||
} else if (recoveryFlag && !recoveryRaw) {
|
||
errors.push(`--recovery 에는 ${RECOVERY_ENV_KEY}="아이디:비밀번호" 를 셸에서 일회성으로 함께 줘야 합니다.`);
|
||
}
|
||
}
|
||
|
||
// 신뢰 대역 — 고정한 앱 네트워크 서브넷, 또는 그 안의 더 좁은 대역만 허용한다 (더 넓으면 외부 접속까지 믿게 된다)
|
||
const appNetworkSubnet = readEnv('APP_NETWORK_SUBNET') ?? DEFAULT_APP_NETWORK_SUBNET;
|
||
const subnetProblem = describeSubnetProblem(appNetworkSubnet);
|
||
const subnetValid = parseCidr(appNetworkSubnet) !== null;
|
||
if (subnetProblem) errors.push(`APP_NETWORK_SUBNET(${appNetworkSubnet}) — ${subnetProblem}`);
|
||
const trustedNetworks = (readEnv('MAIL_TRUSTED_NETWORKS') ?? appNetworkSubnet)
|
||
.split(',')
|
||
.map((item) => item.trim())
|
||
.filter(Boolean);
|
||
if (trustedNetworks.length === 0) errors.push('MAIL_TRUSTED_NETWORKS 가 비어 있습니다.');
|
||
for (const network of trustedNetworks) {
|
||
if (parseCidr(network) === null) {
|
||
errors.push(`MAIL_TRUSTED_NETWORKS 항목은 IPv4 CIDR 이어야 합니다 (앱 네트워크는 IPv4): ${network}`);
|
||
} else if (subnetValid && !cidrWithin(network, appNetworkSubnet)) {
|
||
errors.push(
|
||
`MAIL_TRUSTED_NETWORKS 의 ${network} 가 APP_NETWORK_SUBNET(${appNetworkSubnet}) 보다 넓거나 그 밖에 있습니다. ` +
|
||
'신뢰 대역은 앱 네트워크 안으로만 좁힐 수 있습니다 — 비우면 APP_NETWORK_SUBNET 과 같습니다.',
|
||
);
|
||
}
|
||
}
|
||
|
||
// 메일 전용 DNS 리졸버 — compose 의 `${MAIL_RESOLVER_*:-…}` 와 같은 기본값 (preflight 의 checkResolver · ensureEngineDns)
|
||
const resolverSubnet = readEnv('MAIL_RESOLVER_SUBNET') ?? DEFAULT_RESOLVER_SUBNET;
|
||
const resolverIp = readEnv('MAIL_RESOLVER_IP') ?? DEFAULT_RESOLVER_IP;
|
||
errors.push(...describeResolverProblems(resolverSubnet, resolverIp, appNetworkSubnet));
|
||
|
||
// Spamhaus 조회 키 (선택) — 있으면 Spamhaus 두 항목을 DQS 로 묻고, 없으면 끈다 (ensureSpamhausDnsbl). 값은 어디에도 출력하지 않는다
|
||
const spamhausKeyRaw = readEnv(SPAMHAUS_DQS_KEY_ENV);
|
||
let spamhausKey = null;
|
||
if (spamhausKeyRaw) {
|
||
if (SPAMHAUS_DQS_KEY_PATTERN.test(spamhausKeyRaw.toLowerCase())) {
|
||
spamhausKey = spamhausKeyRaw.toLowerCase();
|
||
// 엔진 응답 · 오류 글에 실려 와도 출력에는 이름만 (redact)
|
||
hideInOutput(spamhausKey, `<${SPAMHAUS_DQS_KEY_ENV}>`);
|
||
} else {
|
||
errors.push(`${SPAMHAUS_DQS_KEY_ENV} 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다 (지금 ${spamhausKeyRaw.length}자 — 값은 출력하지 않는다).`);
|
||
}
|
||
// 운영은 env 파일에만 — Spamhaus 를 켜고 끄는 것은 엔진에 남는 설정이라 파일만 보고도 알 수 있어야 하고, 셸에만 있으면 다음 실행이 끈다
|
||
// (파일에 같은 키가 있으면서 값이 다른 것은 위 findShellOverrides 가 이미 막았다 — 여기는 파일에 없는 경우)
|
||
if (isProduction && fromFiles.get(SPAMHAUS_DQS_KEY_ENV)?.trim() !== spamhausKeyRaw) {
|
||
errors.push(
|
||
`운영의 ${SPAMHAUS_DQS_KEY_ENV} 는 셸이 아니라 .env.production 에 둡니다 — 셸에만 두면 그 실행만 켜지고 다음 실행이 끈다. ` +
|
||
`파일에 적고 셸에서 지운 뒤 다시 실행하세요 (unset ${SPAMHAUS_DQS_KEY_ENV}).`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 엔진 웹훅 서명 키 (12단계) — setup 과 backend 가 같은 값을 쓴다. 운영 필수, 개발은 비면 웹훅을 끈다(ensureEngineWebhook). 값은 어디에도 출력하지 않는다
|
||
const webhookSecretRaw = readEnv(WEBHOOK_SECRET_ENV);
|
||
let webhookSecret = null;
|
||
if (webhookSecretRaw) {
|
||
if (WEBHOOK_SECRET_PATTERN.test(webhookSecretRaw)) {
|
||
webhookSecret = webhookSecretRaw;
|
||
// 엔진 응답 · 오류 글에 실려 와도 출력에는 이름만 (redact)
|
||
hideInOutput(webhookSecret, `<${WEBHOOK_SECRET_ENV}>`);
|
||
} else {
|
||
errors.push(
|
||
`${WEBHOOK_SECRET_ENV} 는 영문 · 숫자로만 32자 이상이어야 합니다 (지금 ${webhookSecretRaw.length}자 — 값은 출력하지 않는다). ` +
|
||
'예: openssl rand -hex 24 — 바꾸면 backend 도 다시 띄운다.',
|
||
);
|
||
}
|
||
} else if (isProduction) {
|
||
errors.push(
|
||
`${WEBHOOK_SECRET_ENV} 가 설정되지 않았습니다 — 운영 필수(엔진 웹훅의 서명 키 · backend 와 같은 값 — 메일 프로그램 로그인 기록). ` +
|
||
'예: openssl rand -hex 24 (mail/README.md "엔진 웹훅").',
|
||
);
|
||
}
|
||
// 웹훅이 부를 백엔드 포트 — compose 의 backend PORT(=BACKEND_PORT)와 같다. 웹훅을 둘 때만 본다
|
||
const backendPortRaw = readEnv('BACKEND_PORT');
|
||
let backendPort = null;
|
||
if (webhookSecret) {
|
||
if (backendPortRaw && PORT_PATTERN.test(backendPortRaw) && Number(backendPortRaw) <= MAX_PORT) {
|
||
backendPort = Number(backendPortRaw);
|
||
} else {
|
||
errors.push(
|
||
`BACKEND_PORT 는 1~${MAX_PORT} 의 10진수(앞자리 0 없이)여야 합니다 — 엔진 웹훅의 주소 http://${WEBHOOK_HOST}:<BACKEND_PORT>${WEBHOOK_PATH} 에 쓴다 ` +
|
||
`(현재: ${backendPortRaw ?? '없음'}).`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
|
||
const secrets = {
|
||
MAIL_ADMIN_PASSWORD: adminPassword,
|
||
MAIL_GATEWAY_PASSWORD: gatewayPassword,
|
||
MAIL_RECOVERY_ADMIN: recovery?.password,
|
||
MAIL_DEV_USER_PASSWORD: devUserPassword,
|
||
[WEBHOOK_SECRET_ENV]: webhookSecretRaw,
|
||
};
|
||
for (const [key, value] of Object.entries(secrets)) {
|
||
if (!value || !PLACEHOLDER_PATTERN.test(value)) continue;
|
||
if (isProduction) errors.push(`${key} 가 예시 값 그대로입니다. 운영용 무작위 값으로 교체하세요.`);
|
||
else warnings.push(`${key} 가 예시 값 그대로입니다. 개발용이라도 무작위 값을 권장합니다.`);
|
||
}
|
||
if (adminPassword && adminPassword === gatewayPassword) {
|
||
(isProduction ? errors : warnings).push('MAIL_GATEWAY_PASSWORD 가 MAIL_ADMIN_PASSWORD 와 같습니다. 서로 다른 값을 쓰세요.');
|
||
}
|
||
|
||
// 첨부 합계 한도 — 백엔드(mail.settings.ts)와 같은 값: 비우면 기본값, 합계는 파일 하나 한도보다 작아지지 않는다.
|
||
// 엔진의 임시 업로드 크기 합계를 그 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 둔다 (ensureUploadQuota)
|
||
const attachmentMb = (key, fallback) => {
|
||
const value = readEnv(key);
|
||
if (value === undefined) return fallback;
|
||
if (POSITIVE_INT_PATTERN.test(value) && Number.isSafeInteger(Number(value))) return Number(value);
|
||
errors.push(`${key} 는 양의 정수여야 합니다 — 앞자리 0·부호·소수점 없이 숫자만 씁니다 (백엔드 env 검증과 같은 규칙, 현재: "${value}").`);
|
||
return fallback;
|
||
};
|
||
const attachmentTotalMb = Math.max(
|
||
attachmentMb('MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB', DEFAULT_ATTACHMENT_TOTAL_MAX_SIZE_MB),
|
||
attachmentMb('MAIL_ATTACHMENT_MAX_SIZE_MB', DEFAULT_ATTACHMENT_MAX_SIZE_MB),
|
||
);
|
||
const uploadQuotaFloor = attachmentTotalMb * MEGABYTE * UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL;
|
||
if (!Number.isSafeInteger(uploadQuotaFloor)) {
|
||
errors.push(`첨부 한도(MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB · MAIL_ATTACHMENT_MAX_SIZE_MB)가 너무 큽니다 (${attachmentTotalMb}MB).`);
|
||
}
|
||
|
||
// 관리 API 주소 — 명시값 > 개발 관리 포트(127.0.0.1) > compose 내부망
|
||
const adminPort = readEnv('MAIL_ADMIN_PORT');
|
||
const adminUrl = (readEnv('MAIL_ADMIN_URL') ?? (adminPort ? `http://127.0.0.1:${adminPort}` : INTERNAL_ADMIN_URL)).replace(/\/+$/, '');
|
||
|
||
// 공개 주소 — 운영은 https 여야 한다 (JMAP·OAuth 디스커버리 주소, 그리고 바깥에서 본 443 차단 확인에 쓴다)
|
||
const publicUrl = readEnv('MAIL_PUBLIC_URL')?.replace(/\/+$/, '');
|
||
if (isProduction && !/^https:\/\/[^/]+$/i.test(publicUrl ?? '')) {
|
||
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'),
|
||
hint: '루트 .env.{APP_ENV} 를 확인하세요 (mail/README.md).',
|
||
});
|
||
}
|
||
|
||
return {
|
||
appEnv,
|
||
isProduction,
|
||
projectName,
|
||
container: `${projectName}-mail`,
|
||
backendContainer: `${projectName}-backend`,
|
||
domain,
|
||
hostname,
|
||
adminUrl,
|
||
publicUrl,
|
||
adminLocalPart,
|
||
adminAddress: `${adminLocalPart}@${domain}`,
|
||
bootstrapAdminAddress: `${BOOTSTRAP_ADMIN_LOCAL_PART}@${domain}`,
|
||
blockedAccounts,
|
||
adminPassword,
|
||
recovery,
|
||
recoveryFlag,
|
||
gatewayAddress,
|
||
gatewayLocal,
|
||
gatewayPassword,
|
||
directoryLocalPart,
|
||
appNetworkSubnet,
|
||
networkName: `${projectName}_app-network`,
|
||
trustedNetworks,
|
||
// 메일 전용 DNS 리졸버 (docker-compose.yml 의 mail-resolver · 네트워크 mail-dns · mail-dns-egress)
|
||
resolver: {
|
||
subnet: resolverSubnet,
|
||
ip: resolverIp,
|
||
container: `${projectName}-${RESOLVER_SERVICE}`,
|
||
network: `${projectName}_${RESOLVER_NETWORK}`,
|
||
egressNetwork: `${projectName}_${RESOLVER_EGRESS_NETWORK}`,
|
||
},
|
||
spamhausKey,
|
||
// 엔진 웹훅 (12단계) — 키가 없으면(개발) 둘 다 null 이고 웹훅을 끈다 (ensureEngineWebhook)
|
||
webhookSecret,
|
||
webhookUrl: webhookSecret ? `http://${WEBHOOK_HOST}:${backendPort}${WEBHOOK_PATH}` : null,
|
||
attachmentTotalMb,
|
||
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(),
|
||
};
|
||
}
|
||
|
||
// ============================================================
|
||
// IPv4 대역 계산 (비트 연산 대신 산술 — 32비트 부호 문제 회피)
|
||
// ============================================================
|
||
|
||
function parseIpv4(text) {
|
||
const parts = text.split('.');
|
||
if (parts.length !== 4) return null;
|
||
let value = 0;
|
||
for (const part of parts) {
|
||
if (!/^\d{1,3}$/.test(part) || Number(part) > 255) return null;
|
||
value = value * 256 + Number(part);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function parseCidr(text) {
|
||
const [ip, bits = '32'] = text.split('/');
|
||
const base = parseIpv4(ip);
|
||
if (base === null || !/^\d{1,2}$/.test(bits) || Number(bits) > 32) return null;
|
||
return { base, prefix: Number(bits) };
|
||
}
|
||
|
||
function cidrContains(cidr, ipText) {
|
||
const range = parseCidr(cidr);
|
||
const ip = parseIpv4(ipText);
|
||
if (range === null || ip === null) return false;
|
||
const blockSize = 2 ** (32 - range.prefix);
|
||
return Math.floor(ip / blockSize) === Math.floor(range.base / blockSize);
|
||
}
|
||
|
||
/** CIDR 의 [첫 주소, 끝 주소] */
|
||
function cidrRange({ base, prefix }) {
|
||
const blockSize = 2 ** (32 - prefix);
|
||
const start = Math.floor(base / blockSize) * blockSize;
|
||
return [start, start + blockSize - 1];
|
||
}
|
||
|
||
/** inner 대역이 outer 대역 안에 완전히 들어가는가 (같아도 된다) */
|
||
function cidrWithin(inner, outer) {
|
||
const a = parseCidr(inner);
|
||
const b = parseCidr(outer);
|
||
if (a === null || b === null) return false;
|
||
const [aStart, aEnd] = cidrRange(a);
|
||
const [bStart, bEnd] = cidrRange(b);
|
||
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);
|
||
if (cidr === null || !text.includes('/')) return 'IPv4 CIDR 이어야 합니다 (예: 172.16.71.0/24).';
|
||
const { widest, narrowest } = APP_NETWORK_PREFIX_RANGE;
|
||
if (cidr.prefix < widest || cidr.prefix > narrowest) {
|
||
return `접두 길이는 /${widest} ~ /${narrowest} 여야 합니다 — 이 대역 전체가 메일 엔진의 신뢰 대역이 되므로 넓게 잡지 않는다 (setup.mjs 의 APP_NETWORK_PREFIX_RANGE).`;
|
||
}
|
||
const [start] = cidrRange(cidr);
|
||
if (start !== cidr.base) return `네트워크 주소로 적어야 합니다 (예: ${formatIpv4(start)}/${cidr.prefix}).`;
|
||
if (!PRIVATE_IPV4_RANGES.some((range) => cidrWithin(text, range))) {
|
||
return `사설 대역(${PRIVATE_IPV4_RANGES.join(', ')}) 안이어야 합니다 — 공인 대역이면 그 대역의 실제 인터넷 주소를 믿게 된다.`;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 메일 전용 리졸버의 네트워크(MAIL_RESOLVER_SUBNET) · 주소(MAIL_RESOLVER_IP)로 쓸 수 없는 이유 — 쓸 수 있으면 빈 배열.
|
||
* 사설 대역 · 네트워크 주소 · /24 ~ /29 (RESOLVER_SUBNET_PREFIX_RANGE) · 앱 네트워크와 겹치지 않음. 주소는 그 안이고 도커가 가져가는 자리
|
||
* (네트워크 주소 · 게이트웨이 = 첫 주소 · 브로드캐스트)가 아니어야 한다 — compose 도 서브넷 밖 주소는 거부하지만 이유를 먼저 알린다.
|
||
*/
|
||
function describeResolverProblems(subnet, ip, appNetworkSubnet) {
|
||
const cidr = parseCidr(subnet);
|
||
if (cidr === null || !subnet.includes('/')) {
|
||
return [`MAIL_RESOLVER_SUBNET(${subnet}) — IPv4 CIDR 이어야 합니다 (예: ${DEFAULT_RESOLVER_SUBNET}).`];
|
||
}
|
||
const issues = [];
|
||
const { widest, narrowest } = RESOLVER_SUBNET_PREFIX_RANGE;
|
||
if (cidr.prefix < widest || cidr.prefix > narrowest) {
|
||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 접두 길이는 /${widest} ~ /${narrowest} 여야 합니다 (mail 과 리졸버 둘만 붙는다).`);
|
||
}
|
||
const [start, end] = cidrRange(cidr);
|
||
if (start !== cidr.base) issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 네트워크 주소로 적어야 합니다 (예: ${formatIpv4(start)}/${cidr.prefix}).`);
|
||
if (!PRIVATE_IPV4_RANGES.some((range) => cidrWithin(subnet, range))) {
|
||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) — 사설 대역(${PRIVATE_IPV4_RANGES.join(', ')}) 안이어야 합니다 — 공인 대역이면 그 대역의 실제 주소로 가는 길을 이 네트워크가 가린다.`);
|
||
}
|
||
if (parseCidr(appNetworkSubnet) !== null && cidrOverlaps(subnet, appNetworkSubnet)) {
|
||
issues.push(`MAIL_RESOLVER_SUBNET(${subnet}) 이 APP_NETWORK_SUBNET(${appNetworkSubnet}) 과 겹칩니다 — 다른 사설 대역으로 바꿉니다.`);
|
||
}
|
||
const address = parseIpv4(ip);
|
||
if (address === null || formatIpv4(address) !== ip) {
|
||
issues.push(`MAIL_RESOLVER_IP(${ip}) — IPv4 주소여야 합니다 (앞자리 0 없이, 예: ${DEFAULT_RESOLVER_IP}).`);
|
||
} else if (address < start || address > end) {
|
||
issues.push(`MAIL_RESOLVER_IP(${ip}) 가 MAIL_RESOLVER_SUBNET(${subnet}) 밖입니다.`);
|
||
} else if (address <= start + 1 || address === end) {
|
||
issues.push(
|
||
`MAIL_RESOLVER_IP(${ip}) — 네트워크 주소 · 도커 게이트웨이(첫 주소) · 브로드캐스트(끝 주소)는 쓸 수 없습니다. ` +
|
||
`서브넷의 끝 쪽(예: ${formatIpv4(end - 1)})을 씁니다 — 도커는 mail 에 앞쪽부터 차례로 주소를 준다.`,
|
||
);
|
||
}
|
||
return issues;
|
||
}
|
||
|
||
function formatIpv4(value) {
|
||
const parts = [];
|
||
let rest = value;
|
||
for (let i = 0; i < 4; i += 1) {
|
||
parts.unshift(rest % 256);
|
||
rest = Math.floor(rest / 256);
|
||
}
|
||
return parts.join('.');
|
||
}
|
||
|
||
// ============================================================
|
||
// docker (호스트에서 실행할 때만 — 컨테이너 안에서는 없을 수 있다)
|
||
// ============================================================
|
||
|
||
function runDocker(args) {
|
||
return spawnSync('docker', args, { encoding: 'utf8' });
|
||
}
|
||
|
||
function dockerAvailable() {
|
||
return runDocker(['version', '--format', '{{.Server.Version}}']).status === 0;
|
||
}
|
||
|
||
/** 컨테이너의 { 네트워크 이름: IP } — 컨테이너가 없으면 null */
|
||
function containerNetworks(name) {
|
||
const result = runDocker(['inspect', '--format', '{{json .NetworkSettings.Networks}}', name]);
|
||
if (result.status !== 0) return null;
|
||
const networks = JSON.parse(result.stdout) ?? {};
|
||
return Object.fromEntries(Object.entries(networks).map(([network, info]) => [network, info.IPAddress]));
|
||
}
|
||
|
||
/** 도커 네트워크의 서브넷 목록 — 네트워크가 없으면 null */
|
||
function networkSubnets(name) {
|
||
const result = runDocker(['network', 'inspect', '--format', '{{json .IPAM.Config}}', name]);
|
||
if (result.status !== 0) return null;
|
||
return (JSON.parse(result.stdout) ?? []).map((config) => config.Subnet).filter(Boolean);
|
||
}
|
||
|
||
/** 도커 네트워크에 붙은 컨테이너 이름 목록 — 네트워크가 없으면 null */
|
||
function networkContainerNames(name) {
|
||
const result = runDocker(['network', 'inspect', '--format', '{{json .Containers}}', name]);
|
||
if (result.status !== 0) return null;
|
||
return Object.values(JSON.parse(result.stdout) ?? {})
|
||
.map((container) => container.Name)
|
||
.filter(Boolean)
|
||
.sort();
|
||
}
|
||
|
||
/** 도커 네트워크의 { subnets, internal, containers } — 네트워크가 없으면 null */
|
||
function networkDetails(name) {
|
||
const result = runDocker([
|
||
'network',
|
||
'inspect',
|
||
'--format',
|
||
'{"ipam":{{json .IPAM.Config}},"internal":{{json .Internal}},"containers":{{json .Containers}}}',
|
||
name,
|
||
]);
|
||
if (result.status !== 0) return null;
|
||
const info = JSON.parse(result.stdout);
|
||
return {
|
||
subnets: (info.ipam ?? []).map((config) => config.Subnet).filter(Boolean),
|
||
internal: info.internal === true,
|
||
containers: Object.values(info.containers ?? {})
|
||
.map((container) => container.Name)
|
||
.filter(Boolean)
|
||
.sort(),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 컨테이너의 { running, startedAt, dns, networks: { 네트워크 이름: IP } } — 컨테이너가 없으면 null.
|
||
* 필요한 칸만 읽는다(환경변수 같은 비밀이 든 칸은 가져오지 않는다)
|
||
*/
|
||
function containerDetails(name) {
|
||
const result = runDocker([
|
||
'inspect',
|
||
'--format',
|
||
'{"running":{{json .State.Running}},"startedAt":{{json .State.StartedAt}},"dns":{{json .HostConfig.Dns}},"networks":{{json .NetworkSettings.Networks}}}',
|
||
name,
|
||
]);
|
||
if (result.status !== 0) return null;
|
||
const info = JSON.parse(result.stdout);
|
||
return {
|
||
running: info.running === true,
|
||
startedAt: info.startedAt ?? null,
|
||
dns: info.dns ?? [],
|
||
networks: Object.fromEntries(Object.entries(info.networks ?? {}).map(([network, settings]) => [network, settings.IPAddress])),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 메일 컨테이너 안에서 DNS 를 한 번 묻는 작은 클라이언트 — 이미지에 dig 이 없어 perl 로 묻는다(Debian 의 perl-base 만: IO::Socket::INET · IO::Select).
|
||
* 프로그램은 표준 입력으로 들어가고(perl -), 묻는 값은 그 뒤 __DATA__ 줄들로 받는다 — 조회 이름(Spamhaus 키가 들 수 있다)이 docker exec 의
|
||
* 명령줄(도커 이벤트 · ps)에 남지 않게. 결과에는 이름을 담지 않는다: rcode · ad · tc · answers · rrsig · a <주소> 또는 error <까닭>.
|
||
* 묻는 값(한 줄에 하나): 서버 IP · 포트 · udp|tcp · A|SOA|DNSKEY · DO(DNSSEC 자료 요청 0|1) · 이름 · 기다림(초) · EDNS 크기(DO 일 때 알리는 UDP 응답 크기)
|
||
*/
|
||
const DNS_PROBE_SCRIPT = String.raw`
|
||
use strict;
|
||
use IO::Socket::INET;
|
||
use IO::Select;
|
||
my @in = <DATA>;
|
||
chomp @in;
|
||
my ($server, $port, $proto, $type, $do, $name, $wait, $edns) = @in;
|
||
my %types = (A => 1, SOA => 6, DNSKEY => 48);
|
||
sub out { print join("\n", @_), "\n"; exit 0 }
|
||
out('error input') unless defined $edns && $edns =~ /^\d+$/ && $edns >= 512 && $edns <= 65535 && $types{$type} && ($proto eq 'udp' || $proto eq 'tcp');
|
||
my $id = int(rand(65536));
|
||
my $query = pack('n6', $id, 0x0100, 1, 0, 0, $do ? 1 : 0);
|
||
for my $label (grep { length } split /\./, $name) {
|
||
out('error input') if length($label) > 63;
|
||
$query .= pack('C', length $label) . $label;
|
||
}
|
||
$query .= pack('Cnn', 0, $types{$type}, 1);
|
||
$query .= pack('CnnCCnn', 0, 41, $edns, 0, 0, 0x8000, 0) if $do;
|
||
my $sock = IO::Socket::INET->new(PeerAddr => $server, PeerPort => $port, Proto => $proto, Timeout => $wait) or out('error connect');
|
||
my $select = IO::Select->new($sock);
|
||
my $reply = '';
|
||
if ($proto eq 'tcp') {
|
||
syswrite($sock, pack('n', length $query) . $query) or out('error send');
|
||
my $need;
|
||
while ($select->can_read($wait)) {
|
||
my $chunk = '';
|
||
my $read = sysread($sock, $chunk, 65535);
|
||
last unless $read;
|
||
$reply .= $chunk;
|
||
$need = unpack('n', $reply) if !defined $need && length($reply) >= 2;
|
||
last if defined $need && length($reply) >= $need + 2;
|
||
}
|
||
out('error timeout') unless defined $need && length($reply) >= $need + 2;
|
||
$reply = substr($reply, 2, $need);
|
||
} else {
|
||
defined(send($sock, $query, 0)) or out('error send');
|
||
out('error timeout') unless $select->can_read($wait);
|
||
defined(recv($sock, $reply, 65535, 0)) or out('error recv');
|
||
}
|
||
out('error short') if length($reply) < 12;
|
||
my ($rid, $flags, $qdcount, $ancount) = unpack('n4', $reply);
|
||
out('error id') if $rid != $id;
|
||
my $offset = 12;
|
||
sub skip_name {
|
||
for (1 .. 128) {
|
||
return 0 if $offset >= length $reply;
|
||
my $len = unpack('C', substr($reply, $offset, 1));
|
||
if (($len & 0xC0) == 0xC0) { $offset += 2; return 1 }
|
||
$offset += 1 + $len;
|
||
return 1 if $len == 0;
|
||
}
|
||
return 0;
|
||
}
|
||
for (1 .. $qdcount) { skip_name() or out('error parse'); $offset += 4 }
|
||
my @addresses;
|
||
my $signatures = 0;
|
||
for (1 .. $ancount) {
|
||
skip_name() or out('error parse');
|
||
out('error parse') if $offset + 10 > length $reply;
|
||
my ($rtype, $rclass, $ttl, $rdlength) = unpack('nnNn', substr($reply, $offset, 10));
|
||
$offset += 10;
|
||
out('error parse') if $offset + $rdlength > length $reply;
|
||
push @addresses, join('.', unpack('C4', substr($reply, $offset, 4))) if $rtype == 1 && $rdlength == 4;
|
||
$signatures++ if $rtype == 46;
|
||
$offset += $rdlength;
|
||
}
|
||
out('rcode ' . ($flags & 0x0F), 'ad ' . (($flags >> 5) & 1), 'tc ' . (($flags >> 9) & 1), "answers $ancount", "rrsig $signatures", map { "a $_" } @addresses);
|
||
`;
|
||
|
||
/**
|
||
* 메일 컨테이너 안에서 DNS 를 한 번 묻는다 (DNS_PROBE_SCRIPT) — 엔진과 같은 자리(메일 컨테이너의 네트워크)에서 같은 주소 · 포트로.
|
||
* 돌려주는 값: { rcode, ad, tc, answers, rrsig, addresses } · { error } · { error, toolMissing, exitCode } (메일 이미지에 perl 이 없다) ·
|
||
* docker 를 부르지 못하면 null
|
||
*/
|
||
function dnsQueryInsideMail(cfg, { server, port = RESOLVER_PORT, protocol, type, dnssec = false, name }) {
|
||
const values = [server, port, protocol, type, dnssec ? 1 : 0, name, RESOLVER_PROBE_TIMEOUT_SECONDS, RESOLVER_PROBE_EDNS_SIZE];
|
||
const result = spawnSync('docker', ['exec', '-i', cfg.container, 'perl', '-'], {
|
||
input: `${DNS_PROBE_SCRIPT}\n__DATA__\n${values.join('\n')}\n`,
|
||
encoding: 'utf8',
|
||
timeout: (RESOLVER_PROBE_TIMEOUT_SECONDS + 15) * 1000,
|
||
});
|
||
if (result.error) return null;
|
||
const answer = { addresses: [] };
|
||
for (const line of (result.stdout ?? '').split(/\r?\n/)) {
|
||
const [key, value] = line.trim().split(' ');
|
||
if (key === 'a') answer.addresses.push(value);
|
||
else if (key === 'error') answer.error = value;
|
||
else if (['rcode', 'ad', 'tc', 'answers', 'rrsig'].includes(key)) answer[key] = Number(value);
|
||
}
|
||
// 표준 오류는 옮기지 않는다 — 출력이 없으면 종료 코드만. docker exec 는 명령을 찾지 못하면 127 · 실행하지 못하면 126 이다 —
|
||
// 메일 이미지에 perl 이 없다(이미지를 바꿨다): 리졸버의 답이 아니라 확인 도구의 문제라 따로 알린다
|
||
if (answer.error === undefined && answer.rcode === undefined) {
|
||
answer.exitCode = result.status;
|
||
answer.toolMissing = result.status === 126 || result.status === 127;
|
||
answer.error = answer.toolMissing ? 'perl' : `종료 코드 ${result.status}`;
|
||
}
|
||
return answer;
|
||
}
|
||
|
||
/** DNS 응답 코드 이름 (RFC 1035 · 2136) */
|
||
const DNS_RCODES = { 0: 'NOERROR', 1: 'FORMERR', 2: 'SERVFAIL', 3: 'NXDOMAIN', 4: 'NOTIMP', 5: 'REFUSED' };
|
||
|
||
/** dnsQueryInsideMail 의 결과를 사람이 읽는 글로 — 이름은 담지 않는다 */
|
||
function describeDnsAnswer(answer) {
|
||
if (!answer) return 'docker exec 실패';
|
||
if (answer.toolMissing) return `메일 컨테이너에 perl 이 없음(docker exec 종료 코드 ${answer.exitCode})`;
|
||
if (answer.error) return `${answer.error === 'timeout' ? '답 없음(시간 초과)' : answer.error === 'connect' ? '연결 실패' : `오류 ${answer.error}`}`;
|
||
const parts = [DNS_RCODES[answer.rcode] ?? `rcode ${answer.rcode}`];
|
||
// 잘린 UDP 응답 — 답 · 서명이 비어 온다(받는 쪽이 TCP 로 다시 물어야 한다)
|
||
if (answer.tc) parts.push(`잘림(TC) · 답 ${answer.answers}`);
|
||
if (answer.addresses.length > 0) parts.push(answer.addresses.join(' · '));
|
||
if (answer.rrsig > 0 || answer.ad) parts.push(`ad ${answer.ad} · 서명 ${answer.rrsig}`);
|
||
return parts.join(' — ');
|
||
}
|
||
|
||
/**
|
||
* mail 컨테이너에 Bootstrap/set 이 쓴 설정 파일이 있는가 — true·false, docker 로 확인할 수 없으면 null.
|
||
* (이미지에 test 가 있다. 컨테이너가 없거나 exec 가 실패하면 null)
|
||
*/
|
||
function mailConfigWritten(cfg) {
|
||
if (!cfg.docker) return null;
|
||
const result = runDocker(['exec', cfg.container, 'test', '-s', MAIL_CONFIG_FILE]);
|
||
if (result.status === 0) return true;
|
||
if (result.status === 1) return false;
|
||
return null;
|
||
}
|
||
|
||
/** 컨테이너 환경변수 값이 비어 있지 않은가 — 값 자체는 읽기만 하고 출력하지 않는다. 컨테이너가 없으면 null */
|
||
function containerEnvIsSet(name, key) {
|
||
const result = runDocker(['inspect', '--format', '{{json .Config.Env}}', name]);
|
||
if (result.status !== 0) return null;
|
||
const entry = (JSON.parse(result.stdout) ?? []).find((line) => line.startsWith(`${key}=`));
|
||
return Boolean(entry && entry.slice(key.length + 1).trim());
|
||
}
|
||
|
||
/** 메일 컨테이너 안에서 본 HTTP 상태 코드 (이미지의 curl 사용, 자체 인증서라 -k) — 실패하면 null */
|
||
function statusInsideMail(cfg, url) {
|
||
const result = runDocker(['exec', cfg.container, 'curl', '-sk', '-o', '/dev/null', '-w', '%{http_code}', '--max-time', '10', url]);
|
||
const code = Number(result.stdout?.trim());
|
||
return result.status === 0 && Number.isInteger(code) ? code : null;
|
||
}
|
||
|
||
/**
|
||
* 메일 컨테이너의 현지 시각이 UTC 와 몇 분 다른가 (이미지의 date +%z — 엔진의 정리 시각이 이 시각으로 돈다 · RETENTION_SCHEDULES_KST).
|
||
* docker 를 쓸 수 없거나 읽지 못하면 null
|
||
*/
|
||
function mailUtcOffsetMinutes(cfg) {
|
||
if (!cfg.docker) return null;
|
||
const result = runDocker(['exec', cfg.container, 'date', '+%z']);
|
||
const match = /^([+-])(\d{2})(\d{2})$/.exec(result.stdout?.trim() ?? '');
|
||
if (result.status !== 0 || !match) return null;
|
||
const minutes = Number(match[2]) * 60 + Number(match[3]);
|
||
return match[1] === '-' ? -minutes : minutes;
|
||
}
|
||
|
||
/**
|
||
* 메일 컨테이너 안에서 이름을 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
|
||
// ============================================================
|
||
|
||
function basicAuth(user, password) {
|
||
return `Basic ${Buffer.from(`${user}:${password}`, 'utf8').toString('base64')}`;
|
||
}
|
||
|
||
function unreachableHint(cfg) {
|
||
if (cfg.isProduction) {
|
||
return '운영은 8080 을 공개하지 않으므로 compose 내부망에서 실행해야 합니다 (mail/README.md "운영 배포").';
|
||
}
|
||
return (
|
||
'mail 컨테이너가 떠 있는지 확인하세요 — 루트 .env.development 에 COMPOSE_PROFILES=mail 후 ' +
|
||
'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d ' +
|
||
'(MAIL_ADMIN_PORT 가 compose 포트 매핑과 같은지도 확인)'
|
||
);
|
||
}
|
||
|
||
async function request(cfg, path, init = {}) {
|
||
const url = `${cfg.adminUrl}${path}`;
|
||
try {
|
||
return await fetch(url, { ...init, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||
} catch (error) {
|
||
throw new SetupError(`메일 엔진에 연결할 수 없습니다: ${url} (${error.cause?.code ?? error.message})`, {
|
||
hint: unreachableHint(cfg),
|
||
});
|
||
}
|
||
}
|
||
|
||
/** HTTP 가 응답할 때까지 기다린다 (bootstrap 모드에서도 8080 /healthz/live 는 열린다) */
|
||
async function waitForHttp(cfg, timeoutMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
let lastError = '';
|
||
while (Date.now() < deadline) {
|
||
try {
|
||
const res = await fetch(`${cfg.adminUrl}/healthz/live`, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
|
||
if (res.ok) return;
|
||
lastError = `HTTP ${res.status}`;
|
||
} catch (error) {
|
||
lastError = error.cause?.code ?? error.message;
|
||
}
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
throw new SetupError(`메일 엔진이 응답하지 않습니다: ${cfg.adminUrl} (${lastError})`, { hint: unreachableHint(cfg) });
|
||
}
|
||
|
||
/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치 · 402 비밀번호는 맞고 2단계 인증이 켜짐 · 403 허용 IP 밖(security.ip-unauthorized) */
|
||
async function loginStatus(cfg, user, password) {
|
||
const res = await request(cfg, '/jmap/session', { headers: { authorization: basicAuth(user, password) } });
|
||
await res.body?.cancel();
|
||
return res.status;
|
||
}
|
||
|
||
/**
|
||
* https 주소의 HTTP 상태 — 인증서는 검사하지 않는다(발급 전일 수 있다). authorized 는 인증서가 믿을 만한지.
|
||
* 연결하지 못하면 { error }.
|
||
*/
|
||
function httpsStatus(url) {
|
||
return new Promise((resolve) => {
|
||
const req = httpsRequest(url, { method: 'GET', rejectUnauthorized: false, timeout: REQUEST_TIMEOUT_MS }, (res) => {
|
||
const authorized = res.socket.authorized === true;
|
||
res.resume();
|
||
resolve({ status: res.statusCode, authorized });
|
||
});
|
||
req.on('timeout', () => req.destroy(Object.assign(new Error('시간 초과'), { code: 'ETIMEDOUT' })));
|
||
req.on('error', (error) => resolve({ error: error.code ?? error.message }));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** 관리 API 클라이언트 — 로그인에 실패하면 null */
|
||
async function openClient(cfg, user, password) {
|
||
const auth = basicAuth(user, password);
|
||
const res = await request(cfg, '/jmap/session', { headers: { authorization: auth } });
|
||
if (res.status === 401 || res.status === 403) {
|
||
await res.body?.cancel();
|
||
return null;
|
||
}
|
||
if (res.status === HTTP_MFA_REQUIRED) {
|
||
// 비밀번호는 맞고 그 계정에 2단계 인증(OTP)이 켜져 있다 — 비밀번호만으로는 관리 API 에 들어갈 수 없다 (12단계)
|
||
await res.body?.cancel();
|
||
throw new SetupError(`${user} 에 2단계 인증(OTP)이 켜져 있어 비밀번호만으로 로그인할 수 없습니다 (HTTP ${HTTP_MFA_REQUIRED}).`, {
|
||
hint: 'setup 이 쓰는 계정(관리자 · 복구 관리자 · 게이트웨이)에는 2단계 인증을 켜지 않는다 — 관리 화면에서 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
|
||
});
|
||
}
|
||
if (!res.ok) throw new SetupError(`세션 조회 실패 (HTTP ${res.status})`);
|
||
const session = await res.json();
|
||
return { cfg, auth, session, accountId: session.primaryAccounts?.[STALWART_CAPABILITY] };
|
||
}
|
||
|
||
async function jmap(client, methodCalls) {
|
||
const res = await request(client.cfg, '/jmap/', {
|
||
method: 'POST',
|
||
headers: { authorization: client.auth, 'content-type': 'application/json' },
|
||
body: JSON.stringify({ using: JMAP_USING, methodCalls }),
|
||
});
|
||
if (!res.ok) {
|
||
throw new SetupError(`JMAP 요청 실패 (HTTP ${res.status})`, { detail: (await res.text()).slice(0, 500) });
|
||
}
|
||
return (await res.json()).methodResponses;
|
||
}
|
||
|
||
async function call(client, method, args = {}) {
|
||
const [[name, result]] = await jmap(client, [[method, { accountId: client.accountId, ...args }, 'c0']]);
|
||
if (name === 'error') throw new JmapMethodError(method, result);
|
||
return result;
|
||
}
|
||
|
||
function describeSetError(error) {
|
||
const parts = [error.type];
|
||
if (error.description) parts.push(error.description);
|
||
if (error.properties?.length) parts.push(`속성: ${error.properties.join(', ')}`);
|
||
return parts.join(' — ');
|
||
}
|
||
|
||
/** x:<Type>/set — 하나라도 거부되면 예외 */
|
||
async function setObjects(client, type, { create, update, destroy }) {
|
||
const args = {};
|
||
if (create && Object.keys(create).length > 0) args.create = create;
|
||
if (update && Object.keys(update).length > 0) args.update = update;
|
||
if (destroy && destroy.length > 0) args.destroy = destroy;
|
||
const result = await call(client, `x:${type}/set`, args);
|
||
|
||
const failures = [];
|
||
for (const [field, label] of [
|
||
['notCreated', '생성'],
|
||
['notUpdated', '변경'],
|
||
['notDestroyed', '삭제'],
|
||
]) {
|
||
for (const [id, error] of Object.entries(result[field] ?? {})) {
|
||
failures.push(` - ${label} 거부 (${id}): ${describeSetError(error)}`);
|
||
}
|
||
}
|
||
if (failures.length > 0) throw new SetupError(`x:${type}/set 요청이 거부되었습니다.`, { detail: failures.join('\n') });
|
||
return result;
|
||
}
|
||
|
||
/** x:<Type>/query 전체 페이지 → x:<Type>/get */
|
||
async function listAll(client, type, properties) {
|
||
const ids = [];
|
||
for (;;) {
|
||
const page = await call(client, `x:${type}/query`, { position: ids.length, limit: QUERY_PAGE_SIZE, calculateTotal: true });
|
||
ids.push(...page.ids);
|
||
const done =
|
||
page.ids.length === 0 || (typeof page.total === 'number' ? ids.length >= page.total : page.ids.length < QUERY_PAGE_SIZE);
|
||
if (done) break;
|
||
}
|
||
const list = [];
|
||
for (let i = 0; i < ids.length; i += GET_CHUNK_SIZE) {
|
||
const page = await call(client, `x:${type}/get`, { ids: ids.slice(i, i + GET_CHUNK_SIZE), properties });
|
||
list.push(...page.list);
|
||
}
|
||
return list;
|
||
}
|
||
|
||
// ============================================================
|
||
// bootstrap · 관리자
|
||
// ============================================================
|
||
|
||
function readState() {
|
||
if (!existsSync(STATE_FILE)) return null;
|
||
try {
|
||
const state = JSON.parse(readFileSync(STATE_FILE, 'utf8'));
|
||
return state?.username && state?.secret ? state : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** 상태 파일을 쓸 수 없을 때의 안내 — 운영은 sudo docker compose 가 data/ 를 root 소유로 만들었을 수 있다 */
|
||
function stateDirHint() {
|
||
return (
|
||
`이 스크립트를 실행하는 사용자가 ${dirname(STATE_FILE)} 에 쓸 수 있어야 합니다. ` +
|
||
'저장소의 data/ 와 data/mail/ 이 그 사용자 소유인지 확인하고, 아니면 소유자를 바꾼 뒤 다시 실행하세요 ' +
|
||
'(예: sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail). ' +
|
||
'매번 같은 사용자(같은 sudo 여부)로 실행해야 한다 — mail/README.md "운영 배포".'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Bootstrap/set 을 부르기 전에 상태 파일 자리에 실제로 써 본다.
|
||
* bootstrap 직후에 보관하지 못하면 재실행이 Bootstrap/set 을 다시 부르게 되므로(원자적이지 않다),
|
||
* 쓸 수 없으면 아무것도 바꾸지 않은 지금 멈춘다.
|
||
*/
|
||
function ensureStateWritable() {
|
||
const probe = `${STATE_FILE}.${process.pid}.probe`;
|
||
try {
|
||
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
||
writeFileSync(probe, '', { mode: 0o600 });
|
||
rmSync(probe, { force: true });
|
||
} catch (error) {
|
||
throw new SetupError(
|
||
`임시 비밀번호 파일 자리(${dirname(STATE_FILE)})에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다 — 아무것도 바꾸지 않았습니다 (${error.code ?? error.message}).`,
|
||
{ hint: stateDirHint() },
|
||
);
|
||
}
|
||
}
|
||
|
||
function writeState(issued) {
|
||
const state = { username: issued.username, secret: issued.secret, issuedAt: new Date().toISOString() };
|
||
try {
|
||
mkdirSync(dirname(STATE_FILE), { recursive: true });
|
||
writeFileSync(STATE_FILE, JSON.stringify(state), { mode: 0o600 });
|
||
} catch (error) {
|
||
// 바로 앞에서 써 봤으므로 드물다. bootstrap 은 이미 끝났다 — 재실행은 config.json 으로 이를 알아보고 재시작만 한다
|
||
throw new SetupError(`bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다 (${error.code ?? error.message}).`, {
|
||
hint:
|
||
'Bootstrap/set 을 다시 부르지 마세요. 권한을 고친 뒤 같은 명령으로 다시 실행하면 재시작 후 복구 관리자로 관리자 비밀번호를 맞춥니다. ' +
|
||
stateDirHint(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/** 상태 파일 삭제 — strict 면 실패를 오류로, 아니면 경고만 (비밀번호를 이미 바꿨다면 남은 파일은 옛 값이다) */
|
||
function removeState({ strict = false } = {}) {
|
||
try {
|
||
rmSync(STATE_FILE, { force: true });
|
||
} catch (error) {
|
||
if (strict) throw new SetupError(`옛 임시 비밀번호 파일을 지우지 못했습니다: ${STATE_FILE} (${error.code ?? error.message})`, { hint: stateDirHint() });
|
||
warn(`임시 비밀번호 파일을 지우지 못했습니다 — 직접 지우세요: ${STATE_FILE} (${error.code ?? error.message})`);
|
||
}
|
||
}
|
||
|
||
function resetHint(cfg) {
|
||
return (
|
||
'개발 데이터를 버려도 된다면 메일 볼륨을 지우고 처음부터 시작할 수 있습니다: ' +
|
||
'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit && ' +
|
||
`docker volume rm ${cfg.projectName}_mail-config ${cfg.projectName}_mail-data`
|
||
);
|
||
}
|
||
|
||
function adminRecoveryHint(cfg) {
|
||
if (cfg.isProduction) {
|
||
return (
|
||
`MAIL_ADMIN_PASSWORD 가 서버에 설정된 값과 다릅니다(또는 최초 bootstrap 입니다). 복구 관리자를 셸에서 일회성으로 주고 ` +
|
||
`mail 컨테이너를 다시 만든 뒤(export ${RECOVERY_ENV_KEY}=…; docker compose --env-file .env.production up -d mail) ` +
|
||
'--recovery 로 이 스크립트를 실행하면 관리자 비밀번호를 env 값으로 맞춥니다. 끝나면 안내대로 복구 관리자를 끕니다 (mail/README.md "운영 배포").'
|
||
);
|
||
}
|
||
return (
|
||
`MAIL_ADMIN_PASSWORD 가 서버에 설정된 값과 다릅니다. ${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤` +
|
||
'(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) ' +
|
||
`이 스크립트를 다시 실행하면 관리자 비밀번호를 env 값으로 맞춥니다. ${resetHint(cfg)}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 관리 API 를 쓸 수 있는 세션인가 — 데이터 없는 호출(x:Account/get ids:[])로 본다.
|
||
* 직원 세션은 forbidden, 관리자(또는 관리자를 대리 접근한 세션)는 성공한다.
|
||
*/
|
||
async function hasManagementAccess(client) {
|
||
const [[name, result]] = await jmap(client, [['x:Account/get', { accountId: client.accountId, ids: [] }, 'm']]);
|
||
if (name !== 'error') return true;
|
||
if (result.type === 'forbidden') return false;
|
||
throw new JmapMethodError('x:Account/get', result);
|
||
}
|
||
|
||
/** bootstrap 모드에서는 Bootstrap 외의 객체가 전부 forbidden 이다 */
|
||
async function isBootstrapMode(client) {
|
||
try {
|
||
await call(client, 'x:Account/query', { limit: 1 });
|
||
return false;
|
||
} catch (error) {
|
||
if (error instanceof JmapMethodError && error.type === 'forbidden' && /bootstrap/i.test(error.description)) return true;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function restartMail(cfg, reason) {
|
||
if (!cfg.docker) throw new RestartRequired(reason, cfg.container);
|
||
log(`[재시작] ${reason} — docker restart ${cfg.container}`);
|
||
const result = runDocker(['restart', cfg.container]);
|
||
if (result.status !== 0) {
|
||
throw new SetupError(`컨테이너를 재시작하지 못했습니다: ${cfg.container}`, { detail: result.stderr?.trim() });
|
||
}
|
||
await waitForHttp(cfg, RESTART_TIMEOUT_MS);
|
||
}
|
||
|
||
async function runBootstrap(cfg, recovery) {
|
||
log(
|
||
`[생성] bootstrap — 호스트명 ${cfg.hostname}, 기본 도메인 ${cfg.domain}, DKIM 키 생성` +
|
||
(cfg.isProduction ? ', TLS 인증서(ACME) 요청' : ''),
|
||
);
|
||
const result = await call(recovery, 'x:Bootstrap/set', {
|
||
update: {
|
||
singleton: {
|
||
serverHostname: cfg.hostname,
|
||
defaultDomain: cfg.domain,
|
||
// 개발 도메인(.test)은 공개 DNS 에 없으므로 인증서를 요청하지 않는다
|
||
requestTlsCertificate: cfg.isProduction,
|
||
generateDkimKeys: true,
|
||
// 로그를 컨테이너 표준 출력으로 보낸다 → docker logs (기본값은 컨테이너 안의 파일)
|
||
tracer: { '@type': 'Stdout', level: 'info', ansi: false },
|
||
},
|
||
},
|
||
});
|
||
const issued = result.updated?.singleton;
|
||
if (!issued?.username || !issued?.secret) {
|
||
const rejected = result.notUpdated?.singleton;
|
||
throw new SetupError('bootstrap 이 거부되었습니다.', {
|
||
detail: rejected ? describeSetError(rejected) : undefined,
|
||
hint:
|
||
rejected?.type === 'primaryKeyViolation'
|
||
? `데이터 볼륨(${cfg.projectName}_mail-data)에 이전 설치가 남아 있는데 설정 볼륨에 config.json 이 없는 상태입니다. ` +
|
||
(cfg.isProduction
|
||
? '새 설치라면 볼륨을 지우고 처음부터, 운영 중이던 서버라면 백업에서 mail-config 볼륨을 복원하세요 (mail/README.md "문제 해결").'
|
||
: resetHint(cfg))
|
||
: undefined,
|
||
});
|
||
}
|
||
// 무작위 관리자 비밀번호는 이 응답에서 한 번만 보인다 — 다른 일을 하기 전에 먼저 보관한다
|
||
writeState(issued);
|
||
}
|
||
|
||
/** 관리자 역할 계정인가 — bootstrap 이 만드는 관리자는 roles {"@type":"Admin"} 이다 */
|
||
function isAdminAccount(account) {
|
||
return account.roles?.['@type'] === 'Admin';
|
||
}
|
||
|
||
/** MAIL_ADMIN_LOCAL_PART 를 bootstrap 뒤에 바꿨다 — 엔진의 관리자와 이름이 다르다 */
|
||
function adminNameMismatch(cfg, engineAdmins) {
|
||
return new SetupError(
|
||
`메일 엔진의 관리자(${engineAdmins.join(', ') || '찾지 못함'})와 MAIL_ADMIN_LOCAL_PART(${cfg.adminLocalPart}) 가 다릅니다 — 아무것도 바꾸지 않고 중단합니다.`,
|
||
{
|
||
hint:
|
||
'MAIL_ADMIN_LOCAL_PART 는 bootstrap 전에만 정할 수 있다. 그대로 두면 백엔드는 새 이름만 막고 엔진의 실제 관리자는 막지 않는다. ' +
|
||
'이미 바꿨다면 (1) env 를 엔진 관리자의 아이디로 되돌리고 backend 를 다시 띄운다(가장 간단), 또는 (2) 아이디를 꼭 바꿔야 하면 ' +
|
||
'복구 관리자로 엔진의 관리자 계정 아이디를 새 값으로 바꾼 뒤 backend 를 다시 띄우고 이 스크립트를 다시 실행한다 (mail/README.md "문제 해결").',
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 영구 관리자 계정을 env 에 맞춘다 (비밀번호 = MAIL_ADMIN_PASSWORD).
|
||
* freshBootstrap(갓 bootstrap 해 아무도 쓰지 않은 엔진)이면 bootstrap 이 만든 admin@ 의 아이디를 MAIL_ADMIN_LOCAL_PART 로 바꾼다.
|
||
* 이미 쓰던 엔진에서 env 아이디의 관리자가 없으면 이름을 바꾼 것이므로 중단한다.
|
||
*/
|
||
async function claimAdminAccount(client, cfg, { via, freshBootstrap }) {
|
||
const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']);
|
||
const byAddress = (address) => accounts.find((account) => account.emailAddress?.toLowerCase() === address);
|
||
const engineAdmins = accounts.filter(isAdminAccount).map((account) => account.emailAddress?.toLowerCase());
|
||
|
||
const admin = byAddress(cfg.adminAddress);
|
||
if (admin) {
|
||
if (!isAdminAccount(admin)) {
|
||
throw new SetupError(`${cfg.adminAddress}(MAIL_ADMIN_LOCAL_PART)는 메일 엔진의 관리자 계정이 아닙니다 — 아무것도 바꾸지 않고 중단합니다.`, {
|
||
hint: `엔진의 관리자: ${engineAdmins.join(', ') || '없음'}. MAIL_ADMIN_LOCAL_PART 를 그 아이디로 맞추세요.`,
|
||
});
|
||
}
|
||
await setObjects(client, 'Account', {
|
||
update: { [admin.id]: { [`credentials/${passwordKey(admin)}/secret`]: cfg.adminPassword } },
|
||
});
|
||
log(`[변경] 관리자 ${cfg.adminAddress} 비밀번호를 MAIL_ADMIN_PASSWORD 로 맞췄습니다 (${via} 사용).`);
|
||
return;
|
||
}
|
||
|
||
const bootstrapAdmin = byAddress(cfg.bootstrapAdminAddress);
|
||
if (freshBootstrap && bootstrapAdmin && isAdminAccount(bootstrapAdmin)) {
|
||
// Bootstrap/set 은 아이디를 받지 않는다 — 아무도 쓰기 전에 아이디를 바꿔 admin@ 이 남지 않게 한다
|
||
await setObjects(client, 'Account', {
|
||
update: {
|
||
[bootstrapAdmin.id]: { name: cfg.adminLocalPart, [`credentials/${passwordKey(bootstrapAdmin)}/secret`]: cfg.adminPassword },
|
||
},
|
||
});
|
||
log(`[변경] bootstrap 이 만든 관리자 ${cfg.bootstrapAdminAddress} → ${cfg.adminAddress} (MAIL_ADMIN_LOCAL_PART), 비밀번호 MAIL_ADMIN_PASSWORD (${via} 사용)`);
|
||
return;
|
||
}
|
||
throw adminNameMismatch(cfg, engineAdmins);
|
||
}
|
||
|
||
/**
|
||
* 관리자로 로그인하지 못했고 복구 수단도 없을 때 — bootstrap 뒤에 MAIL_ADMIN_LOCAL_PART 만 바꾼 경우인지 본다.
|
||
* bootstrap 기본 아이디(admin@)에 MAIL_ADMIN_PASSWORD 가 통하면 그렇다 (이 스크립트의 접속 주소는 신뢰 대역이라 자동 차단에 걸리지 않는다).
|
||
*/
|
||
async function detectRenamedAdmin(cfg) {
|
||
if (cfg.adminLocalPart === BOOTSTRAP_ADMIN_LOCAL_PART) return;
|
||
const status = await loginStatus(cfg, cfg.bootstrapAdminAddress, cfg.adminPassword);
|
||
if (status >= 200 && status < 300) throw adminNameMismatch(cfg, [cfg.bootstrapAdminAddress]);
|
||
}
|
||
|
||
/**
|
||
* 관리자 로그인 상태를 확정한다.
|
||
* 정상 + env 비밀번호 일치 → 그대로 사용
|
||
* bootstrap 모드 → bootstrap(최초 1회) → 재시작
|
||
* bootstrap 완료·재시작 전 → 재시작만 (Bootstrap/set 재호출 금지)
|
||
* 판단: mail 컨테이너의 config.json (docker 를 쓸 수 없으면 보관 파일)
|
||
* 정상 + env 비밀번호 불일치 → 보관된 임시 비밀번호 또는 recovery 관리자로 env 값에 맞춘다
|
||
* 정상 + env 아이디의 관리자가 없음 → 갓 bootstrap 한 엔진이면 admin@ 의 아이디를 바꾸고, 아니면 중단 (claimAdminAccount)
|
||
*/
|
||
async function ensureAdmin(cfg) {
|
||
/** 이번 실행에서 bootstrap 뒤 재시작을 했다 — 엔진의 관리자를 아직 아무도 쓰지 않았다 */
|
||
let restarted = false;
|
||
/** 이번 실행에서 복구 관리자로 관리자 허용 IP 를 지금 대역으로 고쳤다 — 한 번만 (고친 뒤에도 403 이면 다른 원인이다) */
|
||
let allowedIpsReset = false;
|
||
for (let step = 0; step < MAX_ADMIN_STEPS; step += 1) {
|
||
const admin = await openClient(cfg, cfg.adminAddress, cfg.adminPassword);
|
||
if (admin?.accountId) {
|
||
if (!(await hasManagementAccess(admin))) {
|
||
throw new SetupError(`${cfg.adminAddress}(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다 — 아무것도 바꾸지 않고 중단합니다.`, {
|
||
hint: 'MAIL_ADMIN_LOCAL_PART 를 엔진의 관리자 아이디로 맞추세요 (mail/README.md "문제 해결").',
|
||
});
|
||
}
|
||
removeState(); // 비밀번호가 env 값과 같아졌으니 임시 비밀번호를 보관할 이유가 없다
|
||
log(`[확인] 관리자 ${cfg.adminAddress} 로그인 (MAIL_ADMIN_PASSWORD)`);
|
||
return admin;
|
||
}
|
||
|
||
const recovery = cfg.recovery ? await openClient(cfg, cfg.recovery.user, cfg.recovery.password) : null;
|
||
if (recovery?.accountId && (await isBootstrapMode(recovery))) {
|
||
const configWritten = mailConfigWritten(cfg); // true·false, 모르면 null
|
||
const saved = readState();
|
||
const alreadyBootstrapped = configWritten ?? Boolean(saved);
|
||
if (alreadyBootstrapped && !restarted) {
|
||
// 지난 실행이 bootstrap 직후 재시작 전에 멈췄다 — 재시작만 하면 된다
|
||
await restartMail(cfg, 'bootstrap 은 이미 끝났고 재시작만 남았습니다');
|
||
restarted = true;
|
||
continue;
|
||
}
|
||
if (configWritten === true) {
|
||
// 설정 파일이 있는데도 bootstrap 모드로 떴다 — Bootstrap/set 을 다시 부르면 일부 설정이 중복된다
|
||
throw new SetupError('재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음). Bootstrap/set 을 다시 부르지 않고 중단합니다.', {
|
||
hint: `기동 로그에서 설정 오류를 확인하세요: docker logs ${cfg.container} (mail/README.md "문제 해결").`,
|
||
});
|
||
}
|
||
if (saved) {
|
||
warn('bootstrap 모드인데 설정 파일이 없습니다. 보관 중이던 임시 비밀번호는 이전 설치의 것이므로 지웁니다.');
|
||
removeState({ strict: true });
|
||
}
|
||
ensureStateWritable();
|
||
await runBootstrap(cfg, recovery);
|
||
await restartMail(cfg, 'bootstrap 설정(config.json)을 적용합니다');
|
||
restarted = true;
|
||
continue;
|
||
}
|
||
|
||
// 관리자 로그인이 403 — 비밀번호가 틀린 것(401)이 아니라 허용 IP(13단계 — 앱 네트워크) 밖이거나 로그인 권한이 없다.
|
||
// 비밀번호를 다시 맞춰도 풀리지 않는다 (ADMIN_DISABLED_PERMISSION 머리말 2). 복구 관리자가 있고 이 실행의 주소가 지금 대역 안이면
|
||
// 허용 IP 가 옛 대역(APP_NETWORK_SUBNET 을 바꾼 뒤 등)인 경우라 지금 대역으로 고쳐 잇는다 — 한 번만, 그래도 403 이면 멈춘다
|
||
if (!restarted && (await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword)) === 403) {
|
||
if (recovery?.accountId && !allowedIpsReset && adminUrlInsideAppNetwork(cfg) && (await resetAdminAllowedIps(recovery, cfg))) {
|
||
allowedIpsReset = true;
|
||
continue;
|
||
}
|
||
throw new SetupError(`관리자(${cfg.adminAddress}) 로그인이 HTTP 403 입니다 — 비밀번호가 아니라 접속 주소가 관리자 허용 IP(앱 네트워크 ${cfg.appNetworkSubnet}) 밖이다.`, {
|
||
hint:
|
||
`이 실행의 관리 API 주소는 ${cfg.adminUrl} — 운영은 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080)로 둔다 (mail/README.md "운영 배포" 3). ` +
|
||
`엔진이 본 주소는 docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)에 있다. ` +
|
||
'APP_NETWORK_SUBNET 을 바꿨다면 복구 관리자를 일회성으로 주고 --recovery 로 실행하면 허용 IP 를 지금 대역으로 고친다(mail/README.md "관리자 계정 단단히"). ' +
|
||
'관리자의 로그인 권한(authenticate)을 뺐다면 복구 관리자로 되돌린다.',
|
||
});
|
||
}
|
||
|
||
// 정상 모드인데 MAIL_ADMIN_PASSWORD 로는 로그인되지 않는다 → env 값으로 맞춘다
|
||
const saved = readState();
|
||
const temporary = saved ? await openClient(cfg, saved.username, saved.secret) : null;
|
||
if (temporary?.accountId) {
|
||
// 보관한 임시 비밀번호가 아직 통한다 = bootstrap 이 만든 관리자를 아무도 쓰지 않았다
|
||
await claimAdminAccount(temporary, cfg, { via: 'bootstrap 이 발급한 임시 비밀번호', freshBootstrap: true });
|
||
removeState();
|
||
continue;
|
||
}
|
||
if (recovery?.accountId) {
|
||
await claimAdminAccount(recovery, cfg, { via: 'recovery 관리자', freshBootstrap: restarted });
|
||
removeState();
|
||
continue;
|
||
}
|
||
if (restarted) {
|
||
// 재시작 직후에는 디렉터리 초기화가 늦을 수 있다 — 잠시 뒤 다시 확인한다
|
||
await sleep(POLL_INTERVAL_MS);
|
||
continue;
|
||
}
|
||
await detectRenamedAdmin(cfg);
|
||
throw new SetupError(`관리자(${cfg.adminAddress})로 로그인할 수 없습니다.`, {
|
||
hint:
|
||
adminRecoveryHint(cfg) +
|
||
' (MAIL_ADMIN_LOCAL_PART 를 bootstrap 뒤에 바꿨다면 비밀번호가 아니라 아이디가 다른 것이다 — mail/README.md "문제 해결")',
|
||
});
|
||
}
|
||
throw new SetupError('관리자 계정 상태를 확정하지 못했습니다 (반복 한도 초과).', { hint: adminRecoveryHint(cfg) });
|
||
}
|
||
|
||
// ============================================================
|
||
// 구성 요소 (멱등 — 없으면 만들고, 다르면 고치고, 같으면 둔다)
|
||
// ============================================================
|
||
|
||
async function findDomain(client, cfg) {
|
||
const domains = await listAll(client, 'Domain', ['name']);
|
||
const domain = domains.find((item) => item.name?.toLowerCase() === cfg.domain);
|
||
if (!domain) {
|
||
throw new SetupError(`도메인 ${cfg.domain} 이 메일 엔진에 없습니다.`, {
|
||
hint: 'MAIL_DOMAIN 이 bootstrap 때의 값과 같은지 확인하세요. 다른 도메인은 관리 화면에서 추가합니다.',
|
||
});
|
||
}
|
||
return { id: domain.id, name: cfg.domain };
|
||
}
|
||
|
||
async function ensureAllowedIps(client, cfg) {
|
||
const entries = await listAll(client, 'AllowedIp', ['address', 'reason']);
|
||
const present = new Set(entries.map((entry) => entry.address));
|
||
const create = {};
|
||
cfg.trustedNetworks.forEach((address, index) => {
|
||
log(`${present.has(address) ? '[유지]' : '[생성]'} 자동 차단 예외 ${address}`);
|
||
if (!present.has(address)) create[`ip${index}`] = { address, reason: ALLOWED_IP_REASON };
|
||
});
|
||
// 이 스크립트가 만든 것 중 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;
|
||
await setObjects(client, 'AllowedIp', { create, destroy: stale.map((entry) => entry.id) });
|
||
// 차단·예외 목록은 캐시된다 — 바로 반영되게 다시 읽힌다
|
||
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadBlockedIps' } } });
|
||
}
|
||
|
||
function sameTrueSet(actual, expectedKeys) {
|
||
const keys = Object.entries(actual ?? {})
|
||
.filter(([, value]) => value === true)
|
||
.map(([key]) => key)
|
||
.sort();
|
||
return JSON.stringify(keys) === JSON.stringify([...expectedKeys].sort());
|
||
}
|
||
|
||
function toTrueSet(keys) {
|
||
return Object.fromEntries(keys.map((key) => [key, true]));
|
||
}
|
||
|
||
function stableJson(value) {
|
||
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
|
||
if (value && typeof value === 'object') {
|
||
return `{${Object.keys(value)
|
||
.sort()
|
||
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
||
.join(',')}}`;
|
||
}
|
||
return JSON.stringify(value);
|
||
}
|
||
|
||
async function ensureGatewayRole(client) {
|
||
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
|
||
const role = roles.find((item) => item.description === GATEWAY_ROLE_DESCRIPTION);
|
||
if (!role) {
|
||
const result = await setObjects(client, 'Role', {
|
||
create: { role: { description: GATEWAY_ROLE_DESCRIPTION, enabledPermissions: GATEWAY_PERMISSIONS } },
|
||
});
|
||
log('[생성] 역할 — 대리 접근 전용 (authenticate, impersonate)');
|
||
return result.created.role.id;
|
||
}
|
||
if (!sameTrueSet(role.enabledPermissions, Object.keys(GATEWAY_PERMISSIONS))) {
|
||
await setObjects(client, 'Role', { update: { [role.id]: { enabledPermissions: GATEWAY_PERMISSIONS } } });
|
||
log('[변경] 역할 — 대리 접근 전용 권한을 authenticate, impersonate 로 되돌림');
|
||
} else {
|
||
log('[유지] 역할 — 대리 접근 전용 (authenticate, impersonate)');
|
||
}
|
||
return role.id;
|
||
}
|
||
|
||
function passwordKey(account) {
|
||
const entry = Object.entries(account.credentials ?? {}).find(([, credential]) => credential['@type'] === 'Password');
|
||
if (!entry) {
|
||
throw new SetupError(`${account.emailAddress ?? account.name} 에 비밀번호 자격 증명이 없습니다.`, {
|
||
hint: '관리 화면에서 비밀번호를 추가한 뒤 다시 실행하세요.',
|
||
});
|
||
}
|
||
return entry[0];
|
||
}
|
||
|
||
/**
|
||
* 로그인 확인 — 2xx 면 맞고 401 이면 틀리다. 그 밖(429 시도 과다·5xx 등)은 판단할 수 없으므로 중단한다
|
||
* (틀린 것으로 보고 비밀번호를 덮어쓰거나, 맞는 것으로 보고 넘어가면 안 된다).
|
||
* 402 — 엔진은 맞는 비밀번호에만 "2단계 인증 코드가 더 필요하다"(402)를 주고 틀리면 401 이다(12단계). mfaMeansMatch(웹메일에서 2단계 인증을 켤 수 있는
|
||
* 테스트 직원)면 맞는 것으로 보고, 그 밖의 계정(게이트웨이)이면 중단한다 — 게이트웨이에 2단계 인증이 켜지면 백엔드의 대리 접근이 모두 막힌다
|
||
*/
|
||
async function passwordMatches(cfg, address, password, { mfaMeansMatch = false } = {}) {
|
||
const status = await loginStatus(cfg, address, password);
|
||
if ((status >= 200 && status < 300) || (status === HTTP_MFA_REQUIRED && mfaMeansMatch)) return true;
|
||
if (status === 401) return false;
|
||
if (status === HTTP_MFA_REQUIRED) {
|
||
throw new SetupError(`${address} 에 2단계 인증(OTP)이 켜져 있습니다 (HTTP ${HTTP_MFA_REQUIRED}) — 아무것도 바꾸지 않고 중단합니다.`, {
|
||
hint: '이 계정에는 2단계 인증을 켜지 않는다 — 관리 화면 · 관리 API 로 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
|
||
});
|
||
}
|
||
throw new SetupError(`${address} 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP ${status}). 아무것도 바꾸지 않았습니다.`, {
|
||
hint:
|
||
status === 429
|
||
? '시도가 너무 많아 잠시 막혔습니다. 몇 분 뒤 다시 실행하세요.'
|
||
: `메일 엔진 상태와 로그를 확인한 뒤 다시 실행하세요: docker logs ${cfg.container}`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 게이트웨이 계정에 2단계 인증(OTP)이 켜져 있으면 아무것도 바꾸기 전에 멈춘다 (12단계). 대리 접근(<직원>%<게이트웨이>)은 게이트웨이 자신의
|
||
* 비밀번호 · OTP 를 확인하므로 백엔드의 대리 접근이 모두 402 로 막혀 웹메일 전체가 선다. 게이트웨이가 아직 없거나(처음 설치) 비밀번호가
|
||
* env 와 다르면 401 이라 지나간다(ensureAccount 가 맞춘다). 이 확인은 호스트(신뢰 대역)에서 하므로 자동 차단에 세지 않는다
|
||
*/
|
||
async function checkGatewayWithoutTwoFactor(cfg) {
|
||
if ((await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) !== HTTP_MFA_REQUIRED) return;
|
||
throw new SetupError(
|
||
`게이트웨이 계정(${cfg.gatewayAddress})에 2단계 인증(OTP)이 켜져 있습니다 — 백엔드의 대리 접근이 모두 막혀(HTTP ${HTTP_MFA_REQUIRED}) 웹메일 전체가 선다. 아무것도 바꾸지 않고 중단합니다.`,
|
||
{
|
||
hint:
|
||
'게이트웨이의 OTP 를 지운 뒤 다시 실행하세요 — mail/README.md "휴대폰을 잃은 직원" 의 절차를 게이트웨이 주소로 한다. 게이트웨이에는 2단계 인증을 켜지 않는다.',
|
||
},
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 계정을 env 값에 맞춘다. 저장된 비밀번호는 읽을 수 없으므로 실제 로그인으로 확인하고, 다르면 env 값으로 바꾼다.
|
||
* spec: { name, description, roles, password, allowedIps?, label, mfaMeansMatch? } — mfaMeansMatch 는 테스트 직원만 (passwordMatches)
|
||
*/
|
||
async function ensureAccount(client, cfg, domain, spec) {
|
||
const address = `${spec.name}@${domain.name}`;
|
||
const accounts = await listAll(client, 'Account', ['name', 'domainId', 'description', 'roles', 'credentials']);
|
||
const existing = accounts.find((account) => account.name === spec.name && account.domainId === domain.id);
|
||
|
||
if (!existing) {
|
||
const credential = { '@type': 'Password', secret: spec.password };
|
||
if (spec.allowedIps) credential.allowedIps = toTrueSet(spec.allowedIps);
|
||
await setObjects(client, 'Account', {
|
||
create: {
|
||
account: {
|
||
'@type': 'User',
|
||
name: spec.name,
|
||
domainId: domain.id,
|
||
description: spec.description,
|
||
roles: spec.roles,
|
||
credentials: { 0: credential },
|
||
},
|
||
},
|
||
});
|
||
log(`[생성] ${spec.label} ${address} (${spec.description})`);
|
||
return address;
|
||
}
|
||
|
||
const key = passwordKey(existing);
|
||
const patch = {};
|
||
const changes = [];
|
||
if (existing.description !== spec.description) {
|
||
patch.description = spec.description;
|
||
changes.push('표시 이름');
|
||
}
|
||
if (stableJson(existing.roles) !== stableJson(spec.roles)) {
|
||
patch.roles = spec.roles;
|
||
changes.push('역할');
|
||
}
|
||
if (spec.allowedIps && !sameTrueSet(existing.credentials[key].allowedIps, spec.allowedIps)) {
|
||
patch[`credentials/${key}/allowedIps`] = toTrueSet(spec.allowedIps);
|
||
changes.push('허용 IP');
|
||
}
|
||
if (!(await passwordMatches(cfg, address, spec.password, { mfaMeansMatch: spec.mfaMeansMatch === true }))) {
|
||
patch[`credentials/${key}/secret`] = spec.password;
|
||
changes.push('비밀번호');
|
||
}
|
||
|
||
if (changes.length === 0) {
|
||
log(`[유지] ${spec.label} ${address} (${spec.description})`);
|
||
return address;
|
||
}
|
||
await setObjects(client, 'Account', { update: { [existing.id]: patch } });
|
||
log(`[변경] ${spec.label} ${address} — ${changes.join(', ')}`);
|
||
if (changes.includes('비밀번호') && (await loginStatus(cfg, address, spec.password)) === 401) {
|
||
warn(`${address} 로그인이 여전히 거부됩니다. 이 스크립트의 접속 주소가 허용 IP 밖이면 정상일 수 있습니다.`);
|
||
}
|
||
return address;
|
||
}
|
||
|
||
// ============================================================
|
||
// 사전 확인 — 신뢰 대역을 바꾸기 전에 (하나라도 어긋나면 아무것도 바꾸지 않고 중단한다)
|
||
// ============================================================
|
||
|
||
/** 실제 앱 네트워크의 서브넷이 APP_NETWORK_SUBNET 과 같은지 — 다르면 옛 설정으로 만든 네트워크가 남아 있다 */
|
||
function checkNetworkSubnet(cfg, issues) {
|
||
const subnets = networkSubnets(cfg.networkName);
|
||
if (subnets === null) {
|
||
issues.push(
|
||
`앱 네트워크 ${cfg.networkName} 가 없습니다 — compose 로 먼저 띄우고(PROJECT_NAME 이 compose 와 같은 env 파일인지도 확인) 다시 실행하세요.`,
|
||
);
|
||
return false;
|
||
}
|
||
if (subnets.includes(cfg.appNetworkSubnet)) {
|
||
log(`[확인] 앱 네트워크 ${cfg.networkName} = ${cfg.appNetworkSubnet} (APP_NETWORK_SUBNET)`);
|
||
return true;
|
||
}
|
||
issues.push(
|
||
`앱 네트워크 ${cfg.networkName} 의 서브넷(${subnets.join(', ') || '없음'})이 APP_NETWORK_SUBNET(${cfg.appNetworkSubnet}) 과 다릅니다. ` +
|
||
'서브넷을 고정하기 전(또는 바꾸기 전)에 만든 네트워크입니다 — docker compose … down 후 up -d 로 네트워크를 다시 만들고 이 스크립트를 다시 실행하세요.',
|
||
);
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 앱 네트워크에 붙은 컨테이너 — 이 네트워크 전체가 신뢰 대역이므로 정해진 서비스만 붙어야 한다.
|
||
* 인터넷을 향한 frontend(nginx)는 edge 네트워크로만 백엔드에 닿는다 (docker-compose.yml).
|
||
* 운영은 그 밖의 컨테이너가 있으면 중단, 개발은 경고만 한다.
|
||
*/
|
||
function checkAppNetworkMembers(cfg, issues) {
|
||
const names = networkContainerNames(cfg.networkName);
|
||
if (names === null) return; // 네트워크가 없으면 서브넷 확인이 이미 보고했다
|
||
const expected = new Set(APP_NETWORK_SERVICES.map((service) => `${cfg.projectName}-${service}`));
|
||
const unexpected = names.filter((name) => !expected.has(name));
|
||
if (unexpected.length === 0) {
|
||
log(`[확인] 앱 네트워크 구성원 — ${names.join(', ') || '없음'}`);
|
||
return;
|
||
}
|
||
const message =
|
||
`앱 네트워크 ${cfg.networkName} 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다: ${unexpected.join(', ')} — ` +
|
||
'이 네트워크의 주소는 자동 차단에서 빠지고 게이트웨이로 로그인할 수 있다. ' +
|
||
`docker network disconnect ${cfg.networkName} <컨테이너> 로 떼거나 compose 의 networks 를 고친 뒤 다시 실행하세요.`;
|
||
if (cfg.isProduction) issues.push(message);
|
||
else warn(message);
|
||
}
|
||
|
||
/**
|
||
* 백엔드 컨테이너가 메일 엔진에 붙는 주소가 신뢰 대역 안인지 — 밖이면 게이트웨이 로그인이 막힌다.
|
||
* 신뢰 대역이 앱 네트워크 전체면(MAIL_TRUSTED_NETWORKS 비움) 서브넷이 맞는 한 항상 안이다 — 백엔드가 아직 없어도 된다.
|
||
* 대역을 좁혔다면 실제 주소로 확인해야 하므로 백엔드가 떠 있어야 한다.
|
||
*/
|
||
function checkBackendNetwork(cfg, issues, subnetMatches) {
|
||
const mail = containerNetworks(cfg.container);
|
||
if (!mail) {
|
||
issues.push(`${cfg.container} 컨테이너를 찾을 수 없습니다 (PROJECT_NAME 이 compose 와 같은 env 파일인지 확인).`);
|
||
return;
|
||
}
|
||
if (!(cfg.networkName in mail)) {
|
||
issues.push(`${cfg.container} 가 앱 네트워크 ${cfg.networkName} 에 없습니다 (compose 의 mail.networks 확인).`);
|
||
return;
|
||
}
|
||
const wholeSubnet = cfg.trustedNetworks.length === 1 && cfg.trustedNetworks[0] === cfg.appNetworkSubnet;
|
||
const backend = containerNetworks(cfg.backendContainer);
|
||
const ip = backend?.[cfg.networkName];
|
||
if (!ip) {
|
||
if (wholeSubnet && subnetMatches) {
|
||
log(`[확인] 신뢰 대역 = 앱 네트워크 전체(${cfg.appNetworkSubnet}) — 백엔드가 이 네트워크에 붙으면 주소는 항상 그 안이다 (${cfg.backendContainer} 아직 없음)`);
|
||
return;
|
||
}
|
||
issues.push(
|
||
`${cfg.backendContainer} 가 없거나 앱 네트워크 ${cfg.networkName} 에 붙어 있지 않아 백엔드 주소가 신뢰 대역(${cfg.trustedNetworks.join(', ')}) 안인지 확인할 수 없습니다. ` +
|
||
'백엔드를 먼저 띄우거나, MAIL_TRUSTED_NETWORKS 를 비워(= APP_NETWORK_SUBNET) 다시 실행하세요.',
|
||
);
|
||
return;
|
||
}
|
||
if (cfg.trustedNetworks.some((cidr) => cidrContains(cidr, ip))) {
|
||
log(`[확인] 백엔드 주소 ${ip} (${cfg.networkName}) 가 신뢰 대역 안에 있습니다.`);
|
||
return;
|
||
}
|
||
issues.push(
|
||
`백엔드 주소 ${ip} (${cfg.networkName}) 가 신뢰 대역(${cfg.trustedNetworks.join(', ')}) 밖입니다 — 게이트웨이 로그인이 거부됩니다. ` +
|
||
'MAIL_TRUSTED_NETWORKS 를 비우거나(= APP_NETWORK_SUBNET) 백엔드가 있는 대역으로 고치고 다시 실행하세요.',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 리졸버가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주는가 — RESOLVER_DNSSEC_PROBES(UDP 루트 SOA · TCP 루트 DNSKEY)를 차례로 묻는다.
|
||
* 엔진과 같은 자리(메일 컨테이너의 네트워크) · 같은 주소다. 답마다 rcode 0 · ad 1 · 서명 1 개 이상이어야 한다.
|
||
* 돌려주는 값: { answers: [{ protocol, type, answer }], toolMissing(메일 이미지에 perl 이 없다 — 답을 볼 수 없었다), failed: answers 가운데 어긋난 것 }
|
||
*/
|
||
function probeResolverDnssec(cfg) {
|
||
const answers = RESOLVER_DNSSEC_PROBES.map(({ protocol, type }) => ({
|
||
protocol,
|
||
type,
|
||
answer: dnsQueryInsideMail(cfg, { server: cfg.resolver.ip, protocol, type, dnssec: true, name: '.' }),
|
||
}));
|
||
return {
|
||
answers,
|
||
toolMissing: answers.find(({ answer }) => answer?.toolMissing)?.answer ?? null,
|
||
failed: answers.filter(({ answer }) => !(answer?.rcode === 0 && answer.ad === 1 && answer.rrsig > 0)),
|
||
};
|
||
}
|
||
|
||
/** probeResolverDnssec 의 답들을 한 줄로 — "UDP 루트 SOA NOERROR — ad 1 · 서명 1, TCP 루트 DNSKEY …" */
|
||
function describeResolverProbes(answers) {
|
||
return answers.map(({ protocol, type, answer }) => `${protocol.toUpperCase()} 루트 ${type} ${describeDnsAnswer(answer)}`).join(', ');
|
||
}
|
||
|
||
/**
|
||
* 메일 전용 DNS 리졸버 — 엔진의 DNS 를 맡기기 전에 (RESOLVER_SERVICE · DEFAULT_RESOLVER_IP 머리말).
|
||
* 리졸버가 없거나 · 주소가 env 와 다르면 issues 에 넣는다(아무것도 바꾸지 않고 멈춘다).
|
||
* 메일 엔진만 닿는가(두 네트워크의 구성원 · 리졸버가 붙은 네트워크)는 운영은 멈추고 개발은 [주의] — 앱 네트워크 구성원 확인과 같다.
|
||
* 메일 엔진에서 답하고 DNSSEC 을 검증하는가(probeResolverDnssec — UDP · TCP, 엔진은 TCP 가 되어야 DNSSEC 을 믿는다)도 운영은 멈추고 개발은 [주의]다 —
|
||
* 개발 메일은 Mailpit(도커 DNS 가 푸는 이름)으로 가고 엔진 DNS 는 System 이라, 바깥 53 이 막힌 망에서도 개발 setup 은 끝까지 간다.
|
||
* 돌려주는 값: 엔진의 DNS 를 맡겨도 되는가
|
||
*/
|
||
function checkResolver(cfg, issues) {
|
||
const { resolver } = cfg;
|
||
const before = issues.length;
|
||
const upHint = 'docker compose … up -d 로 mail-resolver 를 띄우고 mail 을 다시 만든다(compose 가 함께 한다)';
|
||
const strayIssue = (message) => (cfg.isProduction ? issues.push(message) : warn(message));
|
||
|
||
const network = networkDetails(resolver.network);
|
||
if (!network) {
|
||
issues.push(`리졸버 네트워크 ${resolver.network} 가 없습니다 — ${upHint}. PROJECT_NAME 이 compose 와 같은 env 파일인지도 확인하세요.`);
|
||
return false;
|
||
}
|
||
if (!network.subnets.includes(resolver.subnet)) {
|
||
issues.push(
|
||
`리졸버 네트워크 ${resolver.network} 의 서브넷(${network.subnets.join(', ') || '없음'})이 MAIL_RESOLVER_SUBNET(${resolver.subnet}) 과 다릅니다 — ` +
|
||
'docker compose … down 후 up -d 로 네트워크를 다시 만든다.',
|
||
);
|
||
}
|
||
if (!network.internal) {
|
||
issues.push(`리졸버 네트워크 ${resolver.network} 가 internal 이 아닙니다 — 바깥 길은 ${resolver.egressNetwork} 하나여야 한다. docker compose … down 후 up -d 로 다시 만든다.`);
|
||
}
|
||
const strayMembers = network.containers.filter((name) => name !== cfg.container && name !== resolver.container);
|
||
if (strayMembers.length > 0) {
|
||
strayIssue(
|
||
`리졸버 네트워크 ${resolver.network} 에 mail · ${RESOLVER_SERVICE} 밖의 컨테이너가 붙어 있습니다: ${strayMembers.join(', ')} — 그 컨테이너도 리졸버에 닿고, ` +
|
||
`리졸버가 그 컨테이너에 닿는다. docker network disconnect ${resolver.network} <컨테이너> 로 떼거나 compose 의 networks 를 고친다.`,
|
||
);
|
||
}
|
||
const egress = networkDetails(resolver.egressNetwork);
|
||
if (!egress) {
|
||
issues.push(`리졸버의 바깥 길 ${resolver.egressNetwork} 가 없습니다 — ${upHint}.`);
|
||
} else {
|
||
if (egress.internal) issues.push(`리졸버의 바깥 길 ${resolver.egressNetwork} 가 internal 이라 리졸버가 바깥 DNS 서버에 묻지 못합니다 — down 후 up -d.`);
|
||
const strayEgress = egress.containers.filter((name) => name !== resolver.container);
|
||
if (strayEgress.length > 0) {
|
||
strayIssue(`리졸버의 바깥 길 ${resolver.egressNetwork} 에 다른 컨테이너가 붙어 있습니다: ${strayEgress.join(', ')} — 그 컨테이너가 리졸버에 닿는다. 떼어 낸다.`);
|
||
}
|
||
}
|
||
|
||
const unbound = containerDetails(resolver.container);
|
||
if (!unbound) {
|
||
issues.push(`${resolver.container} 컨테이너가 없습니다 — ${upHint}.`);
|
||
return false;
|
||
}
|
||
if (!unbound.running) {
|
||
issues.push(`${resolver.container} 가 떠 있지 않습니다 — docker logs ${resolver.container} 로 까닭을 본 뒤 docker compose … up -d 로 띄운다.`);
|
||
return false;
|
||
}
|
||
if (unbound.networks[resolver.network] !== resolver.ip) {
|
||
issues.push(
|
||
`${resolver.container} 의 ${resolver.network} 주소(${unbound.networks[resolver.network] || '없음'})가 MAIL_RESOLVER_IP(${resolver.ip}) 와 다릅니다 — ` +
|
||
'docker compose … up -d --force-recreate mail-resolver mail 로 다시 만든다.',
|
||
);
|
||
}
|
||
const extraNetworks = Object.keys(unbound.networks).filter((name) => name !== resolver.network && name !== resolver.egressNetwork);
|
||
if (extraNetworks.length > 0) {
|
||
strayIssue(
|
||
`${resolver.container} 가 ${extraNetworks.join(', ')} 에도 붙어 있습니다 — 리졸버는 ${resolver.network} · ${resolver.egressNetwork} 에만 둔다` +
|
||
(extraNetworks.includes(cfg.networkName) ? ' (앱 네트워크는 메일 엔진의 신뢰 대역이다)' : '') +
|
||
'. compose 의 networks 를 고친다.',
|
||
);
|
||
}
|
||
// 메일 컨테이너가 없으면 앞의 checkBackendNetwork 가 이미 알렸다
|
||
const mail = containerDetails(cfg.container);
|
||
if (!mail) return false;
|
||
if (!mail.networks[resolver.network]) issues.push(`${cfg.container} 가 리졸버 네트워크 ${resolver.network} 에 없습니다 — ${upHint}.`);
|
||
if (!(mail.dns.length === 1 && mail.dns[0] === resolver.ip)) {
|
||
issues.push(`${cfg.container} 의 dns(${mail.dns.join(', ') || '없음'})가 MAIL_RESOLVER_IP(${resolver.ip}) 하나가 아닙니다 — ${upHint}.`);
|
||
}
|
||
if (issues.length > before) return false;
|
||
|
||
// 엔진과 같은 자리 · 같은 주소로 — 운영은 어긋나면 멈추고(닫힌 쪽), 개발은 [주의] 뒤 이어 간다(이 함수 머리말)
|
||
const probeFailed = (message, devNote = '') => {
|
||
if (cfg.isProduction) {
|
||
issues.push(message);
|
||
} else {
|
||
warn(`${message} 개발이라 멈추지 않고 이어 갑니다(운영이면 여기서 멈춘다) — 개발 메일은 Mailpit 으로 가고 엔진 DNS 는 System(도커 DNS)이다${devNote}.`);
|
||
}
|
||
return false;
|
||
};
|
||
const probe = probeResolverDnssec(cfg);
|
||
if (probe.toolMissing) {
|
||
return probeFailed(
|
||
`메일 컨테이너(${cfg.container})에서 리졸버를 확인하지 못했습니다 — ${describeDnsAnswer(probe.toolMissing)}. ` +
|
||
'리졸버의 답이 아니라 확인 도구의 문제다: setup 은 메일 이미지의 perl 로 묻는다(이미지에 dig 이 없다). ' +
|
||
'MAIL_IMAGE 를 바꿨다면 그 이미지에 perl 이 있는지 보고, 없으면 setup 의 리졸버 확인(DNS_PROBE_SCRIPT)을 그 이미지에 맞춘다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").',
|
||
);
|
||
}
|
||
if (probe.failed.length > 0) {
|
||
return probeFailed(
|
||
`리졸버 ${resolver.ip}:${RESOLVER_PORT} 가 메일 컨테이너에서 DNSSEC 을 검증한 답을 주지 않습니다 (${describeResolverProbes(probe.failed)}). ` +
|
||
`리졸버 로그(docker logs ${resolver.container}) · 이 서버에서 바깥으로 나가는 53(UDP · TCP) · 서버 시계를 확인하세요 (mail/README.md "DNS 리졸버와 스팸 차단 목록").`,
|
||
' — 바깥 이름(SPF · DKIM · DNSBL)과 DANE 은 리졸버가 바깥에 닿을 때까지 되지 않고, 바깥에 전혀 닿지 않으면 뒤의 설정 리로드도 엔진이 거절한다' +
|
||
'(스팸 필터의 Pyzor 주소를 같은 리졸버로 푼다 — RELOAD_LOOKUP_FAILED)',
|
||
);
|
||
}
|
||
log(
|
||
`[확인] 메일 전용 리졸버 ${resolver.ip}:${RESOLVER_PORT} — 메일 컨테이너에서 UDP · TCP 로 답하고 DNSSEC 을 검증한다(${describeResolverProbes(probe.answers)}) · ` +
|
||
`${resolver.network}(internal) 구성원 ${network.containers.join(', ')} · 바깥 길 ${resolver.egressNetwork}`,
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Spamhaus 키가 있으면 두 항목의 시험 항목을 DQS 로 물어 본다 (메일 컨테이너 → 리졸버, 키는 표준 입력으로만 — DNS_PROBE_SCRIPT).
|
||
* 돌려주는 값: Map(항목 이름 → { ok, detail }) — 키가 없으면 null
|
||
*/
|
||
function checkSpamhausKey(cfg) {
|
||
if (!cfg.spamhausKey) return null;
|
||
const results = new Map();
|
||
for (const entry of SPAMHAUS_DNSBL) {
|
||
const answer = dnsQueryInsideMail(cfg, {
|
||
server: cfg.resolver.ip,
|
||
protocol: 'udp',
|
||
type: 'A',
|
||
name: `${entry.test.name}.${cfg.spamhausKey}.${entry.dqsZone}`,
|
||
});
|
||
const ok = answer?.rcode === 0 && answer.addresses.includes(entry.test.expect);
|
||
results.set(entry.name, { ok, detail: describeDnsAnswer(answer) });
|
||
if (ok) {
|
||
log(`[확인] Spamhaus 키(${SPAMHAUS_DQS_KEY_ENV}) — ${entry.label} 시험 항목 ${entry.test.name} → ${answer.addresses.join(' · ')} (DQS ${entry.dqsZone}, 메일 컨테이너 → 리졸버)`);
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* 메일 엔진 설정을 바꾸기 전의 확인 — 어긋나면 아무것도 바꾸지 않고 중단한다.
|
||
* (신뢰 대역을 바꾼 뒤에야 알면 전 직원의 메일이 멈추고, 백엔드 주소가 자동 차단 예외에서 빠져 차단될 수 있다.
|
||
* 메일 전용 리졸버가 없거나 닿지 않는데 엔진의 DNS 를 맡기면 바깥 메일을 주고받지 못한다)
|
||
* 돌려주는 값: { spamhaus } — Spamhaus 키 확인 결과(checkSpamhausKey), 확인하지 못했으면 null
|
||
*/
|
||
function preflight(cfg) {
|
||
if (!cfg.docker) {
|
||
// 운영은 main 이 이미 막았다 — 여기는 개발뿐이다
|
||
warn('docker 를 쓸 수 없어 앱 네트워크 확인(서브넷·구성원·백엔드 주소)과 메일 전용 리졸버 확인을 건너뜁니다 (개발 환경만 허용).');
|
||
return { spamhaus: null };
|
||
}
|
||
const issues = [];
|
||
const subnetMatches = checkNetworkSubnet(cfg, issues);
|
||
checkAppNetworkMembers(cfg, issues);
|
||
checkBackendNetwork(cfg, issues, subnetMatches);
|
||
checkResolver(cfg, issues);
|
||
if (issues.length > 0) {
|
||
throw new SetupError('사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.', {
|
||
detail: issues.map((message) => ` - ${message}`).join('\n'),
|
||
});
|
||
}
|
||
return { spamhaus: checkSpamhausKey(cfg) };
|
||
}
|
||
|
||
// ============================================================
|
||
// 잠금 · 엔진 한도 · 반영 확인
|
||
// ============================================================
|
||
|
||
/**
|
||
* 직원(User) 역할에서 Principal 조회 권한을 뺀다.
|
||
* 기본값으로는 직원 계정이 JMAP Principal/get 으로 관리자·게이트웨이를 포함한 전체 계정 목록을 볼 수 있다.
|
||
* 웹메일은 백엔드를 거치고, 대리 접근 세션도 직원 역할의 권한을 쓴다. 백엔드가 계정 목록을 읽는 것은 사내 목록 계정
|
||
* (따로 만든 역할 — ensureDirectoryAccount)으로만이고, 이 함수는 기본 직원 역할(설명 "User")만 건드린다.
|
||
* 역할 권한은 캐시되므로 ReloadSettings 가 아니라 InvalidateCaches 로 반영한다 (Stalwart 0.16 에서 확인).
|
||
*/
|
||
async function ensureUserRoleWithoutPrincipals(client) {
|
||
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
|
||
const role = roles.find((item) => item.description === USER_ROLE_DESCRIPTION);
|
||
if (!role) {
|
||
problems.push(`기본 직원 역할("${USER_ROLE_DESCRIPTION}")을 찾지 못해 Principal 권한을 정리하지 못했습니다 (관리 화면 › 역할 확인).`);
|
||
return;
|
||
}
|
||
const granted = Object.entries(role.enabledPermissions ?? {})
|
||
.filter(([key, value]) => value === true && PRINCIPAL_PERMISSION_PATTERN.test(key))
|
||
.map(([key]) => key);
|
||
if (granted.length === 0) {
|
||
log('[유지] 직원 역할 — Principal 조회 권한 없음 (계정 목록 비공개)');
|
||
return;
|
||
}
|
||
const patch = Object.fromEntries(granted.map((key) => [`enabledPermissions/${key}`, null]));
|
||
await setObjects(client, 'Role', { update: { [role.id]: patch } });
|
||
await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
|
||
log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`);
|
||
}
|
||
|
||
/**
|
||
* 관리자 역할에서 대리 접근(impersonate)을 뺀다 — 빼는 권한(disabledPermissions)에 둔다 (ADMIN_DISABLED_PERMISSION 머리말).
|
||
* 관리자로 된다. 역할 권한은 캐시되므로 InvalidateCaches 로 반영한다(직원 역할과 같다). 돌려주는 값: 맞췄으면(이미 맞았으면) true
|
||
*/
|
||
async function ensureAdminRoleWithoutImpersonate(client) {
|
||
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions', 'disabledPermissions']);
|
||
const role = roles.find((item) => item.description === ADMIN_ROLE_DESCRIPTION);
|
||
const label = `관리자 역할("${ADMIN_ROLE_DESCRIPTION}") — 대리 접근(${ADMIN_DISABLED_PERMISSION}) 뺌 (<직원>%<관리자> 로그인 · 남의 메일함 직접 열기 막힘, 관리 API 는 그대로)`;
|
||
if (!role) {
|
||
problems.push(`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못해 대리 접근(${ADMIN_DISABLED_PERMISSION})을 빼지 못했습니다 — 관리 화면 › 역할을 확인하세요 (mail/README.md "관리자 계정 단단히").`);
|
||
return false;
|
||
}
|
||
if (role.disabledPermissions?.[ADMIN_DISABLED_PERMISSION] === true) {
|
||
log(`[유지] ${label}`);
|
||
return true;
|
||
}
|
||
await setObjects(client, 'Role', { update: { [role.id]: { [`disabledPermissions/${ADMIN_DISABLED_PERMISSION}`]: true } } });
|
||
await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
|
||
log(`[변경] ${label}`);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 이 실행이 관리 API 에 붙는 주소가 앱 네트워크 안으로 보이는가 — 관리자 허용 IP 를 걸기 전에 본다(밖이면 걸자마자 이 실행이 막힌다).
|
||
* 루프백(개발 — 포트 매핑은 앱 네트워크의 게이트웨이 주소로 보인다) · compose 이름 mail(앱 네트워크에 붙은 컨테이너) · 앱 네트워크 안의 IPv4(운영 — 호스트에서
|
||
* 컨테이너 주소로 붙으면 그 네트워크의 게이트웨이 주소로 나간다)면 true. 그 밖(다른 네트워크의 주소 · 바깥 이름)은 false
|
||
*/
|
||
function adminUrlInsideAppNetwork(cfg) {
|
||
let host;
|
||
try {
|
||
host = new URL(cfg.adminUrl).hostname.toLowerCase();
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (LOOPBACK_HOSTS.includes(host) || host === new URL(INTERNAL_ADMIN_URL).hostname) return true;
|
||
return parseIpv4(host) !== null && parseCidr(cfg.appNetworkSubnet) !== null && cidrContains(cfg.appNetworkSubnet, host);
|
||
}
|
||
|
||
/** 허용 IP 가 모두 그 대역 안인가 — 비었으면 false(어디서나 된다), IPv6 · 읽지 못하는 항목은 밖으로 본다 */
|
||
function allowedIpsWithin(allowedIps, subnet) {
|
||
const entries = trueKeys(allowedIps);
|
||
return entries.length > 0 && entries.every((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, subnet));
|
||
}
|
||
|
||
/**
|
||
* 영구 관리자 비밀번호 자격의 허용 IP 를 앱 네트워크로 (ADMIN_DISABLED_PERMISSION 머리말 2).
|
||
* 이미 모두 대역 안이면 그대로 둔다 — 운영자가 더 좁힌 값(/32 등)을 넓히지 않는다.
|
||
* 걸기 전에 이 실행의 주소가 대역 안으로 보이는지 보고(adminUrlInsideAppNetwork), 운영에서 처음 걸 때는 복구 관리자가 있어야 건다 —
|
||
* 걸자마자 이 실행이 막히면 되돌릴 길이 복구 관리자뿐이다. 걸지 못하면 운영은 실패(problems — 종료 1), 개발은 [주의].
|
||
* 건 뒤에는 관리자로 다시 로그인해 보고, 막혔으면(403) 복구 관리자로 되돌린다.
|
||
* 돌려주는 값: { ready — 관리자가 허용 IP 안에서 로그인된다, ips — 지금의 허용 IP(좁힌 값이면 그 값) }
|
||
*/
|
||
/** ensureAdminAllowedIps 가 허용 IP 를 걸지 못했을 때 */
|
||
const NOT_READY = Object.freeze({ ready: false, ips: '' });
|
||
|
||
async function ensureAdminAllowedIps(client, cfg) {
|
||
const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']);
|
||
const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
|
||
if (!admin) {
|
||
problems.push(`관리자 ${cfg.adminAddress} 를 찾지 못해 허용 IP 를 걸지 못했습니다.`);
|
||
return NOT_READY;
|
||
}
|
||
const key = passwordKey(admin);
|
||
const current = admin.credentials[key].allowedIps ?? {};
|
||
const label = `관리자 ${cfg.adminAddress} — 허용 IP ${cfg.appNetworkSubnet} (앱 네트워크 — 바깥 993 · 465 · 443 에서는 관리자 비밀번호로 로그인되지 않는다)`;
|
||
if (allowedIpsWithin(current, cfg.appNetworkSubnet)) {
|
||
if (sameTrueSet(current, [cfg.appNetworkSubnet])) log(`[유지] ${label}`);
|
||
else log(`[유지] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(current).join(', ')} (앱 네트워크 ${cfg.appNetworkSubnet} 안으로 좁힌 값 — 넓히지 않는다)`);
|
||
return { ready: true, ips: trueKeys(current).join(', ') };
|
||
}
|
||
const notApplied = (message) => {
|
||
if (cfg.isProduction) problems.push(message);
|
||
else warn(message);
|
||
return NOT_READY;
|
||
};
|
||
if (!adminUrlInsideAppNetwork(cfg)) {
|
||
return notApplied(
|
||
`관리자 허용 IP 를 걸지 않았습니다 — 이 실행의 관리 API 주소(${cfg.adminUrl})가 앱 네트워크(${cfg.appNetworkSubnet}) 안으로 보이는지 알 수 없어, 걸면 이 실행이 막힐 수 있다. ` +
|
||
`MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080 — mail/README.md "운영 배포" 3)로 두고 다시 실행하세요.`,
|
||
);
|
||
}
|
||
const recovery = await openRecoveryClient(cfg);
|
||
if (cfg.isProduction && !recovery) {
|
||
return notApplied(
|
||
`관리자 허용 IP 를 걸지 않았습니다 — 운영에서 처음 걸 때는 복구 관리자가 있어야 한다(걸자마자 이 실행이 막히면 되돌릴 길이 그것뿐이다). ` +
|
||
'복구 관리자를 일회성으로 주고 --recovery 로 다시 실행하세요 (mail/README.md "13단계 전부터 운영 중인 서버").',
|
||
);
|
||
}
|
||
await setObjects(client, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } });
|
||
log(`[변경] ${label}${trueKeys(current).length > 0 ? ` (전 ${trueKeys(current).join(', ')})` : ''}`);
|
||
const status = await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword);
|
||
if (status >= 200 && status < 300) return { ready: true, ips: cfg.appNetworkSubnet };
|
||
// 걸자마자 이 실행이 막혔다 — 엔진이 이 실행의 주소를 대역 밖으로 본다 (403 · security.ip-unauthorized)
|
||
const previous = current;
|
||
if (recovery) {
|
||
await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: previous } } });
|
||
problems.push(
|
||
`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 라 복구 관리자로 되돌렸습니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다. ` +
|
||
`docker logs ${cfg.container} 의 security.ip-unauthorized 줄의 remoteIp 를 보고 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두세요.`,
|
||
);
|
||
return NOT_READY;
|
||
}
|
||
throw new SetupError(`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 입니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다.`, {
|
||
hint:
|
||
`docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)을 본다. 되돌리기 — 복구 관리자를 일회성으로 주고(운영: mail/README.md "운영 배포" 1 · 2) ` +
|
||
'복구 관리자로 관리 화면 › 계정 › 관리자 › Credentials 의 Allowed IPs 를 비운 뒤, MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두고 다시 실행한다.',
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 관리자 로그인이 403 일 때(ensureAdmin) 복구 관리자로 관리자 비밀번호의 허용 IP 를 지금 대역으로 고친다 — APP_NETWORK_SUBNET 을 바꾸면
|
||
* 허용 IP 가 옛 대역에 남아 관리자가 막힌다. 지금 대역 안의 항목이 하나라도 있으면(그대로이거나 운영자가 좁힌 값) 고치지 않고 false —
|
||
* 403 은 다른 원인이다(이 실행의 주소가 좁힌 값 밖 등 — 부른 쪽이 멈춘다)
|
||
*/
|
||
async function resetAdminAllowedIps(recovery, cfg) {
|
||
const accounts = await listAll(recovery, 'Account', ['emailAddress', 'credentials']);
|
||
const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
|
||
if (!admin) return false;
|
||
const key = passwordKey(admin);
|
||
const previous = admin.credentials?.[key]?.allowedIps ?? {};
|
||
if (trueKeys(previous).some((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, cfg.appNetworkSubnet))) return false;
|
||
await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } });
|
||
log(
|
||
`[변경] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(previous).join(', ') || '없음'} → ${cfg.appNetworkSubnet} ` +
|
||
'(관리자 로그인이 403 이었다 — 앱 네트워크 대역이 바뀐 것으로 보고 복구 관리자로 고쳤다)',
|
||
);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 관리자 단단히가 걸렸는지 — 관리자 세션의 실제 권한(GET /api/account 의 permissions — 역할 · 계정 권한을 합친 결과)에 impersonate 가 없고,
|
||
* 관리자 로그인이 이 실행(앱 네트워크 안)에서 된다. 그 밖에 [주의] 둘 (setup 은 그 비밀을 모른다 — 걸지 않는다):
|
||
* - 관리자의 다른 자격(두 번째 비밀번호 · 앱 비밀번호 · API 키)의 허용 IP 가 앱 네트워크 밖이거나 없다 — 그 자격으로 바깥에서 관리자가 된다
|
||
* - 다른 관리자 역할 계정의 비밀번호에 앱 네트워크 안의 허용 IP 가 없다
|
||
*/
|
||
async function checkAdminHardening(admin, cfg, ipState) {
|
||
let permissions = null;
|
||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||
const res = await request(cfg, '/api/account', { headers: { authorization: admin.auth } });
|
||
let body = null;
|
||
if (res.ok) body = await res.json().catch(() => null);
|
||
else await res.body?.cancel();
|
||
permissions = Array.isArray(body?.permissions) ? body.permissions : null;
|
||
if (permissions && !permissions.includes(ADMIN_DISABLED_PERMISSION)) break;
|
||
await sleep(POLL_INTERVAL_MS / 2);
|
||
}
|
||
if (!permissions) {
|
||
problems.push(`관리자 ${cfg.adminAddress} 의 권한(GET /api/account)을 읽지 못해 대리 접근이 빠졌는지 확인하지 못했습니다.`);
|
||
} else if (permissions.includes(ADMIN_DISABLED_PERMISSION)) {
|
||
problems.push(
|
||
`관리자 ${cfg.adminAddress} 에 아직 대리 접근(${ADMIN_DISABLED_PERMISSION}) 권한이 있습니다 — 관리자 비밀번호로 <직원>%<관리자> 로그인이 된다. ` +
|
||
'관리 화면 › 역할에서 System Administrator 의 Disabled permissions 를 확인하세요.',
|
||
);
|
||
} else {
|
||
const ips = ipState?.ready ? ` · 허용 IP ${ipState.ips} 안에서 로그인됨` : '';
|
||
log(`[확인] 관리자 ${cfg.adminAddress} — 대리 접근 권한 없음 (권한 ${permissions.length}개에 ${ADMIN_DISABLED_PERMISSION} 없음)${ips}`);
|
||
}
|
||
const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles', 'credentials']);
|
||
const self = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress);
|
||
if (self) {
|
||
const primary = passwordKey(self);
|
||
const loose = Object.entries(self.credentials ?? {})
|
||
.filter(([key, credential]) => key !== primary && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet))
|
||
.map(([key, credential]) => `${credential['@type'] ?? '?'} ${key}`);
|
||
if (loose.length > 0) {
|
||
warn(
|
||
`관리자 ${cfg.adminAddress} 의 다른 자격(${loose.join(', ')})은 허용 IP 가 앱 네트워크(${cfg.appNetworkSubnet}) 밖이거나 없어 바깥(993 · 465)에서도 관리자로 로그인된다. ` +
|
||
`쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지우고, 쓴다면 Allowed IPs 를 ${cfg.appNetworkSubnet} 안으로 두세요 (mail/README.md "관리자 계정 단단히").`,
|
||
);
|
||
}
|
||
}
|
||
const others = accounts
|
||
.filter((account) => isAdminAccount(account) && account.emailAddress?.toLowerCase() !== cfg.adminAddress)
|
||
.filter((account) =>
|
||
Object.values(account.credentials ?? {}).some(
|
||
(credential) => credential['@type'] === 'Password' && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet),
|
||
),
|
||
)
|
||
.map((account) => account.emailAddress?.toLowerCase());
|
||
if (others.length > 0) {
|
||
warn(
|
||
`관리자 역할 계정 ${others.join(', ')} 의 비밀번호에 허용 IP 가 없어 바깥(993 · 465)에서도 로그인된다 — 대리 접근은 역할에서 빠졌지만 관리 권한은 그대로다. ` +
|
||
`관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 ${cfg.appNetworkSubnet} 로 두거나 쓰지 않는 계정이면 지우세요 (mail/README.md "관리자 계정 단단히").`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만)
|
||
// ============================================================
|
||
|
||
/** 복구 관리자의 관리 API 클라이언트 — 값이 없거나, 로그인되지 않거나, 관리 권한이 없으면 null */
|
||
async function openRecoveryClient(cfg) {
|
||
if (!cfg.recovery) return null;
|
||
const client = await openClient(cfg, cfg.recovery.user, cfg.recovery.password);
|
||
if (!client?.accountId) return null;
|
||
return (await hasManagementAccess(client)) ? client : null;
|
||
}
|
||
|
||
/** 값이 true 인 키 (정렬) */
|
||
function trueKeys(value) {
|
||
return Object.entries(value ?? {})
|
||
.filter(([, flag]) => flag === true)
|
||
.map(([key]) => key)
|
||
.sort();
|
||
}
|
||
|
||
/** 사내 목록 역할이 setup 기준(DIRECTORY_PERMISSIONS 만 · 빼는 권한 없음 · 물려받는 역할 없음)과 다른 점 — 같으면 빈 배열 */
|
||
function directoryRoleDrift(role) {
|
||
const expected = Object.keys(DIRECTORY_PERMISSIONS);
|
||
const enabled = trueKeys(role.enabledPermissions);
|
||
const drift = [];
|
||
const missing = expected.filter((key) => !enabled.includes(key));
|
||
const extra = enabled.filter((key) => !expected.includes(key));
|
||
const disabled = trueKeys(role.disabledPermissions);
|
||
const inherited = trueKeys(role.roleIds);
|
||
if (missing.length > 0) drift.push(`모자란 권한 ${missing.join(', ')}`);
|
||
if (extra.length > 0) drift.push(`더 붙은 권한 ${extra.join(', ')}`);
|
||
if (disabled.length > 0) drift.push(`빼는 권한 ${disabled.join(', ')}`);
|
||
if (inherited.length > 0) drift.push(`물려받는 역할 ${inherited.join(', ')}`);
|
||
return drift;
|
||
}
|
||
|
||
/** 계정이 그 역할 하나만 가졌는가 (roleId 가 없으면 false) */
|
||
function holdsOnlyRole(account, roleId) {
|
||
return Boolean(roleId) && account.roles?.['@type'] === 'Custom' && sameTrueSet(account.roles.roleIds, [roleId]);
|
||
}
|
||
|
||
/** 계정 하나를 출력할 이름 — 주소, 없으면 아이디 */
|
||
function accountLabel(account) {
|
||
return account.emailAddress ?? account.name ?? account.id;
|
||
}
|
||
|
||
/**
|
||
* 사내 목록 계정을 맞춘다 — <MAIL_DIRECTORY_LOCAL_PART>@<도메인>, 역할 DIRECTORY_PERMISSIONS 하나 · 비밀번호 없음 · 저장 한도 DIRECTORY_QUOTAS
|
||
* (계약 docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md "사내 목록 계정").
|
||
* - 복구 관리자를 쓸 수 있으면(개발 · 운영 --recovery) 역할 · 계정을 만들거나 맞춘다.
|
||
* - 없으면(운영의 일반 실행) 관리자로 되는 것(계정의 설명 · 저장 한도 · 비밀번호 빼기 · 옛 계정 지우기)만 맞추고, 역할 · 계정이 없거나
|
||
* 권한이 다르면 [주의] 만 낸다 — 실패로 끝내지 않는다 (그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다 — 계약 3절).
|
||
* 관리자로는 역할의 권한을 고칠 수 없다(빼기도 — DIRECTORY_ROLE_DESCRIPTION 머리말).
|
||
* - 그 주소를 setup 이 만들지 않은 계정이 쓰고 있으면 건드리지 않고 실패로 보고한다 (백엔드는 그 주소를 사내 목록 · 웹메일 로그인에서 뺀다).
|
||
* - MAIL_DIRECTORY_LOCAL_PART 를 바꿨으면 옛 계정(setup 표시 + 이 역할 하나)은 새 계정이 준비된 뒤에 지운다.
|
||
* 이 계정은 메일을 보내지도 받지도 못한다(역할에 emailSend · emailReceive 가 없다) — 그래서 개발의 Mailpit 경로 리로드 전에 만들어도 된다.
|
||
* 준비되면 백엔드가 쓰는 방식 그대로 읽어 본다 (checkDirectoryAccess).
|
||
* 돌려주는 값: { address, state } — state 는 ready(준비됨) · unreadable(맞췄지만 읽지 못함) · pending(복구 관리자가 있어야 맞춘다) · conflict(다른 계정의 주소)
|
||
*/
|
||
async function ensureDirectoryAccount(admin, cfg, domain) {
|
||
const address = `${cfg.directoryLocalPart}@${domain.name}`;
|
||
const label = `사내 목록 계정 ${address}`;
|
||
const roleLabel = `역할 — 사내 목록 계정 목록 읽기 (${Object.keys(DIRECTORY_PERMISSIONS).join(', ')})`;
|
||
const shape = `비밀번호 없음 · 저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트 · 역할은 위 하나`;
|
||
const recovery = await openRecoveryClient(cfg);
|
||
const via = recovery ? ' (복구 관리자 사용)' : '';
|
||
// 계정의 설명 · 저장 한도 · 비밀번호 빼기 · 지우기는 관리자로도 된다
|
||
const writer = recovery ?? admin;
|
||
/** 복구 관리자가 있어야 맞출 수 있는 것 */
|
||
const pending = [];
|
||
let changed = false;
|
||
|
||
// 1) 역할 — 설명으로 찾는다
|
||
const roles = await listAll(admin, 'Role', ['description', 'enabledPermissions', 'disabledPermissions', 'roleIds']);
|
||
const role = roles.find((item) => item.description === DIRECTORY_ROLE_DESCRIPTION);
|
||
let roleId = role?.id ?? null;
|
||
if (!role) {
|
||
if (recovery) {
|
||
const result = await setObjects(recovery, 'Role', {
|
||
create: { role: { description: DIRECTORY_ROLE_DESCRIPTION, enabledPermissions: DIRECTORY_PERMISSIONS } },
|
||
});
|
||
roleId = result.created.role.id;
|
||
changed = true;
|
||
log(`[생성] ${roleLabel}${via}`);
|
||
} else {
|
||
pending.push('역할 없음');
|
||
}
|
||
} else {
|
||
const drift = directoryRoleDrift(role);
|
||
if (drift.length === 0) {
|
||
log(`[유지] ${roleLabel}`);
|
||
} else if (recovery) {
|
||
await setObjects(recovery, 'Role', {
|
||
update: { [role.id]: { enabledPermissions: DIRECTORY_PERMISSIONS, disabledPermissions: {}, roleIds: {} } },
|
||
});
|
||
changed = true;
|
||
log(`[변경] ${roleLabel} 로 되돌림 — 전: ${drift.join(' · ')}${via}`);
|
||
} else {
|
||
pending.push(`역할의 권한이 다름(${drift.join(' · ')})`);
|
||
}
|
||
}
|
||
|
||
// 2) 계정 — 아이디 + 도메인으로 찾는다 (비밀번호 값은 읽지도 출력하지도 않는다 — 있는지만 본다)
|
||
const accounts = await listAll(admin, 'Account', [
|
||
'name',
|
||
'domainId',
|
||
'emailAddress',
|
||
'description',
|
||
'roles',
|
||
'credentials',
|
||
'quotas',
|
||
'permissions',
|
||
'memberGroupIds',
|
||
]);
|
||
const existing = accounts.find((account) => account.name === cfg.directoryLocalPart && account.domainId === domain.id);
|
||
if (existing && !existing.description?.includes(MANAGED_MARK) && !holdsOnlyRole(existing, roleId)) {
|
||
problems.push(
|
||
`${address}(MAIL_DIRECTORY_LOCAL_PART)는 setup 이 만들지 않은 계정의 주소라 사내 목록 계정으로 쓰지 않았습니다 — 그 계정은 건드리지 않았습니다. ` +
|
||
'MAIL_DIRECTORY_LOCAL_PART 를 아무도 쓰지 않는 아이디로 바꾸고 backend 를 다시 띄운 뒤 이 스크립트를 다시 실행하세요 ' +
|
||
'(백엔드는 이 주소를 사내 목록 · 웹메일 로그인에서 뺀다 — mail/README.md "사내 목록 계정").',
|
||
);
|
||
return { address, state: 'conflict' };
|
||
}
|
||
const directoryRoles = roleId ? { '@type': 'Custom', roleIds: { [roleId]: true } } : null;
|
||
if (!existing) {
|
||
if (recovery && roleId) {
|
||
await setObjects(recovery, 'Account', {
|
||
create: {
|
||
account: {
|
||
'@type': 'User',
|
||
name: cfg.directoryLocalPart,
|
||
domainId: domain.id,
|
||
description: DIRECTORY_DESCRIPTION,
|
||
roles: directoryRoles,
|
||
quotas: DIRECTORY_QUOTAS,
|
||
},
|
||
},
|
||
});
|
||
changed = true;
|
||
log(`[생성] ${label} — ${shape}${via}`);
|
||
} else {
|
||
pending.push('계정 없음');
|
||
}
|
||
} else {
|
||
const patch = {};
|
||
const changes = [];
|
||
if (existing.description !== DIRECTORY_DESCRIPTION) {
|
||
patch.description = DIRECTORY_DESCRIPTION;
|
||
changes.push('표시 이름');
|
||
}
|
||
if (stableJson(existing.quotas ?? {}) !== stableJson(DIRECTORY_QUOTAS)) {
|
||
patch.quotas = DIRECTORY_QUOTAS;
|
||
changes.push(`저장 한도 ${formatCount(DIRECTORY_QUOTAS.maxDiskQuota)}바이트`);
|
||
}
|
||
if (Object.keys(existing.credentials ?? {}).length > 0) {
|
||
// 비밀번호가 있으면 그것으로 직접 로그인해 계정 목록(관리자 · 게이트웨이 주소 포함)을 읽을 수 있다
|
||
patch.credentials = {};
|
||
changes.push('비밀번호 뺌');
|
||
}
|
||
let rolePending = false;
|
||
if (roleId && !holdsOnlyRole(existing, roleId)) {
|
||
if (recovery) {
|
||
patch.roles = directoryRoles;
|
||
changes.push('역할');
|
||
} else {
|
||
pending.push('계정의 역할이 다름');
|
||
rolePending = true;
|
||
}
|
||
}
|
||
if (changes.length > 0) {
|
||
await setObjects(writer, 'Account', { update: { [existing.id]: patch } });
|
||
changed = true;
|
||
log(`[변경] ${label} — ${changes.join(', ')}${via}`);
|
||
} else if (roleId && !rolePending) {
|
||
log(`[유지] ${label} — ${shape}`);
|
||
}
|
||
if (
|
||
stableJson(existing.permissions ?? INHERIT_PERMISSIONS) !== stableJson(INHERIT_PERMISSIONS) ||
|
||
Object.keys(existing.memberGroupIds ?? {}).length > 0
|
||
) {
|
||
warn(
|
||
`${label} 에 역할 밖의 권한 설정(permissions)이나 그룹(memberGroupIds)이 있습니다 — setup 은 이것을 맞추지 않는다. ` +
|
||
'관리 화면 › 계정에서 지우세요 (mail/README.md "사내 목록 계정").',
|
||
);
|
||
}
|
||
}
|
||
const ready = Boolean(roleId) && pending.length === 0;
|
||
|
||
// 3) 이 역할을 가진 다른 계정 — setup 이 만든 옛 계정(아이디를 바꿨다)은 새 계정이 준비된 뒤 지우고, 그 밖은 알린다
|
||
if (roleId) {
|
||
const others = accounts.filter(
|
||
(account) => account !== existing && account.roles?.['@type'] === 'Custom' && account.roles.roleIds?.[roleId] === true,
|
||
);
|
||
const stale = others.filter((account) => account.description?.includes(MANAGED_MARK) && holdsOnlyRole(account, roleId));
|
||
const foreign = others.filter((account) => !stale.includes(account));
|
||
if (stale.length > 0 && ready) {
|
||
await setObjects(writer, 'Account', { destroy: stale.map((account) => account.id) });
|
||
changed = true;
|
||
for (const account of stale) log(`[삭제] 옛 사내 목록 계정 ${accountLabel(account)} — MAIL_DIRECTORY_LOCAL_PART 에서 빠짐${via}`);
|
||
} else if (stale.length > 0) {
|
||
log(`[유지] 옛 사내 목록 계정 ${stale.map(accountLabel).join(', ')} — 새 계정 ${address} 가 준비된 뒤에 지운다`);
|
||
}
|
||
if (foreign.length > 0) {
|
||
warn(
|
||
`사내 목록 역할을 다른 계정도 가지고 있습니다: ${foreign.map(accountLabel).join(', ')} — 그 계정은 계정 목록 전체(관리자 · 게이트웨이 주소 포함)를 읽을 수 있다. ` +
|
||
'의도한 것이 아니면 관리 화면 › 계정에서 그 계정의 역할을 바꾸세요 (mail/README.md "사내 목록 계정").',
|
||
);
|
||
}
|
||
}
|
||
|
||
// 역할 권한 · 계정은 캐시된다 — 바로 반영되게 비운다
|
||
if (changed) await setObjects(writer, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } });
|
||
if (ready) {
|
||
const readable = await checkDirectoryAccess(cfg, address, { strict: Boolean(recovery) });
|
||
return { address, state: readable === false ? 'unreadable' : 'ready' };
|
||
}
|
||
|
||
// 복구 관리자가 있어야 맞출 수 있는 것이 남았다
|
||
const absent = pending.includes('역할 없음') || pending.includes('계정 없음');
|
||
const how = cfg.isProduction
|
||
? '--recovery 로 한 번 실행하세요'
|
||
: `${RECOVERY_ENV_KEY} 를 채우고 mail 컨테이너를 다시 만든 뒤(docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d mail) 다시 실행하세요`;
|
||
const message = absent
|
||
? `사내 목록 계정이 없습니다 (${pending.join(' · ')}) — ${how} (mail/README.md "사내 목록 계정"). 그동안 백엔드는 사내 목록 대신 로그인한 직원 기록을 쓴다.`
|
||
: `사내 목록 계정이 setup 기준과 다릅니다 (${pending.join(' · ')}) — 관리자로는 맞출 수 없다(엔진이 거절한다). ${how} (mail/README.md "사내 목록 계정").`;
|
||
if (cfg.recovery && !recovery) {
|
||
// 복구 관리자를 줬는데 쓰지 못했다 — 운영은 --recovery 실행이므로 실패로 본다
|
||
const reason =
|
||
`복구 관리자(${RECOVERY_ENV_KEY})로 관리 API 에 로그인하지 못해 사내 목록 계정을 맞추지 못했습니다 — ` +
|
||
`mail 컨테이너가 지금 값으로 만들어졌는지(up -d mail) 확인하세요. `;
|
||
if (cfg.isProduction) problems.push(reason + message);
|
||
else warn(reason + message);
|
||
} else {
|
||
warn(message);
|
||
}
|
||
// 계정은 있는데 기준과 다르면 — 지금 백엔드가 읽을 수 있는지만 알려 준다 (더 붙은 권한만 있으면 읽힌다)
|
||
if (!absent && existing) await checkDirectoryAccess(cfg, address, { strict: false });
|
||
return { address, state: 'pending' };
|
||
}
|
||
|
||
/**
|
||
* 사내 목록 계정으로 계정 목록을 읽을 수 있는지 — 백엔드가 쓰는 방식 그대로
|
||
* (게이트웨이 대리 접근 <사내 목록 주소>%<게이트웨이 주소> → 세션의 principals 계정 → Principal/query · get).
|
||
* 역할을 바꾼 직후에는 캐시가 퍼질 때까지 몇 번 다시 본다. 이 스크립트의 접속 주소가 게이트웨이 허용 IP 밖이면
|
||
* (MAIL_TRUSTED_NETWORKS 를 좁힌 운영) 게이트웨이 자신도 로그인되지 않으므로 건너뛴다.
|
||
* 읽지 못하면 strict(복구 관리자로 맞춘 실행)는 실패로, 아니면 [주의] 로 남긴다. 돌려주는 값: true · false · null(건너뜀)
|
||
*/
|
||
async function checkDirectoryAccess(cfg, address, { strict }) {
|
||
const login = `${address}%${cfg.gatewayAddress}`;
|
||
const authorization = basicAuth(login, cfg.gatewayPassword);
|
||
let outcome = '';
|
||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||
const sessionRes = await request(cfg, '/jmap/session', { headers: { authorization } });
|
||
if (!sessionRes.ok) {
|
||
await sessionRes.body?.cancel();
|
||
if (sessionRes.status === 401 && (await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) === 401) {
|
||
log(`[건너뜀] 이 스크립트의 접속 주소가 게이트웨이 허용 IP(신뢰 대역) 밖이라 사내 목록 계정의 대리 접근(${login})을 확인하지 못했습니다.`);
|
||
return null;
|
||
}
|
||
outcome = `세션 HTTP ${sessionRes.status}${sessionRes.status === 403 ? ' — 역할에 authenticate 가 없거나 빼는 권한에 들어 있다' : ''}`;
|
||
} else {
|
||
const session = await sessionRes.json();
|
||
const accountId = session.primaryAccounts?.[PRINCIPALS_CAPABILITY];
|
||
if (!accountId) {
|
||
outcome = `세션에 ${PRINCIPALS_CAPABILITY} 계정이 없음`;
|
||
} else {
|
||
// 백엔드처럼 찾고(query) 읽는다(get) — 둘 중 하나만 막혀도 사내 목록을 채우지 못한다
|
||
const res = await request(cfg, '/jmap/', {
|
||
method: 'POST',
|
||
headers: { authorization, 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
using: ['urn:ietf:params:jmap:core', PRINCIPALS_CAPABILITY],
|
||
methodCalls: [
|
||
['Principal/query', { accountId, limit: 1 }, 'q'],
|
||
['Principal/get', { accountId, '#ids': { resultOf: 'q', name: 'Principal/query', path: '/ids' }, properties: ['email'] }, 'g'],
|
||
],
|
||
}),
|
||
});
|
||
if (res.ok) {
|
||
const responses = (await res.json()).methodResponses;
|
||
const failed = responses.find(([name]) => name === 'error');
|
||
if (!failed) {
|
||
log(`[확인] 사내 목록 — 대리 접근 ${login} 로 계정 목록을 읽을 수 있습니다 (Principal/query · get).`);
|
||
return true;
|
||
}
|
||
outcome = `Principal/${failed[2] === 'q' ? 'query' : 'get'} → ${failed[1].type}`;
|
||
} else {
|
||
await res.body?.cancel();
|
||
outcome = `JMAP 요청 HTTP ${res.status}`;
|
||
}
|
||
}
|
||
}
|
||
await sleep(POLL_INTERVAL_MS / 2);
|
||
}
|
||
const message =
|
||
`사내 목록 계정으로 계정 목록을 읽지 못했습니다 — ${login}: ${outcome}. ` +
|
||
'역할의 권한(authenticate · jmapPrincipalGet · jmapPrincipalQuery)과 게이트웨이 허용 IP 를 확인하세요 (mail/README.md "사내 목록 계정").';
|
||
if (strict) problems.push(message);
|
||
else warn(message);
|
||
return false;
|
||
}
|
||
|
||
/** 공개 443(https 리스너)의 HTTP 경로 정책 — 바꿨으면 true (ReloadSettings 뒤에 반영된다) */
|
||
async function ensureHttpEndpointPolicy(client) {
|
||
const current = (await call(client, 'x:Http/get', { ids: ['singleton'], properties: ['allowedEndpoints'] })).list[0];
|
||
if (stableJson(current?.allowedEndpoints) === stableJson(HTTP_ENDPOINT_POLICY)) {
|
||
log('[유지] 공개 443 — JMAP·관리 API·웹 포털 차단 (MTA-STS 정책 파일만 허용)');
|
||
return false;
|
||
}
|
||
await setObjects(client, 'Http', { update: { singleton: { allowedEndpoints: HTTP_ENDPOINT_POLICY } } });
|
||
log('[변경] 공개 443 — JMAP·관리 API·웹 포털 차단 (MTA-STS 정책 파일만 허용)');
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 메일을 받지 않는 시스템 계정의 주소 — 게이트웨이 · 사내 목록(역할을 setup 이 정한다 — emailReceive 없음)과,
|
||
* 관리자 역할(ADMIN_ROLE_DESCRIPTION)에 emailReceive 가 없을 때의 관리자 역할 계정 모두(MAIL_ADMIN_LOCAL_PART 관리자 + 더 만든 관리자).
|
||
* 관리자 역할을 찾지 못하거나 그 역할이 메일을 받게 바뀌었으면 관리자 주소는 넣지 않는다 — 받는 계정을 막지 않게.
|
||
* 사내 목록 주소를 setup 이 만들지 않은 계정이 쓰고 있으면(conflict — 실제 메일함일 수 있다) 그 주소도 넣지 않는다.
|
||
*/
|
||
async function nonReceivingSystemAddresses(client, cfg, directory) {
|
||
const roles = await listAll(client, 'Role', ['description', 'enabledPermissions']);
|
||
const adminRole = roles.find((role) => role.description === ADMIN_ROLE_DESCRIPTION);
|
||
const adminsReceive = !adminRole || adminRole.enabledPermissions?.emailReceive === true;
|
||
const admins = adminsReceive
|
||
? []
|
||
: (await listAll(client, 'Account', ['emailAddress', 'roles']))
|
||
.filter(isAdminAccount)
|
||
.map((account) => account.emailAddress?.toLowerCase())
|
||
.filter(Boolean)
|
||
.sort((a, b) => (a === cfg.adminAddress ? -1 : b === cfg.adminAddress ? 1 : a.localeCompare(b)));
|
||
if (adminsReceive) {
|
||
warn(
|
||
`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못했거나 메일을 받게(emailReceive) 바뀌어 관리자 주소는 RCPT 거절에 넣지 않았습니다 — ` +
|
||
'관리자 계정이 메일을 받지 못한다면 그 주소로 온 메일은 받아 놓고 반송한다 (mail/README.md "보안").',
|
||
);
|
||
}
|
||
return [...new Set([...admins, cfg.gatewayAddress, ...(directory.state === 'conflict' ? [] : [directory.address])])];
|
||
}
|
||
|
||
/**
|
||
* 시스템 계정 주소 → 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(' || ');
|
||
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 ?? {});
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* setup 의 RCPT 단계 식(또는 기본값)에 가드 갈래를 더한 식 — 시스템 계정 갈래는 그대로 두고 둘째에 가드를 둔다(이미 있으면 그대로).
|
||
* 기본값(식 없음)이면 가드 갈래 하나만 — 다음 평소 실행이 시스템 계정 갈래를 채운다. setup 의 식도 기본값도 아니면 null
|
||
*/
|
||
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);
|
||
const desired = { description: SYSTEM_RCPT_SCRIPT.description, isActive: true, contents: SYSTEM_RCPT_SCRIPT.contents };
|
||
if (!script) {
|
||
await setObjects(client, 'SieveSystemScript', { create: { script: { name: SYSTEM_RCPT_SCRIPT.name, ...desired } } });
|
||
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 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;
|
||
}
|
||
if (stableJson(current) !== stableJson(RCPT_SCRIPT_UNSET) && !isOwnRcptScriptExpression(current)) {
|
||
warn(
|
||
`RCPT 단계 스크립트(x:MtaStageRcpt 의 script)를 다른 설정이 쓰고 있어 바꾸지 않았습니다 (${JSON.stringify(current)}) — ` +
|
||
`메일을 받지 않는 시스템 계정(${addresses.join(', ')})은 지금처럼 받아 놓고 보낸 사람에게 반송한다. ` +
|
||
'그 스크립트를 고르는 식에 이 계정들일 때 거절하는 갈래를 합치세요 (mail/README.md "보안").',
|
||
);
|
||
return false;
|
||
}
|
||
await setObjects(client, 'MtaStageRcpt', { update: { singleton: { script: expression } } });
|
||
log(`[변경] ${label}${!guard && hasRcptGuard(current) ? ' — 시범 공존의 가드 갈래를 뺐다 (allowRelaying 이 꺼진 것을 다시 읽어 확인)' : ''}`);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다.
|
||
* 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다).
|
||
* 한도 값(rate)·켜짐(enable)은 건드리지 않는다 — 운영에서 정할 것 (mail/README.md "엔진 한도").
|
||
* pilot(시범 공존)이면 기존 서버 IP 도 뺀다 — 기존 서버가 넘기는 동료 전체의 사내 메일은 보낸 도메인이 모두 같아 한 묶음으로 세어진다
|
||
* (개발에서 같은 IP · 같은 도메인 26번째부터 452 → 뺀 뒤 30/30 통과, 2026-10-29).
|
||
*/
|
||
async function ensureRecipientThrottle(client, pilot = null) {
|
||
const label = `받는 쪽 한도 "${RECIPIENT_THROTTLE.description}"(같은 보낸 도메인 → 한 받는 사람)`;
|
||
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(desired)} 인지 ` +
|
||
'관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 — 아니면 로그인한 직원의 발송도 그 한도에 걸린다 (mail/README.md "엔진 한도").',
|
||
);
|
||
return;
|
||
}
|
||
// 시범이 꺼진 실행 — 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: desired }])),
|
||
});
|
||
}
|
||
for (const item of throttles) {
|
||
const disabled = item.enable === false ? ' · 꺼져 있음(enable: false)' : '';
|
||
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}`);
|
||
}
|
||
}
|
||
|
||
/** 세 자리마다 쉼표 — 숫자가 아니면 JSON 그대로 */
|
||
function formatCount(value) {
|
||
return typeof value === 'number' ? value.toLocaleString('en-US') : JSON.stringify(value);
|
||
}
|
||
|
||
/** 밀리초 → 시간·분으로 떨어지면 그 단위로 */
|
||
function formatDuration(ms) {
|
||
if (!Number.isSafeInteger(ms) || ms <= 0) return `${formatCount(ms)}ms`;
|
||
if (ms % 3_600_000 === 0) return `${ms / 3_600_000}시간`;
|
||
if (ms % 60_000 === 0) return `${ms / 60_000}분`;
|
||
return `${formatCount(ms)}ms`;
|
||
}
|
||
|
||
/**
|
||
* 계정마다 임시 업로드 크기 합계(x:Jmap 의 uploadQuota)를 첨부 합계 한도의 UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL 배 이상으로 — ReloadSettings 뒤에 반영된다.
|
||
* 더 크게 둔 값은 내리지 않는다(운영에서 정한 것). 개수(maxUploadCount) · 기간(uploadTtl) · 한 번 크기(maxUploadSize)는 건드리지 않는다 (mail/README.md "엔진 한도").
|
||
* 0 은 뜻(제한 없음인지)을 확인하지 않았으므로 바꾸지 않고 알린다.
|
||
*/
|
||
async function ensureUploadQuota(client, cfg) {
|
||
const current = (await call(client, 'x:Jmap/get', { ids: ['singleton'], properties: ['uploadQuota', 'maxUploadCount', 'uploadTtl'] })).list[0];
|
||
const quota = current?.uploadQuota;
|
||
const label = '임시 업로드 한도 — 계정마다 크기 합계';
|
||
const floor = `첨부 합계 ${cfg.attachmentTotalMb}MB × ${UPLOAD_QUOTA_PER_ATTACHMENT_TOTAL}`;
|
||
const untouched = `개수 ${formatCount(current?.maxUploadCount)} · 기간 ${formatDuration(current?.uploadTtl)}은 그대로`;
|
||
if (!Number.isSafeInteger(quota) || quota < 0) {
|
||
problems.push(`임시 업로드 한도(x:Jmap 의 uploadQuota = ${formatCount(quota)})를 읽지 못해 바꾸지 않았습니다 — ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상이어야 합니다.`);
|
||
return;
|
||
}
|
||
if (quota === 0) {
|
||
warn(
|
||
`임시 업로드 한도(x:Jmap 의 uploadQuota)가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. ` +
|
||
`제한 없음으로 둔 것이 아니라면 ${floor} = ${formatCount(cfg.uploadQuotaFloor)}바이트 이상으로 두세요 (mail/README.md "엔진 한도").`,
|
||
);
|
||
return;
|
||
}
|
||
if (quota >= cfg.uploadQuotaFloor) {
|
||
log(`[유지] ${label} ${formatCount(quota)}바이트 (${floor} = ${formatCount(cfg.uploadQuotaFloor)} 이상) · ${untouched}`);
|
||
return;
|
||
}
|
||
await setObjects(client, 'Jmap', { update: { singleton: { uploadQuota: cfg.uploadQuotaFloor } } });
|
||
log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`);
|
||
}
|
||
|
||
/** x:<Type> 싱글톤의 칸들 — 읽지 못하면(메서드 오류 · 요청 실패 · 싱글톤 없음) 멈추지 않고 { error: 까닭 } */
|
||
async function readSingleton(client, type, properties) {
|
||
try {
|
||
const item = (await call(client, `x:${type}/get`, { ids: ['singleton'], properties })).list?.[0];
|
||
return item ? { value: item } : { error: `x:${type}/get 에 singleton 이 없다` };
|
||
} catch (error) {
|
||
if (!(error instanceof SetupError)) throw error;
|
||
return { error: error.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 받는 메일 크기 한도 (SMTP_MESSAGE_SIZE_LIMIT · ENGINE_DEFAULT_SIZE_LIMITS 머리말 — mail/README.md "엔진 한도" 「받는 메일 크기」).
|
||
* 1. SMTP 로 받는 한 통(x:MtaStageData 의 maxMessageSize)을 SMTP_MESSAGE_SIZE_LIMIT 이하로 — 저장만 한다(main 의 리로드로 반영).
|
||
* 더 작게 둔 값은 올리지 않는다. 0 은 뜻(제한 없음인지)을 확인하지 않았고, 갈래가 있거나 else 가 숫자 하나가 아닌 식은 운영자의 것이라 바꾸지 않고 [주의].
|
||
* 2. IMAP 요청 · 메일 · 첨부 크기(x:Imap · x:Email)는 읽기만 한다 — 엔진 기본보다 크면 [주의], 읽지 못해도 멈추지 않는다.
|
||
* 돌려주는 값: 1 의 상태 — 리로드 뒤 확인(checkMessageSizeLimit)에 넘긴다. 'saved' 이번에 낮춰 저장 · 'kept' 단순한 꼴(갈래 없음 · 숫자 else)로 이미 한도 이하 ·
|
||
* 'operator' 운영자의 식 · 'zero' 0 · 'unread' 읽지 못함(problems)
|
||
* ⚠️ 출력의 "받는 메일 크기 한도" 는 운영 차례 문서(mail/README.md "큰 메일")가 이 글자로 확인한다 — 바꾸지 않는다
|
||
*/
|
||
async function ensureMessageSizeLimits(client) {
|
||
const label = '받는 메일 크기 한도(SMTP — x:MtaStageData 의 maxMessageSize)';
|
||
const mib = `${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB`;
|
||
const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${mib})`;
|
||
const memory = '큰 메일 한 통에 엔진 메모리가 그 크기의 5.5배쯤 든다';
|
||
const smtp = await readSingleton(client, 'MtaStageData', ['maxMessageSize']);
|
||
const expression = smtp.value?.maxMessageSize;
|
||
let state;
|
||
if (smtp.error || !isExpression(expression)) {
|
||
state = 'unread';
|
||
problems.push(
|
||
`${label}를 읽지 못해 맞추지 않았습니다 (${smtp.error ?? JSON.stringify(expression) ?? '칸 없음'}) — ${limit} 이하여야 합니다 (mail/README.md "엔진 한도").`,
|
||
);
|
||
} else if (branchesOf(expression).length > 0 || !/^\d+$/.test(String(expression.else))) {
|
||
state = 'operator';
|
||
warn(
|
||
`${label}가 운영자의 식이라(갈래가 있거나 else 가 숫자 하나가 아니다) 바꾸지 않았습니다 (${JSON.stringify(expression)}) — ` +
|
||
`모든 갈래와 else 가 ${limit} 이하인지 관리 화면에서 확인하세요. ${memory} (mail/README.md "엔진 한도").`,
|
||
);
|
||
} else {
|
||
const size = Number(expression.else);
|
||
if (size === 0) {
|
||
state = 'zero';
|
||
warn(`${label}가 0 입니다 — 0 의 뜻(제한 없음인지)을 확인하지 않아 바꾸지 않았습니다. 관리 화면에서 1 이상 ${limit} 이하로 두세요 — ${memory} (mail/README.md "엔진 한도").`);
|
||
} else if (size > SMTP_MESSAGE_SIZE_LIMIT) {
|
||
state = 'saved';
|
||
await setObjects(client, 'MtaStageData', { update: { singleton: { maxMessageSize: { match: {}, else: String(SMTP_MESSAGE_SIZE_LIMIT) } } } });
|
||
log(`[변경] ${label} ${limit} · 전 ${formatCount(size)}`);
|
||
} else {
|
||
state = 'kept';
|
||
log(`[유지] ${label} ${formatCount(size)}바이트 (${mib} 이하)`);
|
||
}
|
||
}
|
||
|
||
// IMAP 요청 · 메일 · 첨부 크기 — 이전(imapsync) 동안 운영자가 올리므로 바꾸지 않는다. 0 · null 은 뜻을 확인하지 않은 값이라 "확인하지 못함"
|
||
const scope = 'IMAP 요청 · 메일 크기 한도';
|
||
const large = [];
|
||
const unread = [];
|
||
const within = [];
|
||
for (const [type, defaults] of Object.entries(ENGINE_DEFAULT_SIZE_LIMITS)) {
|
||
const read = await readSingleton(client, type, Object.keys(defaults));
|
||
if (read.error) {
|
||
unread.push(`x:${type} — ${read.error}`);
|
||
continue;
|
||
}
|
||
for (const [field, engineDefault] of Object.entries(defaults)) {
|
||
const value = read.value[field];
|
||
const name = `x:${type} 의 ${field}`;
|
||
if (!Number.isSafeInteger(value) || value <= 0) unread.push(`${name} = ${JSON.stringify(value) ?? '칸 없음'}`);
|
||
else if (value > engineDefault) large.push(`${name} ${formatCount(value)}(기본 ${formatCount(engineDefault)})`);
|
||
else within.push(`${name} ${formatCount(value)}`);
|
||
}
|
||
}
|
||
if (large.length > 0) {
|
||
warn(
|
||
`${scope}가 엔진 기본보다 큽니다 — ${large.join(' · ')}. ${memory} — 메모리 1g 로는 100MB 남짓(운영 106.4MB) 한 통에서 죽을 수 있다. ` +
|
||
'이전(imapsync)하는 동안 올렸다면 마친 뒤 관리 화면에서 기본으로 되돌린다 — setup 은 바꾸지 않는다 (mail/README.md "큰 메일").',
|
||
);
|
||
}
|
||
if (unread.length > 0) {
|
||
warn(
|
||
`${scope}를 읽지 못했거나 뜻을 확인하지 않은 값(0 · null 등)이라 엔진 기본보다 큰지 확인하지 못했습니다 (${unread.join(' · ')}) — ` +
|
||
'setup 은 바꾸지 않는다, 관리 화면에서 본다 (mail/README.md "엔진 한도").',
|
||
);
|
||
}
|
||
if (large.length === 0 && unread.length === 0) log(`[확인] ${scope} — 엔진 기본 이하 (${within.join(' · ')})`);
|
||
return state;
|
||
}
|
||
|
||
/** 시 · 분 → "HH:MM" */
|
||
function formatClock(hour, minute) {
|
||
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
||
}
|
||
|
||
/**
|
||
* 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각 (TRASH_RETENTION_MS · RETENTION_SCHEDULES_KST 머리말) — 저장만 한다(main 의 리로드로 반영).
|
||
* 정리 시각은 메일 컨테이너의 현지 시각으로 넣는다 — 컨테이너의 UTC 차이를 읽지 못하면 UTC 로 본다(compose 는 mail 에 TZ 를 주지 않는다).
|
||
* 관리 화면에서 바꾼 값은 되돌린다. 돌려주는 값: 요약 글
|
||
*/
|
||
async function ensureDataRetention(client, cfg) {
|
||
const fields = ['expungeTrashAfter', ...RETENTION_SCHEDULES_KST.map((item) => item.field)];
|
||
const current = (await call(client, 'x:DataRetention/get', { ids: ['singleton'], properties: fields })).list[0] ?? {};
|
||
const read = mailUtcOffsetMinutes(cfg);
|
||
const offset = read ?? 0;
|
||
const patch = {};
|
||
const lines = [];
|
||
const days = TRASH_RETENTION_MS / 86_400_000;
|
||
const trashSame = current.expungeTrashAfter === TRASH_RETENTION_MS;
|
||
if (!trashSame) patch.expungeTrashAfter = TRASH_RETENTION_MS;
|
||
lines.push(
|
||
`${trashSame ? '[유지]' : '[변경]'} 휴지통 · 스팸메일함 자동 비우기 — 들어온 지 ${days}일이 지나면 영구 삭제 (x:DataRetention 의 expungeTrashAfter` +
|
||
`${trashSame ? '' : ` · 전 ${current.expungeTrashAfter === null ? '꺼짐(null)' : formatDuration(current.expungeTrashAfter)}`})`,
|
||
);
|
||
for (const item of RETENTION_SCHEDULES_KST) {
|
||
const local = (((item.hour * 60 + item.minute - KST_OFFSET_MINUTES + offset) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY;
|
||
const desired = { '@type': 'Daily', hour: Math.floor(local / 60), minute: local % 60 };
|
||
const same = stableJson(current[item.field]) === stableJson(desired);
|
||
if (!same) patch[item.field] = desired;
|
||
const was = current[item.field]?.['@type'] === 'Daily' ? formatClock(current[item.field].hour, current[item.field].minute) : stableJson(current[item.field]);
|
||
lines.push(
|
||
`${same ? '[유지]' : '[변경]'} 엔진 정리 시각 — ${item.label} 매일 KST ${formatClock(item.hour, item.minute)} ` +
|
||
`(컨테이너 시각 ${formatClock(desired.hour, desired.minute)}${same ? '' : ` · 전 ${was}`})`,
|
||
);
|
||
}
|
||
if (Object.keys(patch).length > 0) await setObjects(client, 'DataRetention', { update: { singleton: patch } });
|
||
for (const line of lines) log(line);
|
||
if (read === null) {
|
||
log(`[참고] 메일 컨테이너의 시간대를 읽지 못해 UTC 로 보고 정리 시각을 넣었습니다 (docker 를 쓸 수 없거나 date 가 없음).`);
|
||
} else if (read !== 0) {
|
||
log(`[참고] 메일 컨테이너의 현지 시각이 UTC${read > 0 ? '+' : '-'}${formatClock(Math.floor(Math.abs(read) / 60), Math.abs(read) % 60)} 라 그 시각으로 바꿔 넣었습니다.`);
|
||
}
|
||
if (Object.keys(patch).some((field) => field !== 'expungeTrashAfter')) {
|
||
log('[참고] 바뀐 정리 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 mail 컨테이너가 다시 뜬 뒤 — 밤 백업이 다시 띄운다)부터 걸린다.');
|
||
}
|
||
return `들어온 지 ${days}일 · 정리 KST ${RETENTION_SCHEDULES_KST.map((item) => formatClock(item.hour, item.minute)).join(' · ')}`;
|
||
}
|
||
|
||
/** 엔진 DNS(x:DnsResolver)를 사람이 읽는 글로 — @type 과 Custom 의 서버 */
|
||
function describeEngineDns(value) {
|
||
const servers = Object.values(value?.servers ?? {}).map((server) => `${server.address}:${server.port}/${server.protocol}`);
|
||
return `${value?.['@type'] ?? '없음'}${servers.length > 0 ? ` (${servers.join(' · ')})` : ''}`;
|
||
}
|
||
|
||
/**
|
||
* Custom 의 서버들을 견줄 꼴로 — 서버마다 "protocol address:port" 만 뽑아 정렬한 목록.
|
||
* 엔진이 읽어 줄 때 붙일 수 있는 다른 칸 · 키 이름 · 차례는 보지 않는다 — 통째로 견주면 그런 칸 하나로 매번 [변경] 이 되고,
|
||
* 그러면 checkEngineDnssec 이 기동 로그의 DNSSEC 경고를 늘 [참고] 로 낮춰 가린다(2026-10-30 리뷰 L3). 엔진도 주소마다 연결을 모아 쓴다(resolver.rs)
|
||
*/
|
||
function engineDnsServerList(servers) {
|
||
return Object.values(servers ?? {})
|
||
.map((server) => `${String(server?.protocol ?? '').toLowerCase()} ${server?.address}:${server?.port}`)
|
||
.sort();
|
||
}
|
||
|
||
/**
|
||
* 엔진의 DNS(x:DnsResolver 싱글톤)를 메일 전용 리졸버로 맞춘다 — 저장만 한다(main 의 리로드로 반영). 바꿨으면 true.
|
||
* 운영은 Custom(리졸버 주소의 UDP + TCP — 도커 DNS 를 거치지 않는다), 개발은 System(도커 DNS → compose 의 dns = 리졸버 — Mailpit 같은 내부 이름 때문).
|
||
* 견주는 것은 @type 과 서버마다의 protocol · address · port 뿐이다(engineDnsServerList) — 공통 칸(attempts · timeout · enableEdns …)은 엔진 값을
|
||
* 그대로 둔다(@type 만 바꿔도 남는다 — DEFAULT_RESOLVER_SUBNET 머리말).
|
||
* preflight 의 checkResolver 가 리졸버를 확인한 뒤에만 여기에 온다(docker 가 없는 개발은 확인 없이 System — 리졸버 주소를 엔진에 넣지 않는다).
|
||
*/
|
||
async function ensureEngineDns(client, cfg) {
|
||
const current = (await call(client, 'x:DnsResolver/get', { ids: ['singleton'] })).list[0];
|
||
const { ip } = cfg.resolver;
|
||
const desired = cfg.isProduction
|
||
? {
|
||
'@type': 'Custom',
|
||
servers: {
|
||
0: { protocol: 'udp', address: ip, port: RESOLVER_PORT },
|
||
1: { protocol: 'tcp', address: ip, port: RESOLVER_PORT },
|
||
},
|
||
}
|
||
: { '@type': 'System' };
|
||
const label = cfg.isProduction
|
||
? `엔진 DNS — 메일 전용 리졸버 ${ip}:${RESOLVER_PORT} 에 바로 (Custom · UDP + TCP — DNSSEC · 도커 내부 이름은 풀지 않는다)`
|
||
: `엔진 DNS — System (도커 DNS 가 내부 이름(mailpit)은 풀고 바깥 이름은 메일 전용 리졸버 ${ip} 에 묻는다)`;
|
||
const same =
|
||
current?.['@type'] === desired['@type'] &&
|
||
(!desired.servers || stableJson(engineDnsServerList(current?.servers)) === stableJson(engineDnsServerList(desired.servers)));
|
||
if (same) {
|
||
log(`[유지] ${label}`);
|
||
return false;
|
||
}
|
||
await setObjects(client, 'DnsResolver', { update: { singleton: desired } });
|
||
log(`[변경] ${label} · 전 ${describeEngineDns(current)}`);
|
||
return true;
|
||
}
|
||
|
||
/** Spamhaus 항목의 조회 이름 식 — key 가 있으면 DQS(<값>.<키>.<영역>), 없으면 공개 미러 */
|
||
function spamhausZone(entry, key = null) {
|
||
return { match: {}, else: key ? `${entry.subject} + '.${key}.${entry.dqsZone}'` : `${entry.subject} + '.${entry.publicZone}'` };
|
||
}
|
||
|
||
/** 지금 조회 이름 식이 어떤 꼴인지 — 키를 드러내지 않는다 (식 · 옛 식을 그대로 출력하지 않는 까닭: DQS 키가 들어 있다) */
|
||
function describeSpamhausZone(entry, zone, key) {
|
||
if (stableJson(zone) === stableJson(spamhausZone(entry))) return `공개 미러 ${entry.publicZone}`;
|
||
if (key && stableJson(zone) === stableJson(spamhausZone(entry, key))) return `DQS ${entry.dqsZone}`;
|
||
const other = typeof zone?.else === 'string' && Object.keys(zone?.match ?? {}).length === 0 ? zone.else : '';
|
||
if (other.startsWith(`${entry.subject} + '.`) && other.endsWith(`.${entry.dqsZone}'`)) return `DQS ${entry.dqsZone}(다른 키)`;
|
||
return '다른 식(키가 들 수 있어 출력하지 않는다)';
|
||
}
|
||
|
||
/**
|
||
* 스팸 필터의 Spamhaus 두 항목(SPAMHAUS_DNSBL)을 키에 맞춘다 — 저장만 한다(main 의 리로드로 반영).
|
||
* 키가 있고 preflight 의 시험(checkSpamhausKey)을 통과한 항목만 DQS 로 켠다. 그 밖(키 없음 · 시험 실패 · 확인 못 함)은 끄고 조회 이름 식을
|
||
* 엔진 기본(공개 미러)으로 되돌린다 — 옛 키가 엔진에 남지 않게, 공개 미러로 묻지 않게. tag 식은 그대로 둔다(응답 코드가 같다).
|
||
* 항목은 이름으로 찾는다(id 는 설치마다 다르다). 없으면 만들지 않고 [주의].
|
||
* 돌려주는 값: 요약에 쓸 글
|
||
*/
|
||
async function ensureSpamhausDnsbl(client, cfg, verified) {
|
||
const key = cfg.spamhausKey;
|
||
const servers = await listAll(client, 'SpamDnsblServer', ['name', 'zone', 'enable']);
|
||
const update = {};
|
||
const changes = [];
|
||
const states = [];
|
||
for (const entry of SPAMHAUS_DNSBL) {
|
||
const server = servers.find((item) => item.name === entry.name);
|
||
if (!server) {
|
||
warn(
|
||
`스팸 차단 목록 ${entry.label}(${entry.name})을 찾지 못해 맞추지 않았습니다 — 지웠다면 그대로 두고, 이름을 바꿨다면 그 항목의 조회 이름 · 켜짐을 ` +
|
||
'관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").',
|
||
);
|
||
states.push(`${entry.short} 없음`);
|
||
continue;
|
||
}
|
||
const check = verified?.get(entry.name);
|
||
const useDqs = Boolean(key && check?.ok);
|
||
const desired = { zone: spamhausZone(entry, useDqs ? key : null), enable: useDqs };
|
||
const target = useDqs ? `DQS 로 묻는다 (${entry.dqsZone} — 키 ${SPAMHAUS_DQS_KEY_ENV})` : `꺼짐 (공개 미러 ${entry.publicZone} 로 묻지 않는다)`;
|
||
states.push(`${entry.short} ${useDqs ? 'DQS' : '꺼짐'}`);
|
||
if (stableJson(server.zone) === stableJson(desired.zone) && server.enable === desired.enable) {
|
||
log(`[유지] 스팸 차단 목록 ${entry.label} — ${target}`);
|
||
} else {
|
||
update[server.id] = desired;
|
||
changes.push(`[변경] 스팸 차단 목록 ${entry.label} — ${target} · 전 ${server.enable ? '켜짐' : '꺼짐'} · ${describeSpamhausZone(entry, server.zone, key)}`);
|
||
}
|
||
if (key && !useDqs) {
|
||
problems.push(
|
||
`Spamhaus 키(${SPAMHAUS_DQS_KEY_ENV})로 ${entry.label} 의 시험 항목(${entry.test.name} → ${entry.test.expect})을 받지 못해 그 항목을 꺼 둡니다 — ` +
|
||
`${check ? check.detail : 'docker 를 쓸 수 없어 확인하지 못함'}. 키(26자) · 계정에서 그 서비스(IP · 도메인)가 켜져 있는지 Spamhaus 계정 화면에서 본 뒤 다시 실행하세요.`,
|
||
);
|
||
}
|
||
}
|
||
if (Object.keys(update).length > 0) {
|
||
await setObjects(client, 'SpamDnsblServer', { update });
|
||
for (const line of changes) log(line);
|
||
}
|
||
if (!key) {
|
||
const message =
|
||
`${SPAMHAUS_DQS_KEY_ENV} 가 없어 Spamhaus 두 항목(${SPAMHAUS_DNSBL.map((entry) => entry.name).join(' · ')})을 꺼 둔다 — 공개 미러는 소규모 · 비상업 용도다. ` +
|
||
'Spamhaus 의 조회 키(무료 계정은 Fair Use — 소규모 · 비상업, 대상인지는 Spamhaus 가 정한다 · 아니면 유료)를 받으면 env 파일에 넣고 다시 실행한다 ' +
|
||
'(나머지 차단 목록은 그대로 — mail/README.md "DNS 리졸버와 스팸 차단 목록").';
|
||
if (cfg.isProduction) warn(message);
|
||
else log(`[참고] ${message} 개발도 운영처럼 키가 없으면 끈다.`);
|
||
}
|
||
return states.join(' · ');
|
||
}
|
||
|
||
/**
|
||
* 엔진이 기동할 때 리졸버로 DNSSEC 을 믿었는가 — 이번 기동의 로그에 DnsResolver 경고(DNSSEC_UNAVAILABLE_LOG)가 있으면 DANE 이 꺼진 채다.
|
||
* 리로드도 같은 확인을 하지만 그 경고는 로그에 남지 않고(ReloadSettings 응답에도 없다 — 소스 jmap/src/registry/mapping/action.rs) 기동 때만 남는다.
|
||
* 그래서 이번 실행에서 엔진 DNS 를 바꿨다면 지금 로그는 바꾸기 전 설정의 것이다 — 다음 기동부터 맞다.
|
||
* 경고가 없다고 말하려면 그 기동의 로그가 남아 있어야 한다 — 기동 첫 줄(ENGINE_STARTUP_LOG)이 범위에 없으면(로그 회전 등) "확인하지 못함"이다.
|
||
*/
|
||
function checkEngineDnssec(cfg, engineDnsChanged) {
|
||
if (!cfg.docker) return;
|
||
const startedAt = containerDetails(cfg.container)?.startedAt;
|
||
const started = Date.parse(startedAt?.replace(/(\.\d{3})\d+/, '$1') ?? '');
|
||
if (!Number.isFinite(started)) {
|
||
warn(`${cfg.container} 의 기동 시각을 몰라 엔진의 DNSSEC 확인(기동 로그)을 보지 못했습니다.`);
|
||
return;
|
||
}
|
||
const result = runDocker([
|
||
'logs',
|
||
'--since',
|
||
new Date(started - 1000).toISOString(),
|
||
'--until',
|
||
new Date(started + STARTUP_LOG_WINDOW_MS).toISOString(),
|
||
cfg.container,
|
||
]);
|
||
if (result.status !== 0) {
|
||
warn(`${cfg.container} 의 기동 로그를 읽지 못해 엔진의 DNSSEC 확인을 보지 못했습니다 (종료 코드 ${result.status}).`);
|
||
return;
|
||
}
|
||
const when = `이번 기동 ${startedAt.replace(/\.\d+/, '')}`;
|
||
const restart = '다음 mail 기동(docker compose … restart mail) 뒤 setup 을 다시 돌려 본다';
|
||
const output = `${result.stdout}${result.stderr}`;
|
||
if (output.includes(DNSSEC_UNAVAILABLE_LOG)) {
|
||
if (engineDnsChanged) {
|
||
log(`[참고] 엔진 기동 로그(${when})에 DNSSEC 경고가 있다 — 바꾸기 전 DNS 로 뜬 기동이다. 리로드로 새 리졸버를 이미 쓴다. 기동 때의 확인은 ${restart}`);
|
||
} else {
|
||
warn(
|
||
`엔진이 ${when} 때 리졸버로 DNSSEC 을 검증하지 못했다고 남겼습니다("${DNSSEC_UNAVAILABLE_LOG}" — DANE 꺼짐). 그때 리졸버가 아직 답하지 않았을 수 있다 ` +
|
||
`(재부팅처럼 mail 이 먼저 뜬 경우). 이번 실행의 리로드가 같은 확인을 다시 했지만 그 결과는 로그에 남지 않는다 — ${restart}. ` +
|
||
`계속되면 리졸버 로그(docker logs ${cfg.resolver.container}) · 바깥 53(TCP)을 본다 (mail/README.md "DNS 리졸버와 스팸 차단 목록").`,
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
// 그 기동의 로그가 없으면 경고가 없다고 말할 수 없다 — 회전(json-file 의 max-size · max-file 등)으로 지워졌거나 로그 수준을 warn 위로 올렸다
|
||
if (!output.includes(ENGINE_STARTUP_LOG)) {
|
||
warn(
|
||
`${cfg.container} 의 ${when} 로그가 남아 있지 않아(기동 첫 줄 "${ENGINE_STARTUP_LOG}" 이 없다 — 로그 회전 등) 엔진이 그때 DNSSEC 을 검증했는지 ` +
|
||
`확인하지 못했습니다. 메일 · 리졸버는 위 사전 확인대로다 — 기동 때의 확인은 ${restart}(몇 초 끊긴다).`,
|
||
);
|
||
return;
|
||
}
|
||
log(
|
||
`[확인] 엔진 기동 로그(${when})에 DNSSEC 경고가 없다 — 리졸버로 DNSSEC 을 검증한다(DANE 켜짐)` +
|
||
(engineDnsChanged ? ` · 엔진 DNS 를 이번에 바꿨다 — 새 설정의 기동 확인은 ${restart}` : ''),
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 엔진이 설정을 다시 만들며 이름을 풀지 못해 리로드 전체를 거절했을 때의 설명 문구 — 기본 스팸 필터의 Pyzor(public.pyzor.org)는 설정을 만들 때
|
||
* 메일 컨테이너의 시스템 DNS(= 메일 전용 리졸버)로 주소를 푼다. 실패하면 기동은 그 항목만 빼고 뜨지만 리로드는 통째로 거절된다
|
||
* (0.16.27 소스 crates/common/src/config/mailstore/spamfilter.rs 의 PyzorConfig::parse · crates/common/src/cache/reload.rs — 2026-10-30 개발에서 리졸버의 바깥 길을 끊어 확인)
|
||
*/
|
||
const RELOAD_LOOKUP_FAILED = /failed to lookup address information|Invalid address/;
|
||
|
||
/** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */
|
||
async function reloadSettings(client) {
|
||
try {
|
||
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } });
|
||
} catch (error) {
|
||
if (error instanceof SetupError && RELOAD_LOOKUP_FAILED.test(error.detail ?? '')) {
|
||
error.hint =
|
||
'엔진이 설정을 다시 만들며 이름을 풀지 못해 리로드를 통째로 거절했다 — 스팸 필터의 Pyzor 주소(public.pyzor.org)를 메일 컨테이너의 DNS ' +
|
||
`(= 메일 전용 리졸버)로 푼다. 리졸버가 바깥에 닿는지 본다: docker exec ${client.cfg.resolver.container} drill public.pyzor.org @127.0.0.1 A. ` +
|
||
'저장한 설정은 남아 있다 — 리졸버를 고친 뒤 다시 실행하거나, 급하면 docker compose … restart mail(기동은 그 항목만 빼고 뜬다) ' +
|
||
'(mail/README.md "리졸버 장애 때")';
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/** 리로드·캐시 무효화가 퍼질 때까지 확인을 몇 번 다시 해 본다 */
|
||
const SETTLE_ATTEMPTS = 5;
|
||
|
||
/**
|
||
* 공개 443 정책이 실제로 걸렸는지 — https 는 403, 내부 http 는 열려 있어야 한다.
|
||
* ⚠️ 루프백(127.0.0.1)에서 온 요청은 정책을 건너뛰므로(Stalwart 0.16 에서 확인) 컨테이너의 네트워크 주소로 부른다.
|
||
* 호스트·다른 컨테이너·자기 네트워크 주소에서 온 요청은 AllowedIp 에 있어도 403 이다.
|
||
*/
|
||
async function checkHttpEndpointPolicy(cfg) {
|
||
const networks = cfg.docker ? containerNetworks(cfg.container) : null;
|
||
const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null;
|
||
if (!mailIp) {
|
||
const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 공개 443 차단을 실제로 확인하지 못했습니다.';
|
||
if (cfg.isProduction) problems.push(message);
|
||
else log(`[건너뜀] ${message}`);
|
||
return;
|
||
}
|
||
let httpsCode = null;
|
||
let httpCode = null;
|
||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||
httpsCode = statusInsideMail(cfg, `https://${mailIp}:443/jmap/session`);
|
||
httpCode = statusInsideMail(cfg, 'http://127.0.0.1:8080/healthz/live');
|
||
if (httpsCode === 403 && httpCode === 200) break;
|
||
await sleep(POLL_INTERVAL_MS / 2);
|
||
}
|
||
if (httpsCode === 403 && httpCode === 200) {
|
||
log(`[확인] 공개 443 의 /jmap 은 403 (https://${mailIp}:443), 내부 8080 은 열려 있습니다.`);
|
||
return;
|
||
}
|
||
problems.push(
|
||
`공개 443 차단 확인 실패 — https /jmap/session=${httpsCode ?? '응답 없음'}(403 이어야 함), ` +
|
||
`http 8080 /healthz/live=${httpCode ?? '응답 없음'}(200 이어야 함).`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 메일 컨테이너 안의 curl 로 RCPT 까지만 보낸다 — { mailFrom: MAIL FROM 응답 줄, rcpt: 받는 주소(소문자)마다 응답 줄,
|
||
* ehlo: { sent: EHLO 를 보냈는가, size: EHLO 응답의 SIZE 값 — 숫자 글 · 숫자가 없으면 '' · SIZE 줄이 없으면 null }, status: curl 종료 코드 }. curl 을 부르지 못하면 null.
|
||
* MAIL FROM 이 거절되거나 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
|
||
* 받는 주소를 하나도 주지 않으면 curl 은 EHLO 뒤 HELP 만 하고 끝낸다(MAIL FROM · RCPT · DATA 없음 — curl 8.14.1, 2026-11-03 개발 엔진) — EHLO 만 볼 때 쓴다.
|
||
* sender 가 '<>' 면 빈 봉투(MAIL FROM:<>)다 (RCPT_CHECK_SENDER).
|
||
*/
|
||
function smtpRepliesInsideMail(cfg, mailIp, sender, recipients) {
|
||
const result = spawnSync(
|
||
'docker',
|
||
[
|
||
'exec',
|
||
'-i',
|
||
cfg.container,
|
||
'curl',
|
||
'-sS',
|
||
'-v',
|
||
'--max-time',
|
||
'20',
|
||
'--url',
|
||
`smtp://${mailIp}:25/${RCPT_CHECK_EHLO}`,
|
||
'--mail-from',
|
||
sender,
|
||
'--mail-rcpt-allowfails',
|
||
...recipients.flatMap((recipient) => ['--mail-rcpt', recipient]),
|
||
'-T',
|
||
'-',
|
||
],
|
||
{ input: '', encoding: 'utf8' },
|
||
);
|
||
if (result.error) return null;
|
||
const replies = { mailFrom: null, rcpt: new Map(), ehlo: { sent: false, size: null }, status: result.status };
|
||
let asked = null;
|
||
let inEhlo = false;
|
||
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
|
||
if (/^> EHLO /.test(line)) {
|
||
replies.ehlo.sent = true;
|
||
inEhlo = true;
|
||
continue;
|
||
}
|
||
if (inEhlo) {
|
||
// EHLO 응답은 여러 줄이다 — "250-…" 이 이어지고 "250 …" 이 끝이다 (SIZE 는 RFC 1870 — 숫자가 없을 수 있다)
|
||
const extension = /^< 250([ -])(.*)$/.exec(line);
|
||
if (extension) {
|
||
const size = /^SIZE(?:\s+(\d+))?\s*$/i.exec(extension[2].trim());
|
||
if (size) replies.ehlo.size = size[1] ?? '';
|
||
if (extension[1] === ' ') inEhlo = false;
|
||
continue;
|
||
}
|
||
if (/^[<>] /.test(line)) inEhlo = false;
|
||
}
|
||
if (/^> MAIL FROM:/.test(line)) {
|
||
asked = { mailFrom: true };
|
||
continue;
|
||
}
|
||
const sent = /^> RCPT TO:<([^>]*)>/.exec(line);
|
||
if (sent) {
|
||
asked = { rcpt: sent[1].toLowerCase() };
|
||
continue;
|
||
}
|
||
const reply = /^< (\d{3}[ -].*)$/.exec(line);
|
||
if (reply && asked) {
|
||
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 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 빈 봉투(RCPT_CHECK_SENDER) — 사내 도메인 밖이라
|
||
* 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 생기지 않는다.
|
||
* 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다).
|
||
*/
|
||
async function checkSystemRecipientsRejected(cfg, addresses) {
|
||
const mailIp = mailContainerIp(cfg);
|
||
if (!mailIp) {
|
||
const message = 'docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 시스템 계정의 RCPT 거절을 실제로 확인하지 못했습니다.';
|
||
if (cfg.isProduction) problems.push(message);
|
||
else log(`[건너뜀] ${message}`);
|
||
return;
|
||
}
|
||
const last = addresses[addresses.length - 1];
|
||
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 = 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?.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?.rcpt.get(probe) ?? '응답 없음'}`).join(' · ')}${mailFromRejected} (550 이어야 함). ` +
|
||
'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (mail/README.md "보안").',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 받는 메일 크기 한도가 리로드 뒤 엔진에 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션 — 바깥 메일이 들어오는 길)로
|
||
* EHLO 를 보내 SIZE 를 읽는다(smtpRepliesInsideMail 에 받는 주소 없이 — EHLO · HELP 뿐, 메일은 보내지 않는다). 루프백이 아니라 네트워크 주소인 까닭은
|
||
* 시스템 계정 RCPT 확인과 같다. ensureMessageSizeLimits 의 [변경] · [유지] 는 저장한 값이라, 리로드가 걸리지 않았으면 엔진은 옛 한도(기본 100MiB)로 받는다 —
|
||
* 운영 차례(mail/README.md "큰 메일")는 이 [확인] 줄을 보고 메일 컨테이너 메모리를 1g 로 되돌린다.
|
||
* - SIZE 가 1 이상 SMTP_MESSAGE_SIZE_LIMIT 이하 → [확인]
|
||
* - 더 큰데 setup 이 단순한 꼴로 저장 · 유지했다(state 'saved' · 'kept') → problems(종료 1). 운영자의 식 · 0 · 읽지 못함이면 [주의]
|
||
* - SIZE 줄이 없거나 숫자가 아니거나 0, 확인 자체를 못 함 → [주의]
|
||
* ⚠️ "[확인] 받는 메일 크기 한도 — EHLO SIZE" 는 운영 차례 문서가 이 글자로 확인한다 — 바꾸지 않는다
|
||
*/
|
||
async function checkMessageSizeLimit(cfg, state) {
|
||
const label = '받는 메일 크기 한도';
|
||
const limit = `${formatCount(SMTP_MESSAGE_SIZE_LIMIT)}바이트 (${SMTP_MESSAGE_SIZE_LIMIT / MEGABYTE}MiB)`;
|
||
const ours = state === 'saved' || state === 'kept';
|
||
const mailIp = mailContainerIp(cfg);
|
||
if (!mailIp) {
|
||
warn(`docker 를 쓸 수 없거나 mail 컨테이너 주소를 몰라 ${label}가 엔진에 걸렸는지(EHLO 의 SIZE) 확인하지 못했습니다.`);
|
||
return;
|
||
}
|
||
const where = `메일 컨테이너 ${mailIp}:25`;
|
||
const sizeOf = (replies) => (/^\d+$/.test(replies?.ehlo.size ?? '') ? Number(replies.ehlo.size) : null);
|
||
const within = (size) => size !== null && size >= 1 && size <= SMTP_MESSAGE_SIZE_LIMIT;
|
||
let replies = null;
|
||
// setup 이 맞춘 값이면 리로드가 퍼질 때까지 몇 번 더 본다 — 운영자의 식 · 0 은 기다려도 바뀌지 않는다
|
||
for (let attempt = 0; attempt < (ours ? SETTLE_ATTEMPTS : 1); attempt += 1) {
|
||
if (attempt > 0) await sleep(POLL_INTERVAL_MS / 2);
|
||
replies = smtpRepliesInsideMail(cfg, mailIp, RCPT_CHECK_SENDER, []);
|
||
if (within(sizeOf(replies))) break;
|
||
}
|
||
const size = sizeOf(replies);
|
||
if (within(size)) {
|
||
const note = state === 'operator' ? ' · 운영자의 식이라 다른 세션(465 · 인증한 세션 등)은 다를 수 있다 — 위 [주의]' : '';
|
||
log(`[확인] ${label} — EHLO SIZE ${formatCount(size)} (${where} · 리로드 뒤 엔진이 알리는 값${note})`);
|
||
return;
|
||
}
|
||
if (size !== null && size > SMTP_MESSAGE_SIZE_LIMIT) {
|
||
const head = `${label}가 엔진에 걸리지 않았습니다 — 리로드 뒤 EHLO SIZE ${formatCount(size)} (${where}) 가 ${limit} 보다 크다`;
|
||
if (ours) {
|
||
problems.push(
|
||
`${head} — 엔진에 저장된 값은 ${limit} 이하다(setup 이 이번에 저장했거나 그대로 두었다). 리로드가 실패했거나 반영되지 않았으니 위 [실패] · [주의] 를 보고 ` +
|
||
'setup 을 다시 돌린다 — 걸리기 전에는 엔진이 옛 한도로 받아 큰 메일 한 통에 메모리 1g 를 넘을 수 있다 (mail/README.md "큰 메일").',
|
||
);
|
||
} else {
|
||
const untouched = {
|
||
operator: '운영자의 식이라 setup 이 바꾸지 않았다 — 위 [주의]',
|
||
zero: '0 이라 setup 이 바꾸지 않았다 — 위 [주의]',
|
||
unread: 'setup 이 값을 읽지 못해 맞추지 않았다 — 끝의 [실패] 항목',
|
||
};
|
||
warn(`${head} — ${untouched[state] ?? 'setup 이 맞추지 않은 값이다'}. 관리 화면에서 ${limit} 이하로 두고 다시 실행하세요 (mail/README.md "엔진 한도").`);
|
||
}
|
||
return;
|
||
}
|
||
let why;
|
||
if (!replies) why = 'docker exec 로 curl 을 부르지 못했다';
|
||
else if (!replies.ehlo.sent) why = `EHLO 를 보내지 못했다 (curl 종료 코드 ${replies.status})`;
|
||
else if (replies.ehlo.size === null) why = 'EHLO 응답에 SIZE 줄이 없다';
|
||
else why = `EHLO 의 SIZE 가 한도를 알리지 않는다 (SIZE ${replies.ehlo.size || '숫자 없음'})`;
|
||
warn(`${label}가 엔진에 걸렸는지 확인하지 못했습니다 — ${why} (${where}). 관리 API 로 x:MtaStageData 의 maxMessageSize 를 보고, 리로드한 뒤 다시 실행하세요 (mail/README.md "엔진 한도").`);
|
||
}
|
||
|
||
/**
|
||
* 운영 — 바깥에서 쓰는 주소(MAIL_PUBLIC_URL) 그대로 /jmap/session 이 403 인지 (이 스크립트를 돌리는 호스트에서).
|
||
* 컨테이너 안 확인과 달리 실제 공개 경로를 본다 — 앞단 프록시가 TLS 를 끝내고 8080(http 리스너)으로 넘기면
|
||
* https 리스너 정책이 걸리지 않아 여기서 200 이 나온다 (mail/README.md 대안 3).
|
||
* 연결 자체가 안 되면(DNS 전파 전, 호스트에서 자기 공인 IP 로 되돌아오는 경로가 막힌 환경 등) 경고와 함께 바깥에서 확인할 명령을 보여 준다.
|
||
*/
|
||
async function checkPublicEndpoint(cfg) {
|
||
const url = `${cfg.publicUrl}/jmap/session`;
|
||
const manual = `바깥(다른 네트워크)에서 확인하세요: curl -s -o /dev/null -w '%{http_code}\\n' ${url} # 403 이어야 한다`;
|
||
const result = await httpsStatus(url);
|
||
if (result.error) {
|
||
warn(`공개 주소 ${url} 에 연결하지 못해 바깥 경로의 403 은 확인하지 못했습니다 (${result.error}). ${manual}`);
|
||
return;
|
||
}
|
||
if (result.status === 403) {
|
||
const certificate = result.authorized ? '인증서 유효' : '인증서는 아직 믿을 수 없음 — ACME 발급 전이면 정상, 계속되면 컨테이너 로그 확인';
|
||
log(`[확인] 공개 주소 ${url} → 403 (${certificate})`);
|
||
return;
|
||
}
|
||
problems.push(
|
||
`공개 주소 ${url} 가 ${result.status} 를 돌려줍니다 (403 이어야 함) — 앞단 프록시가 TLS 를 끝내고 8080 으로 넘기고 있지 않은지 확인하세요 (mail/README.md "리버스 프록시와 같은 호스트에 둘 때").`,
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 남은 위험을 매번 보여 준다 — 게이트웨이는 관리자 계정도 대리 접근할 수 있다.
|
||
* Stalwart 0.16 은 대리 접근 대상을 제한하지 못한다: 역할 권한은 접속하는 쪽만 정하고, 테넌트에 넣으면
|
||
* 게이트웨이가 impersonate 권한 자체를 잃는다(2026-10-27 확인 — mail/README.md "보안"). 그래서 막는 일은 백엔드가 한다
|
||
* (관리자·MAIL_BLOCKED_ACCOUNTS 는 웹메일 로그인·대리 접근 대상에서 뺀다).
|
||
* 1. <관리자>%<게이트웨이> 로 데이터 없는 관리 호출 — 성공하면 [주의]: 게이트웨이 비밀번호 = 관리자급 비밀
|
||
* 2. 관리자 역할 계정이 백엔드 차단 목록(MAIL_ADMIN_LOCAL_PART 관리자 + MAIL_BLOCKED_ACCOUNTS)에서 빠졌으면 [주의]
|
||
*/
|
||
async function checkGatewayAdminReach(admin, cfg) {
|
||
const login = `${cfg.adminAddress}%${cfg.gatewayAddress}`;
|
||
const session = await openClient(cfg, login, cfg.gatewayPassword);
|
||
let reachable = false;
|
||
if (!session?.accountId) {
|
||
log(`[확인] 게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근할 수 없습니다 (${login} 로그인 거부).`);
|
||
} else if (await hasManagementAccess(session)) {
|
||
reachable = true;
|
||
warn(
|
||
`게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근하면 관리 API 가 열립니다 (${login} → x:Account/get 성공). ` +
|
||
'게이트웨이 비밀번호는 관리자급 비밀이다 — 백엔드 env 에만 두고, 다른 곳에 재사용하지 말고, 정기 교체하고, ' +
|
||
'게이트웨이 허용 IP(신뢰 대역)를 넓히지 않는다 (mail/README.md "보안"). 백엔드는 관리자 계정의 웹메일 로그인·대리 접근을 막는다.',
|
||
);
|
||
} else {
|
||
log(`[확인] 게이트웨이로 관리자 ${cfg.adminAddress} 를 대리 접근해도 관리 API 는 닫혀 있습니다.`);
|
||
}
|
||
|
||
const blocked = new Set([cfg.adminAddress, ...cfg.blockedAccounts]);
|
||
const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles']);
|
||
const unblocked = accounts
|
||
.filter(isAdminAccount)
|
||
.map((account) => account.emailAddress?.toLowerCase())
|
||
.filter((address) => address && !blocked.has(address));
|
||
if (unblocked.length > 0) {
|
||
warn(
|
||
`관리자 역할 계정 ${unblocked.join(', ')} 가 백엔드 차단 목록에 없습니다 — 웹메일 로그인·대리 접근을 1차로 막지 못한다. ` +
|
||
'MAIL_BLOCKED_ACCOUNTS 에 넣고 backend 를 다시 띄우거나, 쓰지 않는 관리자라면 지우세요.',
|
||
);
|
||
}
|
||
return reachable;
|
||
}
|
||
|
||
/**
|
||
* 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지.
|
||
* 웹메일에서 2단계 인증을 켠 테스트 직원은 비밀번호만으로 로그인하지 못한다(402 — 12단계). 그때는 게이트웨이 대리 접근으로 본다 —
|
||
* 대리 접근 세션의 권한은 그 직원의 역할 그대로다(본인 Basic 과 같은 231개 — 2026-10-31 개발 엔진)
|
||
*/
|
||
async function checkPrincipalHidden(cfg, address) {
|
||
const viaGateway = (await loginStatus(cfg, address, cfg.devUserPassword)) === HTTP_MFA_REQUIRED;
|
||
const client = viaGateway
|
||
? await openClient(cfg, `${address}%${cfg.gatewayAddress}`, cfg.gatewayPassword)
|
||
: await openClient(cfg, address, cfg.devUserPassword);
|
||
const accountId = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
|
||
if (!client || !accountId) {
|
||
problems.push(`${address} 로 로그인하지 못해 계정 목록 비공개를 확인하지 못했습니다.`);
|
||
return;
|
||
}
|
||
let name = '';
|
||
let result = {};
|
||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||
const res = await request(cfg, '/jmap/', {
|
||
method: 'POST',
|
||
headers: { authorization: client.auth, 'content-type': 'application/json' },
|
||
body: JSON.stringify({
|
||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:principals'],
|
||
methodCalls: [['Principal/get', { accountId, ids: null, properties: ['email'] }, 'p']],
|
||
}),
|
||
});
|
||
[[name, result]] = (await res.json()).methodResponses;
|
||
if (name === 'error' && result.type === 'forbidden') {
|
||
const how = viaGateway ? ' — 2단계 인증이 켜져 있어 게이트웨이 대리 접근으로 봄' : '';
|
||
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden${how}).`);
|
||
return;
|
||
}
|
||
await sleep(POLL_INTERVAL_MS / 2);
|
||
}
|
||
problems.push(`직원(${address})이 아직 계정 목록을 볼 수 있습니다 (Principal/get → ${name === 'error' ? result.type : `${result.list?.length}개`}).`);
|
||
}
|
||
|
||
/** 운영 — 복구 관리자가 mail 컨테이너에 남아 있지 않은지 (값은 출력하지 않는다). main 이 docker 를 이미 확인했다 */
|
||
function checkRecoveryDisabled(cfg) {
|
||
const isSet = containerEnvIsSet(cfg.container, 'STALWART_RECOVERY_ADMIN');
|
||
if (isSet === null) {
|
||
problems.push(`${cfg.container} 를 찾지 못해 복구 관리자가 꺼졌는지 확인하지 못했습니다 (PROJECT_NAME 확인).`);
|
||
} else if (isSet) {
|
||
problems.push(
|
||
`${cfg.container} 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다 — 정상 모드에서도 관리자로 로그인됩니다. ` +
|
||
`셸의 ${RECOVERY_ENV_KEY} 를 지우고 docker compose --env-file .env.production up -d mail 로 다시 만드세요.`,
|
||
);
|
||
} else {
|
||
log('[확인] 복구 관리자 꺼짐 (mail 컨테이너에 STALWART_RECOVERY_ADMIN 없음)');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 개발 — 사내 도메인 밖 메일을 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) {
|
||
await setObjects(client, 'MtaRoute', {
|
||
create: {
|
||
relay: {
|
||
'@type': 'Relay',
|
||
name: DEV_RELAY.name,
|
||
description: DEV_RELAY.description,
|
||
address: DEV_RELAY.address,
|
||
port: DEV_RELAY.port,
|
||
authSecret: { '@type': 'None' },
|
||
},
|
||
},
|
||
});
|
||
log(`[생성] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
|
||
} else if (route.address !== DEV_RELAY.address || route.port !== DEV_RELAY.port) {
|
||
await setObjects(client, 'MtaRoute', { update: { [route.id]: { address: DEV_RELAY.address, port: DEV_RELAY.port } } });
|
||
log(`[변경] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
|
||
} else {
|
||
log(`[유지] 발송 경로 ${DEV_RELAY.name} → ${DEV_RELAY.address}:${DEV_RELAY.port}`);
|
||
}
|
||
|
||
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}`);
|
||
} else {
|
||
log(`[유지] 외부 발송 전략 — 사내 도메인은 로컬, 나머지는 ${DEV_RELAY.name}`);
|
||
}
|
||
// 저장만으로는 반영되지 않는다 — main 이 reloadSettings 뒤에 verifyDevRelay 로 확인한다
|
||
}
|
||
|
||
/**
|
||
* 개발 — 리로드 뒤 외부 발송 전략이 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];
|
||
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 에만 쌓입니다.');
|
||
}
|
||
|
||
/** 운영에 개발용 Mailpit 경로가 섞여 들어오지 않았는지 (개발 볼륨을 옮겨 온 경우 등) */
|
||
async function checkNoDevRelay(client) {
|
||
const strategy = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
|
||
if (stableJson(strategy?.route).includes(`'${DEV_RELAY.name}'`)) {
|
||
problems.push('운영인데 외부 발송 전략이 개발용 Mailpit 경로를 가리킵니다. 관리 화면에서 발송 전략을 확인하세요.');
|
||
}
|
||
}
|
||
|
||
/** 개발 — 게이트웨이로 테스트 직원 메일함에 대리 접근이 되는지 (백엔드가 쓰는 방식 그대로) */
|
||
async function checkImpersonation(cfg, target) {
|
||
const login = `${target}%${cfg.gatewayAddress}`;
|
||
const client = await openClient(cfg, login, cfg.gatewayPassword);
|
||
const mailAccount = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
|
||
if (client && client.session.username?.toLowerCase() === target && mailAccount) {
|
||
log(`[확인] 대리 접근 ${login} → ${target} 메일 계정 세션`);
|
||
return;
|
||
}
|
||
problems.push(`대리 접근 확인 실패 — ${login}. 게이트웨이 역할(impersonate)과 허용 IP(MAIL_TRUSTED_NETWORKS)를 확인하세요.`);
|
||
}
|
||
|
||
async function printDnsZone(client, domain) {
|
||
const { list } = await call(client, 'x:Domain/get', { ids: [domain.id], properties: ['dnsZoneFile'] });
|
||
log(`\n----- 권장 DNS 레코드 (${domain.name}) — 그대로 붙이지 말고 mail/README.md "전환 절차" 순서를 따를 것 -----`);
|
||
log(list[0]?.dnsZoneFile?.trim() || '(없음)');
|
||
log('-----');
|
||
}
|
||
|
||
// ============================================================
|
||
// 엔진 웹훅 — 메일 프로그램 로그인 기록 (12단계 — WEBHOOK_SECRET_ENV 머리말 · mail/README.md "엔진 웹훅")
|
||
// ============================================================
|
||
|
||
function webhookKeyFingerprint(secret, salt) {
|
||
return scryptSync(secret, salt, WEBHOOK_KEY_FINGERPRINT_BYTES).toString('hex');
|
||
}
|
||
|
||
/** 새 표시 머리글 값 — 쓸 때마다(만들기 · 고치기) 새 소금으로 키 지문을 적는다(엔진은 서명 키를 **** 로만 돌려준다 — WEBHOOK_KEY_SALT_BYTES) */
|
||
function newWebhookMarker(secret) {
|
||
const salt = randomBytes(WEBHOOK_KEY_SALT_BYTES).toString('hex');
|
||
return `${MANAGED_MARK} key=${salt}.${webhookKeyFingerprint(secret, salt)}`;
|
||
}
|
||
|
||
/** 엔진의 표시 머리글이 지금 키의 지문인가 — 머리글의 소금으로 다시 계산해 견준다. 옛 모양 · 다른 키면 false */
|
||
function webhookMarkerMatches(value, secret) {
|
||
const match = WEBHOOK_MARKER_PATTERN.exec(String(value ?? ''));
|
||
return Boolean(match) && webhookKeyFingerprint(secret, match[1]) === match[2];
|
||
}
|
||
|
||
/** setup 이 둔 웹훅인가 — 표시 머리글(키 지문과 상관없이 [setup.mjs] 로 시작) */
|
||
function isOwnWebhook(hook) {
|
||
return String(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER] ?? '').startsWith(MANAGED_MARK);
|
||
}
|
||
|
||
/** 웹훅 주소의 경로 — 주소가 아니면 null */
|
||
function webhookUrlPath(url) {
|
||
try {
|
||
return new URL(url).pathname;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** setup 이 두는 웹훅 — 만들기 · 고치기 모두 이 값 전체를 보낸다 (events · httpHeaders 는 통째로 바뀐다 · 표시 머리글은 새 소금) */
|
||
function desiredWebhook(cfg) {
|
||
return {
|
||
enable: true,
|
||
url: cfg.webhookUrl,
|
||
signatureKey: { '@type': 'Value', secret: cfg.webhookSecret },
|
||
httpAuth: WEBHOOK_HTTP_AUTH,
|
||
httpHeaders: { [WEBHOOK_MARKER_HEADER]: newWebhookMarker(cfg.webhookSecret) },
|
||
events: toTrueSet(WEBHOOK_EVENTS),
|
||
...WEBHOOK_SETTINGS,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 엔진의 웹훅이 setup 의 값과 다른 칸 — 같으면 빈 배열. 서명 키는 형식(Value)과 표시 머리글의 키 지문으로 본다 —
|
||
* ⚠️ 관리 화면에서 서명 키만 바꾸면(머리글은 그대로) 알아채지 못한다. 엔진은 키를 **** 로만 돌려주기 때문이다
|
||
*/
|
||
function webhookDrift(hook, cfg) {
|
||
const drift = [];
|
||
if (hook.enable !== true) drift.push('꺼짐');
|
||
if (hook.url !== cfg.webhookUrl) drift.push('주소');
|
||
const keyChanged =
|
||
hook.signatureKey?.['@type'] !== 'Value' || !webhookMarkerMatches(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER], cfg.webhookSecret);
|
||
if (keyChanged) drift.push('서명 키');
|
||
if (!keyChanged && stableJson(Object.keys(hook.httpHeaders ?? {})) !== stableJson([WEBHOOK_MARKER_HEADER])) drift.push('HTTP 머리글');
|
||
if (stableJson(hook.httpAuth ?? null) !== stableJson(WEBHOOK_HTTP_AUTH)) drift.push('HTTP 인증');
|
||
if (!sameTrueSet(hook.events, WEBHOOK_EVENTS)) drift.push('이벤트');
|
||
for (const [key, value] of Object.entries(WEBHOOK_SETTINGS)) {
|
||
if (hook[key] !== value) drift.push(key);
|
||
}
|
||
return drift;
|
||
}
|
||
|
||
/**
|
||
* 엔진 웹훅을 맞춘다 — setup 표시로 찾아 없으면 만들고, 다르면 고치고, 같으면 둔다. 저장만 한다(main 의 리로드 뒤에 반영 — checkEngineWebhook 이 본다).
|
||
* 키가 없으면(개발 — 운영은 loadConfig 가 이미 멈췄다) 만들지 않고 있던 것은 끈다. 표시 없이 같은 받는 곳으로 가는 웹훅은 사람이 만든 것으로 보고 [주의] 만 낸다.
|
||
* 돌려주는 값: { state: 'ready' | 'disabled' | 'absent', id, keyChanged } — keyChanged 는 이번 실행에서 엔진에 서명 키를 넣었는가(만들기 · 키가 바뀜)
|
||
*/
|
||
async function ensureEngineWebhook(client, cfg) {
|
||
const hooks = await listAll(client, 'WebHook', [
|
||
'enable',
|
||
'url',
|
||
'signatureKey',
|
||
'httpAuth',
|
||
'httpHeaders',
|
||
'events',
|
||
'eventsPolicy',
|
||
'level',
|
||
'throttle',
|
||
'timeout',
|
||
'discardAfter',
|
||
'lossy',
|
||
'allowInvalidCerts',
|
||
]);
|
||
const own = hooks.filter(isOwnWebhook);
|
||
const lookalikes = hooks.filter((hook) => !isOwnWebhook(hook) && webhookUrlPath(hook.url) === WEBHOOK_PATH);
|
||
if (lookalikes.length > 0) {
|
||
warn(
|
||
`백엔드의 웹훅 받는 곳(${WEBHOOK_PATH})으로 가는 웹훅이 setup 표시 없이 ${lookalikes.length}개 있습니다(id ${lookalikes.map((hook) => hook.id).join(', ')}) — ` +
|
||
'사람이 만든 것으로 보고 건드리지 않습니다. 같은 이벤트가 두 번 가거나 다른 키로 서명돼 백엔드가 거절한다 — 관리 화면에서 지우세요 (mail/README.md "엔진 웹훅").',
|
||
);
|
||
}
|
||
|
||
if (!cfg.webhookSecret) {
|
||
warn(
|
||
`${WEBHOOK_SECRET_ENV} 가 비어 있어 엔진 웹훅을 두지 않습니다 — 메일 프로그램(IMAP · SMTP) 로그인 기록과 앱 비밀번호의 마지막 사용이 남지 않는다. ` +
|
||
'무작위 값(openssl rand -hex 24)을 넣고 setup 을 다시 실행한 뒤 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅").',
|
||
);
|
||
const enabled = own.filter((hook) => hook.enable !== false);
|
||
if (enabled.length > 0) {
|
||
await setObjects(client, 'WebHook', { update: Object.fromEntries(enabled.map((hook) => [hook.id, { enable: false }])) });
|
||
log(`[변경] 엔진 웹훅 — 끔 (${WEBHOOK_SECRET_ENV} 비어 있음 · ${enabled.length}개)`);
|
||
}
|
||
return { state: own.length > 0 ? 'disabled' : 'absent', id: null, keyChanged: false };
|
||
}
|
||
|
||
const target = `${cfg.webhookUrl} (${WEBHOOK_EVENTS.join(' · ')} — 서명 키 ${WEBHOOK_SECRET_ENV})`;
|
||
if (own.length === 0) {
|
||
const result = await setObjects(client, 'WebHook', { create: { webhook: desiredWebhook(cfg) } });
|
||
log(`[생성] 엔진 웹훅 → ${target}`);
|
||
return { state: 'ready', id: result.created.webhook.id, keyChanged: true };
|
||
}
|
||
const [hook, ...extra] = own;
|
||
if (extra.length > 0) {
|
||
// 같은 이벤트가 여러 번 간다 — 하나만 둔다
|
||
await setObjects(client, 'WebHook', { destroy: extra.map((item) => item.id) });
|
||
log(`[삭제] 엔진 웹훅 — setup 표시가 같은 것 ${extra.length}개 (하나만 둔다)`);
|
||
}
|
||
const drift = webhookDrift(hook, cfg);
|
||
if (drift.length === 0) {
|
||
log(`[유지] 엔진 웹훅 → ${target}`);
|
||
} else {
|
||
await setObjects(client, 'WebHook', { update: { [hook.id]: desiredWebhook(cfg) } });
|
||
log(`[변경] 엔진 웹훅 — ${drift.join(', ')} → ${target}`);
|
||
}
|
||
return { state: 'ready', id: hook.id, keyChanged: drift.includes('서명 키') };
|
||
}
|
||
|
||
/**
|
||
* 메일 컨테이너 안(엔진과 같은 자리 · 같은 시스템 DNS)에서 받는 곳으로 서명한 빈 묶음(WEBHOOK_PROBE_BODY)을 보낸다 — { code, error }.
|
||
* 서명은 표준 입력으로 넘긴다(curl -H @-) — 키로 만든 값이 docker exec 의 명령줄(도커 이벤트 · ps)에 남지 않게. docker 를 쓸 수 없으면 null
|
||
*/
|
||
function postWebhookProbeInsideMail(cfg) {
|
||
if (!cfg.docker) return null;
|
||
const signature = createHmac('sha256', cfg.webhookSecret).update(WEBHOOK_PROBE_BODY).digest('base64');
|
||
const result = spawnSync(
|
||
'docker',
|
||
[
|
||
'exec',
|
||
'-i',
|
||
cfg.container,
|
||
'curl',
|
||
'-sS',
|
||
'-o',
|
||
'/dev/null',
|
||
'-w',
|
||
'%{http_code}',
|
||
'--max-time',
|
||
'5',
|
||
'-H',
|
||
'@-',
|
||
'-H',
|
||
'content-type: application/json',
|
||
'--data-binary',
|
||
WEBHOOK_PROBE_BODY,
|
||
cfg.webhookUrl,
|
||
],
|
||
{ input: `X-Signature: ${signature}\n`, encoding: 'utf8' },
|
||
);
|
||
if (result.error) return { code: null, error: result.error.message };
|
||
const code = Number(result.stdout?.trim());
|
||
return {
|
||
code: Number.isInteger(code) && code > 0 ? code : null,
|
||
error: result.status === 0 ? null : (result.stderr ?? '').trim().split(/\r?\n/).pop()?.slice(0, 200) || `종료 코드 ${result.status}`,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 리로드 뒤 — 엔진에 남은 웹훅이 setup 의 값 그대로인지 다시 읽고, 받는 곳까지 닿는지 · 백엔드의 키가 맞는지 메일 컨테이너에서 서명한 빈 묶음으로 본다.
|
||
* 2xx 면 [확인] — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다는 뜻이다. 엔진에 든 키는 읽을 수 없어 표시 머리글의 지문으로만 안다(webhookDrift).
|
||
* 401(키 · 접속 주소가 맞지 않음) · 404(받는 곳이 없는 백엔드)는 운영은 실패, 개발은 [주의](backend 를 다시 띄우기 전일 수 있다).
|
||
* 다만 이번 실행에서 엔진에 키를 넣었으면(webhook.keyChanged — 키를 바꿈 · 처음 만듦) backend 는 아직 옛 키일 수 있어 401 도 [주의] + 다음 명령이다.
|
||
* 닿지 않으면(백엔드가 아직 뜨는 중 등) 몇 번 다시 해 보고 [주의] — 다시 실행하면 된다
|
||
*/
|
||
async function checkEngineWebhook(client, cfg, webhook) {
|
||
if (webhook.state !== 'ready') return;
|
||
const [hook] = (await call(client, 'x:WebHook/get', { ids: [webhook.id] })).list ?? [];
|
||
const drift = hook ? webhookDrift(hook, cfg) : ['없어짐'];
|
||
if (drift.length > 0) {
|
||
problems.push(`엔진 웹훅이 리로드 뒤 setup 의 값과 다릅니다(${drift.join(', ')}) — 그사이 관리 화면 등에서 바꿨는지 보고 다시 실행하세요.`);
|
||
return;
|
||
}
|
||
log(`[확인] 엔진 웹훅 켜짐 — ${cfg.webhookUrl} · ${WEBHOOK_EVENTS.join(' · ')} · 수준 trace (리로드 뒤 다시 읽음)`);
|
||
|
||
let probe = null;
|
||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
||
probe = postWebhookProbeInsideMail(cfg);
|
||
if (!probe || probe.code !== null) break;
|
||
await sleep(POLL_INTERVAL_MS);
|
||
}
|
||
const compose = cfg.isProduction
|
||
? 'sudo docker compose --env-file .env.production'
|
||
: 'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development';
|
||
// 401 — 새 키로 다시 만든다(compose 의 environment). 404 — 새 코드가 떠야 한다: 운영은 이미지를 다시 만들고(frontend 의 nginx 스니펫도 함께), 개발은 소스가 붙어 있어 다시 띄우기만
|
||
const restartBackend = `${compose} up -d backend`;
|
||
const reloadBackendCode = cfg.isProduction ? `${compose} up -d --build backend frontend` : `${compose} restart backend`;
|
||
const fail = (message) => (cfg.isProduction ? problems.push(message) : warn(message));
|
||
if (!probe) {
|
||
log('[건너뜀] docker 를 쓸 수 없어 엔진 웹훅이 받는 곳까지 닿는지 확인하지 못했습니다.');
|
||
} else if (probe.code !== null && probe.code >= 200 && probe.code < 300) {
|
||
log(`[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP ${probe.code} — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다)`);
|
||
} else if (probe.code === 401 && webhook.keyChanged) {
|
||
warn(
|
||
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — 이번 실행에서 엔진에 ${WEBHOOK_SECRET_ENV} 를 넣었고 backend 는 아직 옛 키(또는 키 없음)로 떠 있다. ` +
|
||
`backend 를 다시 띄운 뒤(${restartBackend}) setup 을 한 번 더 실행해 [확인] 을 본다 — 그 사이 메일 프로그램 로그인 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅").`,
|
||
);
|
||
} else if (probe.code === 401) {
|
||
fail(
|
||
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — backend 의 ${WEBHOOK_SECRET_ENV} 가 setup 과 다르거나(키를 바꾼 뒤 backend 를 다시 띄우지 않음) ` +
|
||
`backend 가 엔진의 접속 주소를 앱 네트워크(APP_NETWORK_SUBNET)로 보지 못한다. backend 를 다시 띄운 뒤(${restartBackend}) setup 을 다시 실행하세요.`,
|
||
);
|
||
} else if (probe.code === 404) {
|
||
fail(
|
||
`엔진 웹훅의 받는 곳(${WEBHOOK_PATH})이 백엔드에 없습니다(HTTP 404) — 12단계 전의 backend 가 떠 있다. ` +
|
||
`새 코드로 backend 를 띄운 뒤(${reloadBackendCode}) setup 을 다시 실행하세요.`,
|
||
);
|
||
} else if (probe.code !== null) {
|
||
warn(`엔진 웹훅의 받는 곳이 서명한 빈 묶음에 HTTP ${probe.code} 로 답했습니다 — 200 이어야 한다 (백엔드 로그를 본다: docker logs ${cfg.backendContainer}).`);
|
||
} else {
|
||
warn(
|
||
`메일 컨테이너에서 엔진 웹훅의 받는 곳(${cfg.webhookUrl})에 닿지 못했습니다(${probe.error}) — backend 가 뜨는 중이면 잠시 뒤 setup 을 다시 실행한다. ` +
|
||
`그동안의 이벤트는 엔진이 5분까지 다시 보낸다 (docker logs ${cfg.backendContainer}).`,
|
||
);
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 시범 공존 — 기존 서버(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:</.test(line));
|
||
const replyLine = sentAt >= 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 "파일럿 공존" 의 확인 명령으로 본다).
|
||
* candidates — 테스트 직원 주소들. 2단계 인증을 켠 직원은 비밀번호만으로 SMTP AUTH 를 하지 못한다("334 5.7.8 … multi-factor" — 12단계) — 비밀번호
|
||
* 로그인이 402 가 아닌 첫 직원을 고르고, 모두 켜져 있으면 [건너뜀] 이다(allowRelaying 문제로 잘못 알리지 않게)
|
||
*/
|
||
async function checkPilotAuthenticatedRelay(cfg, candidates) {
|
||
if (!containerNetworks(cfg.backendContainer)) {
|
||
warn(`${cfg.backendContainer} 가 없어 인증한 세션의 확인(465 RCPT)을 건너뜁니다 — 백엔드를 띄운 뒤 다시 실행하면 확인한다.`);
|
||
return;
|
||
}
|
||
let user = null;
|
||
for (const address of candidates) {
|
||
if ((await loginStatus(cfg, address, cfg.devUserPassword)) !== HTTP_MFA_REQUIRED) {
|
||
user = address;
|
||
break;
|
||
}
|
||
}
|
||
if (!user) {
|
||
log(
|
||
`[건너뜀] 시범 공존 — 인증한 세션의 확인(${DEV_SUBMISSION_PORT} RCPT): 테스트 직원(${candidates.join(', ')})이 모두 2단계 인증을 켜 비밀번호만으로 SMTP AUTH 를 할 수 없다 — ` +
|
||
'한 사람의 2단계 인증을 끄면 다음 실행이 확인한다.',
|
||
);
|
||
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] [${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) — 안내를 출력하고 끝낸다 */
|
||
class FollowUpRequired extends Error {
|
||
constructor(message, steps) {
|
||
super(message);
|
||
this.steps = steps;
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const argv = process.argv.slice(2);
|
||
if (argv.includes('--help') || argv.includes('-h')) {
|
||
printHelp();
|
||
return;
|
||
}
|
||
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
||
if (nodeMajor < MIN_NODE_MAJOR) throw new SetupError(`Node ${MIN_NODE_MAJOR} 이상이 필요합니다 (현재 ${process.versions.node}).`);
|
||
|
||
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) {
|
||
throw new SetupError('운영에서는 docker 를 쓸 수 있어야 합니다 — 아무것도 바꾸지 않고 중단합니다.', {
|
||
hint:
|
||
'네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 한다. 이 스크립트를 docker 를 쓸 수 있는 사용자로 ' +
|
||
'호스트에서 실행하세요 — docker 그룹이면 그대로, 아니면 sudo -E env "PATH=$PATH" node … (mail/README.md "운영 배포").',
|
||
});
|
||
}
|
||
|
||
await waitForHttp(cfg, STARTUP_TIMEOUT_MS);
|
||
// 게이트웨이에 2단계 인증이 켜져 있으면 웹메일 전체가 선다 — 아무것도 바꾸기 전에 멈춘다 (12단계)
|
||
await checkGatewayWithoutTwoFactor(cfg);
|
||
// 앱 네트워크 · 메일 전용 리졸버 확인(아무것도 바꾸기 전 — 어긋나면 멈춘다)과 Spamhaus 키 시험
|
||
const dnsCheck = preflight(cfg);
|
||
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, {
|
||
label: '게이트웨이',
|
||
name: cfg.gatewayLocal,
|
||
description: GATEWAY_DESCRIPTION,
|
||
roles: { '@type': 'Custom', roleIds: { [roleId]: true } },
|
||
password: cfg.gatewayPassword,
|
||
allowedIps: cfg.trustedNetworks,
|
||
});
|
||
|
||
// 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API
|
||
await ensureUserRoleWithoutPrincipals(admin);
|
||
// 관리자 단단히 (13단계) — 관리자 역할의 대리 접근(impersonate) 빼기 · 관리자 비밀번호의 허용 IP = 앱 네트워크 (관리자로 된다)
|
||
await ensureAdminRoleWithoutImpersonate(admin);
|
||
const adminIps = await ensureAdminAllowedIps(admin, cfg);
|
||
await ensureHttpEndpointPolicy(admin);
|
||
// 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의])
|
||
const directory = await ensureDirectoryAccount(admin, cfg, domain);
|
||
// 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영).
|
||
// 시범 공존이면 같은 식에 RCPT 가드(인증 없는 세션의 엔진에 없는 사내 주소)를 함께 둔다 — 시범 A 의 첫째
|
||
const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory);
|
||
// 시범 공존 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배 이상으로 (둘 다 아래 리로드로 반영)
|
||
// SMTP 로 받는 메일 한 통은 50MiB 이하로 (아래 리로드로 반영) — IMAP 요청 · 메일 크기 한도는 이전 동안 운영자가 올리므로 바꾸지 않고, 엔진 기본보다 크면 [주의] 만
|
||
await ensureRecipientThrottle(admin, pilot);
|
||
// 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다)
|
||
await ensurePilotInboundTrust(admin, pilot);
|
||
await ensureUploadQuota(admin, cfg);
|
||
const messageSize = await ensureMessageSizeLimits(admin);
|
||
// 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영)
|
||
const retention = await ensureDataRetention(admin, cfg);
|
||
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
|
||
const engineDnsChanged = await ensureEngineDns(admin, cfg);
|
||
const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus);
|
||
// 엔진 웹훅 — 인증 이벤트를 백엔드로 (메일 프로그램 로그인 기록 — 12단계, 아래 리로드로 반영 · 키가 없으면(개발) 끈다)
|
||
const webhook = await ensureEngineWebhook(admin, cfg);
|
||
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
|
||
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);
|
||
// 받는 메일 크기 한도가 위 리로드로 실제로 걸렸는지 — 메일 컨테이너 안에서 EHLO 의 SIZE (저장 때의 [변경] · [유지] 만으로는 모른다)
|
||
await checkMessageSizeLimit(cfg, messageSize);
|
||
if (pilot) await checkPilotSenderGuard(cfg);
|
||
checkEngineDnssec(cfg, engineDnsChanged);
|
||
await checkEngineWebhook(admin, cfg, webhook);
|
||
await checkAdminHardening(admin, cfg, adminIps);
|
||
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
|
||
|
||
const devUsers = [];
|
||
if (cfg.isProduction) {
|
||
await checkPublicEndpoint(cfg);
|
||
await checkNoDevRelay(admin);
|
||
} else {
|
||
await verifyDevRelay(admin, pilot, keepPilotRoute);
|
||
for (const user of DEV_USERS) {
|
||
const address = await ensureAccount(admin, cfg, domain, {
|
||
label: '테스트 직원',
|
||
name: user.name,
|
||
description: user.description,
|
||
roles: { '@type': 'User' },
|
||
password: cfg.devUserPassword,
|
||
// 웹메일에서 2단계 인증을 켠 테스트 직원(402)은 비밀번호가 맞는 것으로 본다 — 덮어쓰지 않는다 (12단계)
|
||
mfaMeansMatch: true,
|
||
});
|
||
devUsers.push(`${address} (${user.description})`);
|
||
}
|
||
await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`);
|
||
await checkPrincipalHidden(cfg, `${DEV_USERS[0].name}@${domain.name}`);
|
||
// 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에 · 2단계 인증이 꺼진 직원으로)
|
||
if (pilot) await checkPilotAuthenticatedRelay(cfg, DEV_USERS.map((user) => `${user.name}@${domain.name}`));
|
||
}
|
||
|
||
if (cfg.isProduction || cfg.printDns) await printDnsZone(admin, domain);
|
||
|
||
// 복구 관리자는 값이 남아 있는 동안 정상 모드에서도 관리자로 로그인된다
|
||
const recoveryActive = cfg.recovery ? Boolean((await openClient(cfg, cfg.recovery.user, cfg.recovery.password))?.accountId) : false;
|
||
// 운영의 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패로 본다 (--recovery 실행은 끝에 끄라고 안내한다)
|
||
if (cfg.isProduction && !cfg.recoveryFlag) checkRecoveryDisabled(cfg);
|
||
|
||
log('\n[요약]');
|
||
const adminScreen = cfg.isProduction
|
||
? '공개 443 에서는 막혀 있다 — SSH 터널로 내부 8080 (mail/README.md "운영 배포")'
|
||
: `${cfg.publicUrl ?? cfg.adminUrl}/admin/`;
|
||
const adminIpsSummary = adminIps.ready ? `허용 IP ${adminIps.ips}` : '허용 IP 걸지 않음(위 [주의] · [실패])';
|
||
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 대리 접근 없음 · ${adminIpsSummary} — 관리 화면 ${adminScreen}`);
|
||
log(` 휴지통 ${retention} (휴지통 · 스팸메일함 — 엔진이 지운다)`);
|
||
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
|
||
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
|
||
log(
|
||
` DNS 메일 전용 리졸버 ${cfg.resolver.ip} (${cfg.resolver.network}) — 엔진 ${cfg.isProduction ? 'Custom(리졸버에 바로)' : 'System(도커 DNS 를 거쳐)'} · ` +
|
||
`Spamhaus ${spamhausState}`,
|
||
);
|
||
log(
|
||
' 잠금 공개 443 은 MTA-STS 정책 파일만 · 직원은 계정 목록(Principal) 조회 불가' +
|
||
(systemRecipientsRejected ? ` · 시스템 계정(${systemRecipients.join(', ')})으로 오는 메일은 RCPT 에서 550` : ''),
|
||
);
|
||
const directoryFallback = '읽지 못하는 동안 백엔드는 로그인한 직원 기록으로 대신한다';
|
||
if (directory.state === 'ready') {
|
||
log(` 사내 목록 ${directory.address} — 계정 목록(Principal) 읽기 전용 · 비밀번호 없음 · 대리 접근: ${directory.address}%${cfg.gatewayAddress}`);
|
||
} else if (directory.state === 'unreadable') {
|
||
log(` 사내 목록 ${directory.address} — 맞췄지만 계정 목록을 읽지 못함(위 [실패] · [주의]) — ${directoryFallback}`);
|
||
} else if (directory.state === 'conflict') {
|
||
log(` 사내 목록 ${directory.address} — 다른 계정의 주소라 쓰지 않음(위 [실패]) · MAIL_DIRECTORY_LOCAL_PART 를 바꾼다 — ${directoryFallback}`);
|
||
} else {
|
||
const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`;
|
||
log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`);
|
||
}
|
||
if (webhook.state === 'ready') {
|
||
log(` 웹훅 ${cfg.webhookUrl} — ${WEBHOOK_EVENTS.join(' · ')} · 서명 키 ${WEBHOOK_SECRET_ENV} (메일 프로그램 로그인 기록)`);
|
||
} else {
|
||
log(` 웹훅 꺼짐 (${WEBHOOK_SECRET_ENV} 비어 있음 — 메일 프로그램 로그인 기록이 남지 않는다)`);
|
||
}
|
||
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'}`);
|
||
}
|
||
if (recoveryActive && !cfg.isProduction) {
|
||
log(` 참고 ${RECOVERY_ENV_KEY} 가 설정되어 있어 복구 관리자가 정상 모드에서도 로그인됩니다 (개발만 허용 — 127.0.0.1 에만 열려 있다)`);
|
||
}
|
||
|
||
if (problems.length > 0) {
|
||
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).', [
|
||
`unset ${RECOVERY_ENV_KEY} # 셸에 남은 값 지우기`,
|
||
'docker compose --env-file .env.production up -d mail # 복구 관리자 없이 mail 컨테이너를 다시 만든다',
|
||
'node --env-file=.env.production mail/scripts/setup.mjs # --recovery 없이 다시 실행 → 꺼졌는지 확인',
|
||
'(MAIL_ADMIN_URL·sudo 여부는 방금 실행한 것과 같은 형태로)',
|
||
]);
|
||
}
|
||
log('\n[완료] 메일 엔진이 env 설정과 일치합니다.');
|
||
}
|
||
|
||
main().catch((error) => {
|
||
// 오류 글도 log · warn 과 같이 비밀을 가린다 — 엔진의 오류 본문 · 거절 사유가 그대로 실려 온다 (hiddenInOutput)
|
||
const say = (message) => console.error(redact(message));
|
||
if (error instanceof RestartRequired) {
|
||
say(`\n[재시작 필요] ${error.message}`);
|
||
say(' 이 환경에서는 docker 를 쓸 수 없습니다. 호스트에서 아래를 실행한 뒤 같은 명령으로 다시 실행하세요.');
|
||
say(` docker restart ${error.container}`);
|
||
process.exit(EXIT_FOLLOW_UP);
|
||
}
|
||
if (error instanceof FollowUpRequired) {
|
||
say(`\n[후속 조치] ${error.message}`);
|
||
for (const step of error.steps) say(` ${step}`);
|
||
process.exit(EXIT_FOLLOW_UP);
|
||
}
|
||
say(`\n[실패] ${error.message}`);
|
||
if (error.detail) say(error.detail);
|
||
if (error.hint) say(` → ${error.hint}`);
|
||
// 멈추기 전에 쌓인 확인 실패도 함께 — 끝까지 가지 못해도 앞의 [실패] 가 사라지지 않게 (2차 리뷰 N1)
|
||
if (!error.problemsIncluded && problems.length > 0) {
|
||
say(' 그 전에 이 실행에서 확인에 실패한 항목:');
|
||
for (const message of problems) say(` - ${message}`);
|
||
}
|
||
if (!(error instanceof SetupError)) say(error.stack);
|
||
process.exit(EXIT_ERROR);
|
||
});
|