generated from ttipo/project-base
59420df1b9
사내 목록은 엔진의 계정 목록(이름 · 주소)을 읽어야 하는데, 직원 역할은 그 권한을 일부러 막아 두었고 엔진 관리자는 그 권한을 내주지 못한다. 그래서 setup 이 복구 관리자로 돌 때 비밀번호 없는 읽기 전용 계정(MAIL_DIRECTORY_LOCAL_PART, 기본 mail-directory)을 만든다 — 백엔드는 게이트웨이 대리 접근으로만 쓴다. 메일을 받지 않게 역할에서 받기 권한을 빼고 저장 한도도 작게 둔다. 관리자 · 게이트웨이 · 사내 목록 계정은 메일을 받지 않는데, 엔진은 RCPT 를 받아 놓고 배달할 때 거절해 보낸 사람에게 반송했다(backscatter — 위조된 발신자에게 반송이 나가 서버 평판이 떨어진다). RCPT 단계 스크립트로 이 주소(+태그 꼴 포함)를 없는 주소처럼 550 으로 거절하고, setup 이 끝날 때 RCPT 만 보내 스스로 확인한다. 다른 설정이 그 자리를 쓰고 있으면 건드리지 않고 알린다. 일반 관리자는 공용 주소(Group 계정)를 만들지 못한다는 것도 운영 배포 절차에 적었다 — 복구 관리자로 만든다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2409 lines
130 KiB
JavaScript
2409 lines
130 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·관리자 복구·사내 목록 계정 만들기 — 복구 관리자는 일회성으로만 준다
|
||
*
|
||
* 설정값은 전부 루트 .env.{APP_ENV} 에서 온다 (SSOT — .claude/rules/env-structure.md).
|
||
* 비밀번호는 어떤 경우에도 출력하지 않는다.
|
||
*
|
||
* ⚠️ 운영에서는 docker 를 쓸 수 있어야 한다 (docker 그룹 또는 sudo — mail/README.md "운영 배포").
|
||
* 네트워크·복구 관리자 확인과 bootstrap 뒤 재시작을 docker 로 하기 때문이다. 없으면 아무것도 바꾸지 않고 실패한다.
|
||
*
|
||
* 하는 일 (순서가 중요하다)
|
||
* 0. 사전 확인 앱 네트워크 서브넷 · 앱 네트워크에 붙은 컨테이너 · 백엔드 주소 — 하나라도 어긋나면
|
||
* 아무것도 바꾸지 않고 중단한다 (신뢰 대역을 바꾼 뒤에 알면 늦다)
|
||
* 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 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게
|
||
* 공개 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배 이상으로 올린다 (설정 리로드 뒤 반영)
|
||
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
|
||
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
|
||
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
|
||
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
|
||
* 10. [개발] 테스트 직원 계정
|
||
*
|
||
* 종료 코드: 0 성공 · 1 실패 · 3 후속 조치 필요 (안내대로 한 뒤 다시 실행하면 이어서 진행)
|
||
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||
import { request as httpsRequest } from 'node:https';
|
||
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';
|
||
/** 직원 역할에서 뺄 권한 — 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*$/;
|
||
/**
|
||
* 계정 아이디(주소의 @ 앞) 형식 — 영문·숫자·. _ -, 앞뒤는 영문·숫자, 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 거절 확인에서 한 세션에 보내는 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 으로 */
|
||
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: '박서준' },
|
||
];
|
||
|
||
/** *.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 = [];
|
||
|
||
const log = (message) => console.log(message);
|
||
const warn = (message) => console.warn(`[주의] ${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 파일 값이 어긋나는지 검사할 대상 */
|
||
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|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)는 원래 셸에서 일회성으로 주는 값이라 검사하지 않는다.
|
||
*/
|
||
function findShellOverrides(fromFiles) {
|
||
return [...fromFiles]
|
||
.filter(
|
||
([key, value]) =>
|
||
key !== RECOVERY_ENV_KEY && WATCHED_KEY.test(key) && process.env[key] !== undefined && process.env[key] !== value,
|
||
)
|
||
.map(([key]) => key);
|
||
}
|
||
|
||
function loadConfig(argv) {
|
||
const fromFiles = readEnvFiles();
|
||
const overridden = findShellOverrides(fromFiles);
|
||
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 과 같습니다.',
|
||
);
|
||
}
|
||
}
|
||
|
||
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
|
||
const secrets = {
|
||
MAIL_ADMIN_PASSWORD: adminPassword,
|
||
MAIL_GATEWAY_PASSWORD: gatewayPassword,
|
||
MAIL_RECOVERY_ADMIN: recovery?.password,
|
||
MAIL_DEV_USER_PASSWORD: devUserPassword,
|
||
};
|
||
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'}).`);
|
||
}
|
||
|
||
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,
|
||
attachmentTotalMb,
|
||
uploadQuotaFloor,
|
||
devUserPassword,
|
||
mailpitUiPort: readEnv('MAILPIT_UI_PORT'),
|
||
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;
|
||
}
|
||
|
||
/** 앱 네트워크 서브넷으로 쓸 수 없는 이유 — 쓸 수 있으면 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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|
||
|
||
// ============================================================
|
||
// 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 자격 증명 불일치(또는 허용 IP 밖) */
|
||
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.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;
|
||
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;
|
||
}
|
||
|
||
// 정상 모드인데 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 에서 빠진 대역은 지운다 (사람이 만든 항목은 건드리지 않는다)
|
||
const stale = entries.filter((entry) => entry.reason?.startsWith(MANAGED_MARK) && !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 등)은 판단할 수 없으므로 중단한다
|
||
* (틀린 것으로 보고 비밀번호를 덮어쓰거나, 맞는 것으로 보고 넘어가면 안 된다).
|
||
*/
|
||
async function passwordMatches(cfg, address, password) {
|
||
const status = await loginStatus(cfg, address, password);
|
||
if (status >= 200 && status < 300) return true;
|
||
if (status === 401) return false;
|
||
throw new SetupError(`${address} 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP ${status}). 아무것도 바꾸지 않았습니다.`, {
|
||
hint:
|
||
status === 429
|
||
? '시도가 너무 많아 잠시 막혔습니다. 몇 분 뒤 다시 실행하세요.'
|
||
: `메일 엔진 상태와 로그를 확인한 뒤 다시 실행하세요: docker logs ${cfg.container}`,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 계정을 env 값에 맞춘다. 저장된 비밀번호는 읽을 수 없으므로 실제 로그인으로 확인하고, 다르면 env 값으로 바꾼다.
|
||
* spec: { name, description, roles, password, allowedIps?, label }
|
||
*/
|
||
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))) {
|
||
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) 백엔드가 있는 대역으로 고치고 다시 실행하세요.',
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 메일 엔진 설정을 바꾸기 전의 확인 — 어긋나면 아무것도 바꾸지 않고 중단한다.
|
||
* (신뢰 대역을 바꾼 뒤에야 알면 전 직원의 메일이 멈추고, 백엔드 주소가 자동 차단 예외에서 빠져 차단될 수 있다)
|
||
*/
|
||
function preflight(cfg) {
|
||
if (!cfg.docker) {
|
||
// 운영은 main 이 이미 막았다 — 여기는 개발뿐이다
|
||
warn('docker 를 쓸 수 없어 앱 네트워크 확인(서브넷·구성원·백엔드 주소)을 건너뜁니다 (개발 환경만 허용).');
|
||
return;
|
||
}
|
||
const issues = [];
|
||
const subnetMatches = checkNetworkSubnet(cfg, issues);
|
||
checkAppNetworkMembers(cfg, issues);
|
||
checkBackendNetwork(cfg, issues, subnetMatches);
|
||
if (issues.length > 0) {
|
||
throw new SetupError('사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다.', {
|
||
detail: issues.map((message) => ` - ${message}`).join('\n'),
|
||
});
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 잠금 · 엔진 한도 · 반영 확인
|
||
// ============================================================
|
||
|
||
/**
|
||
* 직원(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(', ')})`);
|
||
}
|
||
|
||
// ============================================================
|
||
// 사내 목록 계정 — 계정 목록(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 를 고른다 */
|
||
function systemRcptScriptExpression(addresses) {
|
||
const condition = addresses
|
||
.map((address) => {
|
||
const at = address.lastIndexOf('@');
|
||
return `rcpt == '${address}' || (starts_with(rcpt, '${address.slice(0, at)}+') && ends_with(rcpt, '${address.slice(at)}'))`;
|
||
})
|
||
.join(' || ');
|
||
return { match: { 0: { if: condition, then: `'${SYSTEM_RCPT_SCRIPT.name}'` } }, else: 'false' };
|
||
}
|
||
|
||
/** 이 스크립트가 써 둔 식인가 — 받는 주소 목록만 다를 수 있다 (사내 목록 아이디를 바꿨을 때) */
|
||
function isOwnRcptScriptExpression(value) {
|
||
const branches = Object.values(value?.match ?? {});
|
||
return branches.length === 1 && branches[0]?.then === `'${SYSTEM_RCPT_SCRIPT.name}'` && value?.else === 'false';
|
||
}
|
||
|
||
/**
|
||
* 메일을 받지 않는 시스템 계정을 RCPT 단계에서 없는 주소처럼 거절하게 맞춘다 (SYSTEM_RCPT_REPLY 머리말) — 맞춰져 있으면 true.
|
||
* 관리자 권한으로 된다(복구 관리자가 없어도). x:MtaStageRcpt 의 script 는 하나뿐이라, 기본값(없음)이거나 이 스크립트가 써 둔 식일 때만 고치고,
|
||
* 다른 설정이 쓰고 있으면 건드리지 않고 [주의] 만 낸다.
|
||
*/
|
||
async function ensureSystemRecipientsRejected(client, addresses) {
|
||
const unsafe = addresses.filter((address) => !PLAIN_ADDRESS_PATTERN.test(address));
|
||
if (unsafe.length > 0) {
|
||
problems.push(`RCPT 거절 식에 그대로 넣을 수 없는 주소라 시스템 계정의 RCPT 거절을 맞추지 않았습니다: ${unsafe.join(', ')} (MAIL_DOMAIN 확인)`);
|
||
return false;
|
||
}
|
||
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}`);
|
||
} else {
|
||
const patch = Object.fromEntries(Object.entries(desired).filter(([key, value]) => script[key] !== value));
|
||
if (Object.keys(patch).length > 0) {
|
||
await setObjects(client, 'SieveSystemScript', { update: { [script.id]: patch } });
|
||
log(`[변경] ${scriptLabel} (${Object.keys(patch).join(', ')})`);
|
||
} else {
|
||
log(`[유지] ${scriptLabel}`);
|
||
}
|
||
}
|
||
|
||
const label = `RCPT 단계 — 메일을 받지 않는 시스템 계정 ${addresses.join(', ')}(+태그 꼴 포함)은 없는 주소처럼 550 (직원 · 공용 주소는 그대로)`;
|
||
const expression = systemRcptScriptExpression(addresses);
|
||
const current = (await call(client, 'x:MtaStageRcpt/get', { ids: ['singleton'], properties: ['script'] })).list[0]?.script;
|
||
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}`);
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 받는 사람 단위 받는 쪽 한도를 인증 없는 세션(바깥에서 들어오는 메일)에만 건다 — ReloadSettings 뒤에 반영된다.
|
||
* 설명·key 로 찾고, 없으면 만들지 않고 알린다 (지웠다면 운영에서 정한 것이고, 이름을 바꿨다면 사람이 그 한도를 본다).
|
||
* 한도 값(rate)·켜짐(enable)은 건드리지 않는다 — 운영에서 정할 것 (mail/README.md "엔진 한도").
|
||
*/
|
||
async function ensureRecipientThrottle(client) {
|
||
const label = `받는 쪽 한도 "${RECIPIENT_THROTTLE.description}"(같은 보낸 도메인 → 한 받는 사람)`;
|
||
const scope = '인증 없는 세션에만 (로그인한 직원의 발송 제외)';
|
||
const throttles = (await listAll(client, 'MtaInboundThrottle', ['description', 'key', 'match', 'enable'])).filter(
|
||
(item) => item.description === RECIPIENT_THROTTLE.description && sameTrueSet(item.key, RECIPIENT_THROTTLE.key),
|
||
);
|
||
if (throttles.length === 0) {
|
||
warn(
|
||
`${label}을 찾지 못해 만들지 않았습니다. 이름을 바꿔 쓰고 있다면 그 한도의 match 가 ${JSON.stringify(UNAUTHENTICATED_SESSIONS_ONLY)} 인지 ` +
|
||
'관리 화면 › Settings › MTA › Rates & Quotas › Inbound Rate Limits 에서 확인하세요 — 아니면 로그인한 직원의 발송도 그 한도에 걸린다 (mail/README.md "엔진 한도").',
|
||
);
|
||
return;
|
||
}
|
||
const stale = throttles.filter((item) => stableJson(item.match) !== stableJson(UNAUTHENTICATED_SESSIONS_ONLY));
|
||
if (stale.length > 0) {
|
||
await setObjects(client, 'MtaInboundThrottle', {
|
||
update: Object.fromEntries(stale.map((item) => [item.id, { match: UNAUTHENTICATED_SESSIONS_ONLY }])),
|
||
});
|
||
}
|
||
for (const item of throttles) {
|
||
const disabled = item.enable === false ? ' · 꺼져 있음(enable: false)' : '';
|
||
if (stale.includes(item)) log(`[변경] ${label} — ${scope}${disabled} · 전 match ${JSON.stringify(item.match)}`);
|
||
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}`);
|
||
}
|
||
|
||
/** 저장한 설정을 적용한다. 지난 실행이 리로드 전에 멈췄을 수도 있으므로 매번 부른다 */
|
||
async function reloadSettings(client) {
|
||
await setObjects(client, 'Action', { create: { reload: { '@type': 'ReloadSettings' } } });
|
||
}
|
||
|
||
/** 리로드·캐시 무효화가 퍼질 때까지 확인을 몇 번 다시 해 본다 */
|
||
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 까지만 보낸다 — 받는 주소(소문자)마다 응답 줄. curl 을 부르지 못하면 null.
|
||
* 받는 주소가 모두 거절되면 curl 은 DATA 전에 멈춘다(종료 코드 55) — 그래서 거절될 주소만 넣는다.
|
||
*/
|
||
function rcptRepliesInsideMail(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 = new Map();
|
||
let asked = null;
|
||
for (const line of (result.stderr ?? '').split(/\r?\n/)) {
|
||
const sent = /^> RCPT TO:<([^>]*)>/.exec(line);
|
||
if (sent) {
|
||
asked = sent[1].toLowerCase();
|
||
continue;
|
||
}
|
||
const reply = /^< (\d{3}[ -].*)$/.exec(line);
|
||
if (reply && asked) {
|
||
replies.set(asked, reply[1]);
|
||
asked = null;
|
||
}
|
||
}
|
||
return replies;
|
||
}
|
||
|
||
/**
|
||
* 시스템 계정의 RCPT 거절이 실제로 걸렸는지 — 메일 컨테이너 안에서 자기 네트워크 주소의 25(인증 없는 세션, 바깥 메일이 들어오는 길)로
|
||
* 계정마다 RCPT 를 보내 550 인지 본다 (+태그 꼴은 마지막 계정 하나로). 보낸 사람은 게이트웨이 주소 — 메일을 받지 못하는 계정이라,
|
||
* 거절이 걸리지 않아 curl 이 DATA 까지 가더라도 반송이 밖으로 나가지 않는다.
|
||
* 루프백(127.0.0.1)이 아니라 컨테이너의 네트워크 주소로 부른다 — 공개 443 확인과 같은 까닭(루프백은 다르게 다룰 수 있다).
|
||
*/
|
||
async function checkSystemRecipientsRejected(cfg, addresses) {
|
||
const networks = cfg.docker ? containerNetworks(cfg.container) : null;
|
||
const mailIp = networks ? (networks[cfg.networkName] ?? Object.values(networks).find(Boolean)) : null;
|
||
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 = rcptRepliesInsideMail(cfg, mailIp, cfg.gatewayAddress, probes);
|
||
if (replies && probes.every((probe) => replies.get(probe)?.startsWith('550'))) break;
|
||
await sleep(POLL_INTERVAL_MS / 2);
|
||
}
|
||
const notRejected = probes.filter((probe) => !replies?.get(probe)?.startsWith('550'));
|
||
if (notRejected.length === 0) {
|
||
log(`[확인] RCPT 단계 — ${probes.join(', ')} → 550 (메일 컨테이너 ${mailIp}:25, 인증 없는 세션)`);
|
||
return;
|
||
}
|
||
problems.push(
|
||
`시스템 계정의 RCPT 거절 확인 실패 — ${notRejected.map((probe) => `${probe}: ${replies?.get(probe) ?? '응답 없음'}`).join(' · ')} (550 이어야 함). ` +
|
||
'setup 이 ReloadSettings 까지 했는지, x:MtaStageRcpt 의 script 와 시스템 Sieve 스크립트를 확인하세요 (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)을 볼 수 없는지 */
|
||
async function checkPrincipalHidden(cfg, address) {
|
||
const client = 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') {
|
||
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden).`);
|
||
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 없음)');
|
||
}
|
||
}
|
||
|
||
async function ensureDevRelay(client) {
|
||
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 (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 인지. 아니면 테스트 계정을 만들기 전에 멈춘다 */
|
||
async function verifyDevRelay(client) {
|
||
const after = (await call(client, 'x:MtaOutboundStrategy/get', { ids: ['singleton'] })).list[0];
|
||
if (stableJson(after?.route) !== stableJson(DEV_OUTBOUND_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('-----');
|
||
}
|
||
|
||
// ============================================================
|
||
// 실행
|
||
// ============================================================
|
||
|
||
function printHelp() {
|
||
log('사용법: node --env-file=.env.{APP_ENV} mail/scripts/setup.mjs [--print-dns] [--recovery]');
|
||
log(' 메일 엔진(Stalwart)을 루트 env 에 맞춰 설정한다. 여러 번 실행해도 안전하다. 자세한 내용: mail/README.md');
|
||
log(' 운영은 docker 를 쓸 수 있는 사용자로 호스트에서 실행한다 (docker 그룹 또는 sudo -E).');
|
||
log(` --recovery 운영의 최초 bootstrap·관리자 복구·사내 목록 계정 만들기 — ${RECOVERY_ENV_KEY} 를 셸에서 일회성으로 함께 준다`);
|
||
}
|
||
|
||
/** 후속 조치가 필요하다 (종료 코드 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);
|
||
|
||
// 운영은 확인을 건너뛰지 않는다 — 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);
|
||
preflight(cfg);
|
||
const admin = await ensureAdmin(cfg);
|
||
const domain = await findDomain(admin, cfg);
|
||
|
||
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);
|
||
await ensureHttpEndpointPolicy(admin);
|
||
// 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의])
|
||
const directory = await ensureDirectoryAccount(admin, cfg, domain);
|
||
// 메일을 받지 않는 시스템 계정 — 받아 놓고 반송(backscatter)하지 않게 RCPT 에서 없는 주소처럼 거절한다 (아래 리로드로 반영)
|
||
const systemRecipients = await nonReceivingSystemAddresses(admin, cfg, directory);
|
||
const systemRecipientsRejected = await ensureSystemRecipientsRejected(admin, systemRecipients);
|
||
// 엔진 한도 — 받는 사람 단위 한도는 바깥에서 들어오는 메일에만 (로그인한 직원의 발송에는 걸지 않는다),
|
||
// 계정마다 임시 업로드 크기 합계는 첨부 합계 한도의 20배 이상으로 (둘 다 아래 리로드로 반영)
|
||
await ensureRecipientThrottle(admin);
|
||
await ensureUploadQuota(admin, cfg);
|
||
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
|
||
if (!cfg.isProduction) await ensureDevRelay(admin);
|
||
await reloadSettings(admin);
|
||
await checkHttpEndpointPolicy(cfg);
|
||
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
|
||
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
|
||
|
||
const devUsers = [];
|
||
if (cfg.isProduction) {
|
||
await checkPublicEndpoint(cfg);
|
||
await checkNoDevRelay(admin);
|
||
} else {
|
||
await verifyDevRelay(admin);
|
||
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,
|
||
});
|
||
devUsers.push(`${address} (${user.description})`);
|
||
}
|
||
await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`);
|
||
await checkPrincipalHidden(cfg, `${DEV_USERS[0].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/`;
|
||
log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`);
|
||
log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`);
|
||
log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`);
|
||
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 (gatewayReachesAdmin) {
|
||
log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")');
|
||
}
|
||
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) {
|
||
throw new SetupError('설정은 끝났지만 확인에 실패한 항목이 있습니다.', {
|
||
detail: problems.map((message) => ` - ${message}`).join('\n'),
|
||
});
|
||
}
|
||
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) => {
|
||
if (error instanceof RestartRequired) {
|
||
console.error(`\n[재시작 필요] ${error.message}`);
|
||
console.error(' 이 환경에서는 docker 를 쓸 수 없습니다. 호스트에서 아래를 실행한 뒤 같은 명령으로 다시 실행하세요.');
|
||
console.error(` docker restart ${error.container}`);
|
||
process.exit(EXIT_FOLLOW_UP);
|
||
}
|
||
if (error instanceof FollowUpRequired) {
|
||
console.error(`\n[후속 조치] ${error.message}`);
|
||
for (const step of error.steps) console.error(` ${step}`);
|
||
process.exit(EXIT_FOLLOW_UP);
|
||
}
|
||
console.error(`\n[실패] ${error.message}`);
|
||
if (error.detail) console.error(error.detail);
|
||
if (error.hint) console.error(` → ${error.hint}`);
|
||
if (!(error instanceof SetupError)) console.error(error.stack);
|
||
process.exit(EXIT_ERROR);
|
||
});
|