generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정
웹메일 1단계를 개발 환경에서 띄우고 운영에 올리기 위한 인프라 구성이다. - 메일 엔진 Stalwart(v0.16.27)와 개발용 Mailpit 을 compose profile `mail` 로 추가한다. 초기 설정은 mail/scripts/setup.mjs 가 맡는다 — 여러 번 실행해도 안전하고, 운영에서는 사전 검사가 실패하면 아무것도 바꾸지 않고 멈춘다 - 화면과 API 를 한 주소로 배포한다: 프론트 nginx 가 /api 를 백엔드로 넘기고, 본문 한도·시간 제한·헤더 전달을 env 와 맞춘다. nginx 가 직접 내는 오류도 표준 실패 형식이다 - 인터넷을 향한 nginx 는 edge 네트워크로 백엔드에만 닿고, DB·메일 엔진은 app-network 에 둔다 (메일 엔진의 신뢰 대역도 app-network 뿐) - Node 24.22.1·npm 11.6.2·nginx 1.30.6 을 Dockerfile 과 CI 에 한 값으로 고정한다 - 개발 호스트 포트는 127.0.0.1 로 한정하고, data/ 는 클론 때부터 사용자 소유가 되게 .gitkeep 을 추적한다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+495
@@ -0,0 +1,495 @@
|
||||
# 메일 엔진 (Stalwart)
|
||||
|
||||
사내 메일은 **Stalwart(JMAP) 를 자체 호스팅**한다. 직원 로그인 = 메일 계정이다.
|
||||
웹메일 UI 는 자체 제작하고, 백엔드(NestJS)가 내부 JMAP 으로 메일 엔진에 중계한다.
|
||||
|
||||
- 이미지: `stalwartlabs/stalwart:v0.16.27` — 패치까지 고정 (비-root `stalwart`, UID 2000 · healthcheck 내장. 올릴 때는 [docker 스킬](../.claude/skills/docker/SKILL.md) "이미지 버전 고정")
|
||||
- 설정값 SSOT: 루트 `.env.{APP_ENV}` 의 메일 섹션 ([env-structure](../.claude/rules/env-structure.md))
|
||||
- 초기화·계정 구성: [`scripts/setup.mjs`](./scripts/setup.mjs) — 의존성 없음, 여러 번 실행해도 안전
|
||||
- 이름은 루트 env 의 `PROJECT_NAME` 을 따른다 — 컨테이너 `${PROJECT_NAME}-mail`, 볼륨 `${PROJECT_NAME}_mail-config`·`_mail-data`,
|
||||
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
|
||||
```bash
|
||||
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구조
|
||||
|
||||
```
|
||||
브라우저 ─▶ frontend nginx ─(edge)─▶ backend ──HTTP JMAP (app-network, http://mail:8080)──▶ mail (Stalwart)
|
||||
│ 로그인: <직원 주소>:<비밀번호> 로 세션 조회 (200 / 401) │ 25 SMTP 수신
|
||||
│ 메일함: <직원 주소>%<게이트웨이 주소> 로 대리 접근 │ 465 SMTPS 제출 · 993 IMAPS
|
||||
│ 443 HTTPS — ACME·MTA-STS 만 (JMAP·관리 API·포털은 403)
|
||||
▼ 사내 도메인 밖 (개발 환경만)
|
||||
mailpit — 외부 수신자 메일 수집
|
||||
```
|
||||
|
||||
메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
|
||||
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
|
||||
|
||||
| 구성 요소 | 위치 | 비고 |
|
||||
| --- | --- | --- |
|
||||
| `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `<PROJECT_NAME>_mail-config`(config.json) · `<PROJECT_NAME>_mail-data`(RocksDB) |
|
||||
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
|
||||
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
|
||||
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
|
||||
|
||||
| 포트 | 운영 | 개발 |
|
||||
| --- | --- | --- |
|
||||
| 25 · 465 · 993 · 443 | 호스트에 공개 (`0.0.0.0`, IPv4 만 — [보안](#보안)). 443 의 HTTP 는 MTA-STS 정책 파일만 연다 | 공개하지 않음 |
|
||||
| 8080 (HTTP JMAP·관리 API) | **공개하지 않음** (backend 전용 내부망) | `127.0.0.1:${MAIL_ADMIN_PORT}` (관리 화면·setup 스크립트) |
|
||||
| 995 pop3s · 4190 sieve | 리스너는 있으나 공개하지 않음 | — |
|
||||
|
||||
| 계정 | 용도 | 비밀번호 |
|
||||
| --- | --- | --- |
|
||||
| `<MAIL_ADMIN_LOCAL_PART>@<MAIL_DOMAIN>` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` |
|
||||
| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` |
|
||||
| `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` |
|
||||
| `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` |
|
||||
|
||||
---
|
||||
|
||||
## 개발 시작
|
||||
|
||||
```bash
|
||||
# 0) env 파일 (처음 한 번) — change-me-* 를 무작위 영문·숫자로 교체한다
|
||||
cp .env.development.example .env.development
|
||||
cp backend/.env.development.example backend/.env.development
|
||||
|
||||
# 1) 기동 — COMPOSE_PROFILES=mail 이 기본이라 backend·db·mail·mailpit 이 함께 뜬다
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development up -d
|
||||
|
||||
# 2) 메일 엔진 설정 (처음 한 번 — 이후에도 env 를 바꾸면 다시 실행한다)
|
||||
node --env-file=.env.development mail/scripts/setup.mjs
|
||||
```
|
||||
|
||||
| 확인 | 주소 |
|
||||
| --- | --- |
|
||||
| 관리 화면 (웹 포털) | <http://localhost:8180/admin/> — `admin@arasystem.test` / `MAIL_ADMIN_PASSWORD` |
|
||||
| 직원 셀프서비스 | <http://localhost:8180/account/> |
|
||||
| Mailpit (외부 수신자 메일) | <http://localhost:8025> |
|
||||
|
||||
- 첫 실행은 bootstrap → **mail 컨테이너를 스스로 재시작**(`docker restart <PROJECT_NAME>-mail`)한 뒤 이어서 진행한다.
|
||||
- 출력의 `[생성]`·`[변경]`·`[유지]` 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 `[유지]` 여야 정상이다.
|
||||
- ⚠️ **외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다.** 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다.
|
||||
그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(`example.net` 등)만 쓴다.
|
||||
- 개발 메일 데이터를 버리고 처음부터:
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit
|
||||
docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"
|
||||
# 이후 1) 2) 를 다시
|
||||
```
|
||||
(`docker compose down -v` 도 이 볼륨을 지운다 — 의도한 게 아니라면 쓰지 않는다)
|
||||
|
||||
### setup 스크립트가 하는 일
|
||||
|
||||
0. **사전 확인 (아무것도 바꾸기 전)** — app-network 의 실제 서브넷이 `APP_NETWORK_SUBNET` 인지, 앱 네트워크에 정해진 컨테이너
|
||||
(backend·db·redis·mail·mailpit)만 붙어 있는지, 백엔드 주소가 신뢰 대역 안인지. 어긋나면 **엔진 설정을 바꾸지 않고 중단**한다
|
||||
(신뢰 대역을 바꾼 뒤에 알면 전 직원의 메일이 멈추고 백엔드 주소가 자동 차단 예외에서 빠진다). 앱 네트워크에 다른 컨테이너가 있으면 운영은 중단, 개발은 경고.
|
||||
1. **모드 감지** — bootstrap(첫 기동, 8080 만 열림) / bootstrap 완료·재시작 대기 / 정상
|
||||
2. **bootstrap** — 호스트명·기본 도메인·DKIM 키(ed25519·rsa)·로그(표준 출력) → 재시작.
|
||||
응답의 무작위 관리자 비밀번호는 한 번만 보이므로 `data/mail/bootstrap-admin.json`(git 무시)에 잠시 보관했다가 교체 후 지운다.
|
||||
⚠️ `Bootstrap/set` 은 원자적이지 않다 — 다시 부르면 도메인 중복으로 거부되면서도 tracer 가 하나 더 생겨 설정 오류가 난다. 그래서
|
||||
- 부르기 **전에** `data/mail/` 에 실제로 써 보고, 쓸 수 없으면 아무것도 바꾸지 않고 멈춘다
|
||||
- 재실행 때는 mail 컨테이너의 `/etc/stalwart/config.json` 으로 "bootstrap 은 끝났고 재시작만 남았다"를 알아보고 재시작만 한다
|
||||
(Bootstrap/set 은 재시작 전에 이미 이 파일을 쓴다 — 보관 파일이 없어도 다시 부르지 않는다. docker 를 쓸 수 없으면 보관 파일로 판단)
|
||||
3. **관리자** — `<MAIL_ADMIN_LOCAL_PART>@도메인` 비밀번호를 `MAIL_ADMIN_PASSWORD` 로 맞춘다 (나중에 env 값을 바꾸면 복구 관리자로 다시 맞춘다 — 운영은 `--recovery`).
|
||||
갓 bootstrap 한 엔진이면 bootstrap 이 만든 `admin@` 의 아이디를 `MAIL_ADMIN_LOCAL_PART` 로 바꾼다 (Bootstrap/set 은 아이디를 받지 않는다).
|
||||
이미 쓰던 엔진에서 env 아이디의 관리자가 없으면(bootstrap 뒤에 값만 바꾼 경우) **중단**한다
|
||||
4. **자동 차단 예외** — 신뢰 대역을 AllowedIp 로 등록 (기본 보안 설정은 IP 당 인증 실패 하루 100회면 차단하는데, 모든 로그인이 백엔드 IP 하나를 거친다).
|
||||
신뢰 대역 = `MAIL_TRUSTED_NETWORKS`, 비우면 `APP_NETWORK_SUBNET`(compose 가 고정한 app-network 서브넷).
|
||||
**서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다.
|
||||
5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만
|
||||
6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`).
|
||||
공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안)
|
||||
7. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
|
||||
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
|
||||
200 이면 실패(앞단 프록시가 8080 으로 넘기는 구성), 연결 자체가 안 되면 경고와 함께 바깥에서 확인할 `curl` 명령을 보여 준다.
|
||||
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안))
|
||||
8. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 7 의 리로드로 반영된 것을 확인한 뒤에만 9
|
||||
9. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
|
||||
|
||||
그 밖의 안전장치:
|
||||
|
||||
- **운영은 docker 를 쓸 수 있어야 한다** — 없으면 아무것도 바꾸지 않고 실패한다 (네트워크·복구 관리자 확인, bootstrap 뒤 재시작).
|
||||
- **셸에 `APP_ENV`·`APP_NETWORK_SUBNET` 등이 이미 있으면 중단한다.** Node 의 `--env-file` 은 기존 환경변수를 덮어쓰지 않아,
|
||||
셸에 `APP_ENV=development` 가 남은 채 `.env.production` 으로 돌리면 운영에 개발 설정(Mailpit 경로)을 넣게 된다.
|
||||
(`MAIL_RECOVERY_ADMIN` 만 예외 — 원래 셸에서 일회성으로 주는 값이다)
|
||||
- `APP_NETWORK_SUBNET` 은 사설 대역의 네트워크 주소, 접두 길이 `/24`~`/28` 만 받는다 — 이 대역 전체가 신뢰 대역이라 넓게 잡지 않는다
|
||||
(넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다).
|
||||
- `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다.
|
||||
로그인 확인은 2xx 만 "맞음", 401 만 "틀림"으로 보고 그 밖(429·5xx)은 중단한다 — 비밀번호를 잘못 덮어쓰지 않게.
|
||||
- 운영에서는 `change-me` 예시 값을 거부하고, `MAIL_PUBLIC_URL` 이 경로 없는 `https://` 가 아니면 실패, 발송 전략이 Mailpit 을 가리키면 실패로 보고한다.
|
||||
- 운영의 복구 관리자 규칙 — env 파일에 있으면 실패, 셸에 있는데 `--recovery` 가 없으면 실패,
|
||||
`--recovery` 실행은 끝에 "복구 관리자 끄기"를 안내하고 종료 코드 3, 그 뒤 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패.
|
||||
- 관리자 역할 계정이 백엔드 차단 목록(`MAIL_ADMIN_LOCAL_PART` 관리자 + `MAIL_BLOCKED_ACCOUNTS`)에서 빠져 있으면 `[주의]`.
|
||||
- 종료 코드 `0` 성공 · `1` 실패 · `3` 후속 조치 필요(개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 `--recovery` 뒤 복구 관리자 끄기 — 안내대로 한 뒤 다시 실행).
|
||||
|
||||
### 백엔드 연결 정보
|
||||
|
||||
| env (backend 에 compose 가 주입) | 값 |
|
||||
| --- | --- |
|
||||
| `MAIL_JMAP_URL` | `http://mail:8080` (내부 JMAP) |
|
||||
| `MAIL_DOMAIN` | `arasystem.test` / 운영 `arasystem.it.kr` |
|
||||
| `MAIL_GATEWAY_USER` · `MAIL_GATEWAY_PASSWORD` | 게이트웨이 주소 · 비밀번호 |
|
||||
| `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" |
|
||||
| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상) |
|
||||
|
||||
- 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401
|
||||
- 메일함 접근: Basic `<직원 주소>%<MAIL_GATEWAY_USER>` : `MAIL_GATEWAY_PASSWORD`
|
||||
(게이트웨이 비밀번호 틀림·없는 직원 → 401, 대리 접근 권한 없는 계정 → 403)
|
||||
- ⚠️ 세션 응답의 `apiUrl`·`downloadUrl`·`uploadUrl`·`eventSourceUrl` 은 **`MAIL_PUBLIC_URL` 기준 절대 주소**다
|
||||
(개발 `http://localhost:8180/jmap/`). 백엔드는 이 주소를 따르지 말고 `MAIL_JMAP_URL` 에 경로만 붙여 쓴다.
|
||||
- `Email/query` 의 `subject` 필터는 단어 단위 검색이고 제목 앞의 `[태그]` 를 떼고 색인하는 것으로 보인다 — 부분 문자열 검색이 아니다.
|
||||
- 대리 접근은 메일 엔진 로그 `auth.success` 에 `accountName`(대상 직원)·`details`(게이트웨이)·`remoteIp` 로 남는다.
|
||||
- 신뢰 대역은 자동 차단에서 빠지므로 **로그인 무차별 대입 방어는 백엔드가 맡아야 한다** (로그인 엔드포인트 엄격 한도 — [security](../.claude/rules/security.md)).
|
||||
|
||||
---
|
||||
|
||||
## 운영 배포
|
||||
|
||||
사전 조건:
|
||||
|
||||
- 메일 전용 호스트 — 25·465·993·443 을 다른 서비스가 쓰지 않는다 (리버스 프록시가 443 을 쓰면 [대안](#리버스-프록시와-같은-호스트에-둘-때-대안) 참조)
|
||||
- `MAIL_HOSTNAME` 의 A 레코드 → 이 호스트 IP, 그 IP 의 PTR → `MAIL_HOSTNAME` ([PTR](#ptr))
|
||||
- 아웃바운드 25 허용 (클라우드는 기본 차단인 곳이 많다 — 막혀 있으면 [외부 릴레이](#외부-릴레이-ses-등))
|
||||
- 아웃바운드 HTTPS — Stalwart 는 기동 때 GitHub 에서 웹 포털을 내려받고, ACME(Let's Encrypt)로 인증서를 받는다
|
||||
- **메일 서버 전용 재귀 DNS** — 공용·ISP DNS(KT·Google·Cloudflare 등)로는 Spamhaus 조회가 막혀 스팸 판정이 틀어진다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl))
|
||||
- **스팸 차단 목록 이용 조건** — Spamhaus 는 회사용 무료 DQS 키를 쓰거나 해당 항목을 끈다. 나머지 목록도 조건 확인 (같은 절)
|
||||
- **포털(웹메일) 앞의 외부 리버스 프록시** — 요구사항은 [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" (같은 주소 배포 · `X-Forwarded-For` · IPv6 · 본문 한도·시간 제한)
|
||||
- **`APP_NETWORK_SUBNET`·`EDGE_NETWORK_SUBNET`** 이 그 호스트의 다른 도커 네트워크와 겹치지 않는다 (겹치면 `up` 이 "Pool overlaps" 로 실패한다)
|
||||
- **rootful 도커 + iptables** (도커 기본값). 게시 포트(25·465·993·443)로 들어온 접속이 원래 주소 그대로 컨테이너에 보여야 한다 —
|
||||
rootless 도커(포트 전달이 주소를 바꾼다)나 `"iptables": false`(모든 접속이 docker-proxy 를 거친다)면 외부 접속이 도커 내부 주소로 보여
|
||||
자동 차단·게이트웨이 IP 제한이 무력화된다. 배포 뒤 [원래 접속 주소 확인](#원래-접속-주소-확인-배포-직후)으로 확인한다.
|
||||
- **setup 을 돌릴 사용자** — docker 를 쓸 수 있어야 한다(docker 그룹, 또는 매번 `sudo -E`). 저장소는 그 사용자로 클론한다(`sudo git clone` 금지) —
|
||||
`data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다).
|
||||
- **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다).
|
||||
|
||||
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
|
||||
|
||||
```bash
|
||||
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)"
|
||||
# 1) 복구 관리자를 일회성으로 준다 — 셸 기록에 남지 않게 read 로 받는다 (compose 는 셸 값이 env 파일보다 우선한다)
|
||||
read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P"
|
||||
|
||||
# 2) 기동 (mail 은 bootstrap 모드로 뜬다 — 8080 만 열림). sudo 를 쓰면 -E 로 셸 값을 넘긴다
|
||||
sudo -E docker compose --env-file .env.production up -d --build
|
||||
|
||||
# 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다).
|
||||
# 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다).
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
# (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로:
|
||||
node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# docker 그룹이 아니면 sudo 로 (셸 값과 PATH 를 넘긴다). ⚠️ 그러면 이후 실행도 모두 같은 형태로 — data/mail/ 이 root 소유가 된다
|
||||
sudo -E env "PATH=$PATH" node --env-file=.env.production mail/scripts/setup.mjs --recovery
|
||||
# (B) 호스트에 Node 가 없으면 — 공식 배포 파일을 임시 폴더에 받아 그대로 쓴다 (설치하지 않는다, 체크섬 확인. ARM 서버는 linux-arm64)
|
||||
NODE_DIST=node-v24.22.1-linux-x64; NODE_TMP="$(mktemp -d)"
|
||||
curl -fsSL -o "$NODE_TMP/$NODE_DIST.tar.xz" "https://nodejs.org/dist/v24.22.1/$NODE_DIST.tar.xz"
|
||||
curl -fsSL "https://nodejs.org/dist/v24.22.1/SHASUMS256.txt" | grep " $NODE_DIST.tar.xz\$" | (cd "$NODE_TMP" && sha256sum -c -)
|
||||
tar -xJf "$NODE_TMP/$NODE_DIST.tar.xz" -C "$NODE_TMP" && export PATH="$NODE_TMP/$NODE_DIST/bin:$PATH"
|
||||
# → 이후는 (A) 와 같다. 끝나면 rm -rf "$NODE_TMP"
|
||||
# --recovery 실행은 설정을 마친 뒤 "복구 관리자 끄기"를 안내하고 종료 코드 3 으로 끝난다 → 4) 로
|
||||
|
||||
# 4) 복구 관리자 끄기 — 셸 값을 지우고 mail 컨테이너를 다시 만든다 (빈 값이면 복구 자격 증명은 401 — 검증함)
|
||||
unset MAIL_RECOVERY_ADMIN P
|
||||
sudo docker compose --env-file .env.production up -d mail
|
||||
|
||||
# 5) --recovery 없이 다시 실행 (3 과 같은 형태 — 컨테이너를 다시 만들었으므로 MAIL_ADMIN_URL 도 다시 구한다)
|
||||
# 전부 [유지] 이고, 컨테이너에 복구 관리자가 남아 있지 않은지 확인한다 (남아 있으면 실패)
|
||||
export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080"
|
||||
node --env-file=.env.production mail/scripts/setup.mjs
|
||||
```
|
||||
|
||||
- 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다.
|
||||
- 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면
|
||||
경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https://<MAIL_HOSTNAME>/jmap/session` → `403`.
|
||||
|
||||
#### bootstrap 직후에 멈췄다면 (다시 실행하기 전에)
|
||||
|
||||
setup 이 bootstrap 과 재시작 사이(또는 직후)에 멈췄으면 — 연결 끊김, 권한 오류, 종료 코드 1 — **볼륨을 지우거나 `Bootstrap/set` 을 손으로 부르지 않는다.**
|
||||
|
||||
1. 엔진이 어디까지 갔는지 본다: `sudo docker exec "${PROJECT_NAME}-mail" test -s /etc/stalwart/config.json && echo "bootstrap 끝남"`
|
||||
2. 실패 원인을 고친다 — 출력의 `[실패]` 줄. `data/mail` 에 쓸 수 없다고 나왔다면 소유자를 바꾼다:
|
||||
`sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail` (sudo 로 돌릴 거라면 그대로 둔다 — 매번 같은 형태로)
|
||||
3. 셸에 `MAIL_RECOVERY_ADMIN` 이 남아 있는 채로 **같은 명령(`--recovery`)을 다시** 실행한다.
|
||||
bootstrap 이 끝났으면 재시작만 하고(config.json 으로 판단 — 임시 비밀번호 파일이 없어도 된다) 복구 관리자로 관리자 비밀번호·아이디를 맞춘다.
|
||||
4. `bootstrap 이 거부되었습니다 (primaryKeyViolation)` 가 나오면 [문제 해결](#문제-해결) 의 해당 줄을 본다.
|
||||
|
||||
#### 원래 접속 주소 확인 (배포 직후)
|
||||
|
||||
자동 차단·게이트웨이 IP 제한은 접속 주소를 믿고 동작한다. 바깥(다른 네트워크의 PC)에서 **실제 계정(본인 메일함)으로 IMAP 에 한 번 로그인**한 뒤,
|
||||
메일 엔진 로그의 `auth.success` 줄의 `remoteIp` 가 그 PC 의 공인 주소인지 본다.
|
||||
도커 내부 주소(`172.16.71.1` 같은 게이트웨이 등)로 보이면 rootless·iptables 설정을 고친다.
|
||||
|
||||
- 틀린 비밀번호로는 확인할 수 없다 — setup 이 정한 로그 수준(info)에서는 **실패한 로그인이 로그에 남지 않는다**
|
||||
(2026-10-27 Stalwart 0.16.27 에서 IMAP·HTTP 모두 확인. 성공은 `auth.success … listenerId = "imaps", remoteIp = …` 로 남는다).
|
||||
|
||||
```bash
|
||||
# 바깥 PC 에서 — 연결되면 아래 두 줄을 직접 입력한다 (명령에 비밀번호를 적지 않아 셸 기록에 남지 않는다)
|
||||
openssl s_client -quiet -connect mx1.arasystem.it.kr:993
|
||||
# a LOGIN <본인 주소> <비밀번호>
|
||||
# a LOGOUT
|
||||
# 메일 호스트에서
|
||||
sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3
|
||||
```
|
||||
- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다:
|
||||
`ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>. setup 은 운영에서 직원 계정을 만들지 않는다.
|
||||
- `MAIL_HOSTNAME` 은 bootstrap 때만 반영된다. 바꾸려면 관리 화면에서 고친다.
|
||||
- 관리자 비밀번호를 바꾸려면: env 의 `MAIL_ADMIN_PASSWORD` 변경 → 위 1)·`up -d mail` → `--recovery` 실행 → 4)·5).
|
||||
- 이미 서브넷을 고정하기 전에 만든 환경이라면 처음 한 번은 `sudo docker compose --env-file .env.production down` 후 `up -d` 로 네트워크와
|
||||
컨테이너를 함께 다시 만든다. 그냥 `up -d` 만 하면 네트워크는 새로 만들어지지만 재생성되지 않은 컨테이너가 서비스 이름(`db`·`mail` 등)
|
||||
별칭을 잃어 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 (개발 환경에서 확인 — `up -d --force-recreate` 로도 풀린다).
|
||||
|
||||
### PTR
|
||||
|
||||
메일 호스트 IP 의 역방향 DNS 를 `MAIL_HOSTNAME`(전환 기간에는 `mx1.arasystem.it.kr`)으로 설정한다
|
||||
(가비아가 아니라 **IP 를 준 호스팅사** 콘솔 또는 지원 요청).
|
||||
`A(MAIL_HOSTNAME) → IP` 와 `PTR(IP) → MAIL_HOSTNAME` 이 서로 맞고(FCrDNS), SMTP 인사말(EHLO = `MAIL_HOSTNAME`)과도 같아야
|
||||
Gmail·네이버 등이 거부하거나 스팸 처리하지 않는다.
|
||||
|
||||
### 외부 릴레이 (SES 등)
|
||||
|
||||
아웃바운드 25 가 막혀 있거나 IP 평판이 나쁠 때 외부 메일을 릴레이로 보낸다. 개발의 Mailpit 경로와 같은 방식이고 인증만 다르다.
|
||||
관리자 Basic 인증으로 `POST {관리 API}/jmap/` (`using: ["urn:ietf:params:jmap:core","urn:stalwart:jmap"]`):
|
||||
|
||||
```jsonc
|
||||
[
|
||||
["x:MtaRoute/set", { "accountId": "<세션의 urn:stalwart:jmap 계정>", "create": { "ses": {
|
||||
"@type": "Relay", "name": "ses", "description": "외부 발송 — Amazon SES",
|
||||
"address": "email-smtp.ap-northeast-2.amazonaws.com", "port": 587,
|
||||
"authUsername": "<SMTP 사용자>",
|
||||
// 비밀번호를 DB 에 두지 않고 mail 컨테이너의 환경변수에서 읽는다 → compose 의 mail.environment 에 추가
|
||||
"authSecret": { "@type": "EnvironmentVariable", "variableName": "MAIL_RELAY_PASSWORD" }
|
||||
} } }, "0"],
|
||||
["x:MtaOutboundStrategy/set", { "accountId": "<…>", "update": { "singleton": { "route": {
|
||||
"match": { "0": { "if": "is_local_domain(rcpt_domain)", "then": "'local'" } }, "else": "'ses'" } } } }, "1"],
|
||||
// 리로드 전에는 반영되지 않는다
|
||||
["x:Action/set", { "accountId": "<…>", "create": { "r": { "@type": "ReloadSettings" } } }, "2"]
|
||||
]
|
||||
```
|
||||
|
||||
- SPF 에 릴레이 쪽을 넣는다 (예: SES 는 `include:amazonses.com`). DKIM 은 Stalwart 가 보내기 전에 서명한다.
|
||||
- 포트별 TLS 방식(587 STARTTLS / 465 암묵적 TLS) 속성은 관리 화면에서 확인한다. **이 예시는 실제 SES 로 검증하지 않았다.**
|
||||
|
||||
### DNS 리졸버와 스팸 차단 목록 (DNSBL)
|
||||
|
||||
스팸 필터는 기본으로 외부 차단 목록(DNSBL) 18개를 DNS 로 조회한다 — Spamhaus(`zen` IP · `dbl` 도메인), SURBL, URIBL, Barracuda, SpamCop,
|
||||
DNSWL, Mailspike, SenderScore 등 (목록: 관리 화면 Settings › Spam Filter › DNSBL, 또는 `x:SpamDnsblServer/query`).
|
||||
이 목록들은 Stalwart 와 **별개 서비스라 각자 이용 조건이 있다.**
|
||||
|
||||
1. **메일 서버 전용 재귀 DNS 를 둔다.**
|
||||
- 공용·ISP DNS(KT `168.126.63.1`, `8.8.8.8`, `1.1.1.1` 등)로 조회하면 Spamhaus 가 거부하고 오류 코드 `127.255.255.254` 를 돌려준다.
|
||||
2026-10-26 개발 환경(KT DNS)에서 항상 등록돼 있는 시험 주소를 조회해 이 오류 코드가 오는 것을 확인했다.
|
||||
- 전용 재귀 리졸버(unbound 등)를 메일 호스트나 같은 compose 네트워크에 두고, Stalwart 의 DNS 리졸버 설정을 그쪽으로 바꾼다
|
||||
(관리 화면 Settings › Network › DNS › DNS Resolver. 관리 API 로는 `x:DnsResolver` — 지금은 `@type: System`, 다른 유형의 필드 이름은 관리 화면에서 확인).
|
||||
- DNSSEC 검증도 되어 기동 로그의 "DNS resolver cannot validate DNSSEC. DANE has been disabled" 경고가 사라진다.
|
||||
2. **Spamhaus 이용 조건을 맞춘다.**
|
||||
- 공개 목록(public mirror)은 소규모·비상업용이다. 회사 메일 서버는 **무료 DQS 키**(사용량 한도 안)를 발급받아
|
||||
Spamhaus 두 항목(`STWT_RBL_SPAMHAUS_IP`, `STWT_DBL_SPAMHAUS_DOMAIN`)의 조회 주소를 DQS 주소로 바꾸거나,
|
||||
조건이 맞지 않으면 두 항목을 끈다 (`x:SpamDnsblServer/set` 으로 `enable: false` → `ReloadSettings`).
|
||||
- 나머지 목록도 운영 전에 각 서비스의 이용 조건(상업적 사용, 조회량 한도, 사전 등록 필요 여부)을 확인하고 맞지 않는 것은 끈다.
|
||||
3. **조회 거부 응답의 처리를 시험한다 (미검증).**
|
||||
`127.255.255.254` 같은 "조회 거부" 응답을 "목록에 있음"으로 처리하면 정상 메일까지 스팸 점수를 받는다는 사용자 보고가 있다(Stalwart 공식 확인 아님).
|
||||
운영 전에 외부에서 시험 메일을 받아 스팸 판정 기록에 Spamhaus 항목이 잘못 붙지 않는지 확인한다.
|
||||
|
||||
```bash
|
||||
# 리졸버가 Spamhaus 에 막혀 있는지 확인 — 127.0.0.2 는 항상 등록된 시험 주소다.
|
||||
# 정상이면 127.0.0.x 가 돌아오고, 127.255.255.254 면 그 리졸버는 막혀 있다.
|
||||
sudo docker exec "${PROJECT_NAME}-backend" nslookup 2.0.0.127.zen.spamhaus.org <리졸버 IP>
|
||||
```
|
||||
|
||||
참고: [Spamhaus — 공용 DNS 로 무료 목록 쓰기](https://www.spamhaus.com/resource-center/successfully-accessing-spamhauss-free-block-lists-using-a-public-dns/),
|
||||
[Spamhaus — KT 경유 조회 안내](https://submit.spamhaus.org/resources/query-the-legacy-dnsbls-via-korea-telecom/),
|
||||
[Stalwart DNSBL 문서](https://www.stalw.art/docs/spamfilter/dnsbl)
|
||||
|
||||
---
|
||||
|
||||
## arasystem.it.kr 전환 절차 (mailcow → Stalwart)
|
||||
|
||||
현재 (DNS: 가비아):
|
||||
|
||||
| 레코드 | 값 |
|
||||
| --- | --- |
|
||||
| `arasystem.it.kr` MX | `10 mail.arasystem.it.kr` (mailcow) |
|
||||
| `arasystem.it.kr` TXT (SPF) | `v=spf1 mx ip4:46.250.248.43 -all` |
|
||||
| `_dmarc.arasystem.it.kr` TXT | `v=DMARC1; p=none; rua=mailto:dmarc@arasystem.it.kr` |
|
||||
| `dkim._domainkey.arasystem.it.kr` TXT | mailcow DKIM (selector `dkim`) |
|
||||
|
||||
원칙:
|
||||
|
||||
- **메일박스를 먼저 만들고 MX 를 바꾼다.** 거꾸로면 없는 주소로 들어온 메일이 반송(550)된다.
|
||||
- 새 엔진은 **새 호스트명(`mx1.arasystem.it.kr`)** 으로 띄우고 **MX 대상만** 바꾼다.
|
||||
`mail.arasystem.it.kr` 은 끝까지 mailcow 를 가리키므로, MX 만 되돌리면 롤백된다.
|
||||
- SPF 가 `-all` 이다. **새 서버 IP 를 SPF 에 넣기 전에** 새 서버에서 외부로 보내면 수신 측이 거부하거나 스팸 처리한다.
|
||||
|
||||
| 단계 | 할 일 | 확인 |
|
||||
| --- | --- | --- |
|
||||
| 0. TTL 낮추기 (D-3 이상) | MX·SPF TXT·`mx1` A 의 TTL 을 300초로. **기존 TTL 만큼** 기다린 뒤 다음 단계 | `dig +noall +answer MX arasystem.it.kr` 의 TTL |
|
||||
| 1. 새 호스트 | `mx1.arasystem.it.kr` A → 새 IP, PTR → `mx1.arasystem.it.kr`, `.env.production` 에 `MAIL_HOSTNAME`·`MAIL_PUBLIC_URL` = mx1, [운영 배포](#운영-배포) | 컨테이너 로그에 인증서 발급, `openssl s_client -connect mx1.arasystem.it.kr:993` |
|
||||
| 2. 같은 주소 생성 | 모든 직원 주소·별칭·공용 주소 + **`dmarc@`**(현재 DMARC 리포트 수신처) + `postmaster@`(Stalwart 기본 리포트 수신처) | 관리 화면 계정 목록 |
|
||||
| 3. DKIM selector 추가 | setup 이 출력한 `v1-ed25519-YYYYMMDD._domainkey`·`v1-rsa-YYYYMMDD._domainkey` TXT 를 **추가**. mailcow 의 `dkim._domainkey` 는 그대로 둔다 (selector 가 달라 공존) | `dig TXT v1-rsa-YYYYMMDD._domainkey.arasystem.it.kr` |
|
||||
| 4. SPF 갱신 | `v=spf1 mx ip4:46.250.248.43 ip4:<새 IP> -all` (+ 권장: `mx1.arasystem.it.kr TXT "v=spf1 a -all"`) — **MX 변경·외부 발송 전에** | 새 서버에서 외부(본인 Gmail 등)로 1통 → 헤더의 `spf=pass dkim=pass` |
|
||||
| 5. 메일 이전 1차 | `imapsync` 로 mailcow → Stalwart 전체 동기화 (아래) | 폴더별 메일 수 비교 |
|
||||
| 6. MX 전환 | `arasystem.it.kr MX 10 mx1.arasystem.it.kr` 로 교체. mailcow 는 **계속 켜 둔다** (전파 중에는 mailcow 로도 들어온다) | 외부에서 보낸 메일이 새 엔진 로그에 수신 |
|
||||
| 7. 증분 동기화 | MX 전환 직후 · TTL 경과 후 · 정리 직전에 `imapsync` 를 다시 (이미 옮긴 메일은 건너뛴다) | mailcow 로그에 새 수신 없음 |
|
||||
| 8. 병행 1~2주 후 정리 | SPF 에서 `ip4:46.250.248.43` 제거, `dkim._domainkey` 삭제, mailcow 중지. 필요하면 `mail.arasystem.it.kr` 을 새 호스트로 옮기고(인증서 포함) `MAIL_PUBLIC_URL` 변경 | — |
|
||||
|
||||
롤백: MX 를 `10 mail.arasystem.it.kr` 로 되돌린다. 그 사이 새 엔진에 들어온 메일은 반대 방향 `imapsync` 로 옮긴다.
|
||||
|
||||
주의할 레코드:
|
||||
|
||||
- **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고,
|
||||
리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다.
|
||||
- **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다.
|
||||
- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다.
|
||||
- **DKIM 자동 교체** — 도메인 설정이 `dkimManagement: Automatic`(90일마다 새 selector, 7일 뒤 이전 키 은퇴)이고 DNS 는 수동(가비아)이다.
|
||||
교체된 키의 TXT 가 DNS 에 없으면 서명 검증이 실패하므로, **운영 전에 관리 화면에서 교체 방식을 확인**하고
|
||||
교체 때마다 새 selector 를 등록하거나 자동 교체를 끈다. (교체 시점의 동작은 확인하지 않았다)
|
||||
|
||||
### imapsync
|
||||
|
||||
```bash
|
||||
imapsync \
|
||||
--host1 mail.arasystem.it.kr --ssl1 --user1 '<직원 주소><구분자><마스터 계정>' --password1 '<마스터 비밀번호>' \
|
||||
--host2 mx1.arasystem.it.kr --ssl2 --user2 '<직원 주소>' --password2 '<새 엔진 초기 비밀번호>' \
|
||||
--automap --syncinternaldates
|
||||
```
|
||||
|
||||
- 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과
|
||||
로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다).
|
||||
- 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다.
|
||||
게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다.
|
||||
- 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나,
|
||||
mailcow 의 비밀번호 해시를 그대로 옮길 수 있는지 검토한다 (미검증).
|
||||
|
||||
---
|
||||
|
||||
## 백업
|
||||
|
||||
| 볼륨 | 내용 |
|
||||
| --- | --- |
|
||||
| `<PROJECT_NAME>_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** |
|
||||
| `<PROJECT_NAME>_mail-data` | RocksDB — 메일·계정·설정 전체 |
|
||||
|
||||
RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다.
|
||||
멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
|
||||
|
||||
```bash
|
||||
sudo docker compose --env-file .env.production stop mail
|
||||
sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_NAME}_mail-data:/data:ro" -v "$PWD/backup:/backup" \
|
||||
alpine tar czf "/backup/mail-$(date +%Y%m%d-%H%M).tar.gz" -C / config data
|
||||
sudo docker compose --env-file .env.production start mail
|
||||
```
|
||||
|
||||
- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다.
|
||||
- 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다.
|
||||
|
||||
---
|
||||
|
||||
## 보안
|
||||
|
||||
- **게이트웨이 비밀번호 = 관리자급 비밀.** 전 직원 메일함의 열쇠일 뿐 아니라, **관리자 계정도 대리 접근된다** —
|
||||
`<관리자 주소>%<게이트웨이 주소>` 로 로그인하면 관리 API(`x:Account/get` 등)가 열린다 (백엔드 확인, setup 이 매번 `[주의]` 로 보여 준다).
|
||||
- Stalwart 0.16.27 에는 대리 접근 대상을 제한하는 방법이 없다 (2026-10-27 확인). 역할 권한(`enabledPermissions`)은 접속하는 쪽의 권한만 정하고,
|
||||
테넌트로 나누면 테넌트에 든 게이트웨이는 `impersonate` 권한 자체를 잃는다 — 테넌트 권한을 `Merge`·`Replace` 로 주거나 계정에 직접 줘도
|
||||
모든 대리 접근이 403(`security.unauthorized details = "impersonate"`) 이었다.
|
||||
- 그래서 막는 일은 백엔드가 한다 — 관리자(`MAIL_ADMIN_LOCAL_PART`)·`MAIL_BLOCKED_ACCOUNTS` 계정은 웹메일 로그인·대리 접근 대상에서 뺀다
|
||||
(+ 대리 접근한 세션에 관리 권한이 있으면 거부하는 2차 확인). 관리자 계정을 더 만들면 `MAIL_BLOCKED_ACCOUNTS` 에 넣는다 (빠지면 setup 이 `[주의]`).
|
||||
- **지킬 것**: 백엔드 env(루트 `.env.production`)에만 둔다 · 다른 곳에 재사용하지 않는다(관리자·DB 비밀번호와도 다르게) · 무작위로 길게 ·
|
||||
정기 교체 · 게이트웨이 허용 IP(신뢰 대역 = `APP_NETWORK_SUBNET`)를 넓히지 않는다 — 이 제한이 바깥에서의 사용을 막는 유일한 장치다.
|
||||
- **교체 절차**: `.env.production` 의 `MAIL_GATEWAY_PASSWORD` 를 새 무작위 값으로 → `node --env-file=.env.production mail/scripts/setup.mjs`
|
||||
(운영 배포 3 과 같은 형태 — `[변경] 게이트웨이 … 비밀번호`) → `sudo docker compose --env-file .env.production up -d backend` (새 값으로 다시 만든다).
|
||||
setup 과 backend 재생성 사이에는 웹메일이 `MAIL_001` 로 잠시 멈춘다 — 사용이 적은 시간에 한다. 사용 기록은 `auth.success` 로그에 남는다.
|
||||
- **신뢰 대역 = 고정한 앱 네트워크 서브넷.** compose 가 app-network 서브넷을 `APP_NETWORK_SUBNET`(기본 `172.16.71.0/24`)으로 고정하고,
|
||||
setup 은 그 안의 대역만 신뢰 대역으로 받는다 (더 넓으면 실패). 예전처럼 도커 주소 풀 전체(172.16.0.0/12·192.168.0.0/16)를 믿지 않는다.
|
||||
app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만 붙인다 — **인터넷을 향한 frontend 는 edge 네트워크로만 backend 에 닿는다**
|
||||
(frontend 가 붙어 있으면 그 주소가 신뢰 대역에 들고 `mail:8080` 관리 API 에 닿는다. setup 이 구성원을 확인한다).
|
||||
- **8080 은 절대 공개하지 않는다** — 평문 HTTP 관리 API·JMAP 이다.
|
||||
- **공개 443 은 ACME·MTA-STS 만** — setup 이 `x:Http` 싱글톤의 `allowedEndpoints` 를
|
||||
`listener == 'https' && path != '/.well-known/mta-sts.txt'` → 403 으로 둔다. 웹메일은 백엔드 → 내부 `http`(8080)라 영향이 없다.
|
||||
- 2026-10-26 확인: https 의 `/jmap/session`·`/.well-known/jmap`·`/admin/`·`/account/`·`/login`·OAuth 디스커버리·`/healthz` 는 403,
|
||||
`/.well-known/mta-sts.txt` 는 200, 내부 8080 은 그대로. 호스트·다른 컨테이너·AllowedIp 에 든 주소에서 와도 403 이다.
|
||||
- 식의 변수는 `listener`·`path`(그 밖에 `url`·`method`·`remote_ip`·`local_ip`·`local_port`·`protocol`·`headers`·`is_tls`)가 통하고
|
||||
`url_path`·`host` 등은 저장할 때 거부된다. 바꾼 뒤에는 `ReloadSettings` 가 있어야 반영된다.
|
||||
- ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다.
|
||||
확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다.
|
||||
- ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다).
|
||||
- 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>.
|
||||
직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다.
|
||||
- **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한
|
||||
(`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영).
|
||||
- 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작.
|
||||
- ⚠️ 되돌리기는 **일반 관리자로는 안 된다** — 관리자 역할(System Administrator)에 이 권한이 없어 "You are not authorized to grant permissions" 로 거부된다.
|
||||
복구 관리자(일회성)로 `x:Role/set` `update {"<User 역할 id>": {"enabledPermissions/jmapPrincipalGet": true, …}}` → `InvalidateCaches` 를 한다.
|
||||
(setup 은 다음 실행 때 다시 뺀다 — 계속 필요하면 `ensureUserRoleWithoutPrincipals` 단계를 먼저 고친다)
|
||||
- **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고
|
||||
일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5).
|
||||
- **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는
|
||||
도커 게이트웨이 주소로 보인다 → 신뢰 대역으로 취급되어 자동 차단 예외와 게이트웨이 IP 제한이 무력화된다.
|
||||
IPv6 수신이 필요하면 Docker 데몬의 IPv6(ip6tables)를 켜 원본 주소가 보존되는 것을 확인한 뒤 바인딩을 추가한다.
|
||||
- `X-Forwarded-For` 는 기본 설정에서 신뢰하지 않는다 (`x:Http` 의 `useXForwarded: false` — 위조 헤더로 IP 판정이 바뀌지 않음을 검증).
|
||||
프록시 뒤에서 이를 켤 때는 프록시 주소로 한정해야 한다.
|
||||
- 신뢰 대역은 자동 차단에서 빠진다 → 무차별 대입 방어는 백엔드 rate limit 이 담당한다.
|
||||
- 비밀번호는 영문·숫자로만 — `#`·따옴표·`$`·공백은 compose 와 Node(`--env-file`, `#` 부터 주석)가 다르게 읽는다.
|
||||
|
||||
---
|
||||
|
||||
## 리버스 프록시와 같은 호스트에 둘 때 (대안)
|
||||
|
||||
기본 구성은 Stalwart 가 호스트 443 을 직접 쓴다. 같은 호스트의 리버스 프록시가 이미 443 을 쓰면 아래 중 하나를 택한다. (문서로만 정리 — 검증하지 않았다)
|
||||
|
||||
1. **IP 를 하나 더 붙인다 (가장 단순)** — 메일 포트를 그 IP 에만 바인딩: `"<메일 전용 IP>:443:443"` 등. 나머지는 그대로.
|
||||
2. **SNI 패스스루** — L4 프록시(nginx `stream` + `ssl_preread`, HAProxy, Traefik TCP)가 SNI 가 `mx1.arasystem.it.kr` 인 TLS 를
|
||||
복호화하지 않고 mail 의 443 으로 넘긴다. Stalwart 가 TLS 와 ACME(TLS-ALPN-01)를 그대로 처리한다. compose 에서 mail 의 443 매핑을 뺀다.
|
||||
- ⚠️ **mail 과 L4 프록시만 붙는 전용 네트워크**를 새로 만든다 (예: `<PROJECT_NAME>_mail-ingress`, 서브넷 고정, 다른 컨테이너를 붙이지 않는다).
|
||||
**함께 쓰는 `proxy` 네트워크나 app-network 에 mail 을 붙이지 않는다** — 그 네트워크의 모든 컨테이너가 `mail:8080`(평문 관리 API·JMAP)에 닿는다.
|
||||
- 프록시의 대상은 서비스 이름(`mail`)이 아니라 `<PROJECT_NAME>-mail:443` 으로 적는다 (다른 프로젝트의 `mail` 과 겹치지 않게).
|
||||
```nginx
|
||||
stream {
|
||||
map $ssl_preread_server_name $upstream {
|
||||
mx1.arasystem.it.kr <PROJECT_NAME>-mail:443; # 전용 네트워크로만 닿는다
|
||||
default 127.0.0.1:8443; # 기존 웹 TLS 종단
|
||||
}
|
||||
server { listen 443; ssl_preread on; proxy_pass $upstream; }
|
||||
}
|
||||
```
|
||||
3. **프록시가 TLS 종단 → `mail:8080`** — 993·465 용 인증서는 여전히 Stalwart 가 받아야 하므로 ACME 를 HTTP-01 등으로 바꿔야 한다.
|
||||
|
||||
⚠️ 2·3 은 Stalwart 에 **프록시 주소가 접속 주소로 보인다.** 프록시를 app-network(신뢰 대역 `APP_NETWORK_SUBNET`)에 두면
|
||||
인터넷 접속이 신뢰 대역으로 취급되어 자동 차단·게이트웨이 IP 제한이 무력화된다(setup 의 구성원 확인에서도 걸린다). 프록시는 반드시 app-network 밖(위의 전용 네트워크)에 두고,
|
||||
PROXY protocol(2 — 리스너별 `overrideProxyTrustedNetworks`, 검증 필요) 또는 그 프록시로만 한정한 `X-Forwarded-For`(3)로 원본 주소를 넘긴다.
|
||||
3 은 공개 443 정책(`listener == 'https'`)이 `http` 리스너에는 걸리지 않으므로 프록시 쪽에서 `/jmap`·관리 경로를 따로 막아야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 문제 해결
|
||||
|
||||
| 증상 | 조치 |
|
||||
| --- | --- |
|
||||
| `셸 환경변수가 env 파일 값을 가리고 있습니다` | 표시된 변수를 지우고 다시 (`unset APP_ENV` · PowerShell `Remove-Item Env:APP_ENV`) |
|
||||
| `메일 엔진에 연결할 수 없습니다` | `COMPOSE_PROFILES=mail` 로 떠 있는지, `MAIL_ADMIN_PORT` 가 맞는지. 운영은 `MAIL_ADMIN_URL` 로 컨테이너 내부 주소를 준다 ("운영 배포" 3) |
|
||||
| `운영에서는 docker 를 쓸 수 있어야 합니다` | docker 그룹 사용자로, 또는 `sudo -E env "PATH=$PATH" node …` 로 호스트에서 실행 ("운영 배포" 3). 아무것도 바뀌지 않았다 |
|
||||
| `임시 비밀번호 파일 자리(…/data/mail)에 쓸 수 없어 bootstrap 을 시작하지 않고 중단합니다` | 아무것도 바뀌지 않았다. `data/`·`data/mail/` 을 실행하는 사용자 소유로 (`sudo chown "$(id -u):$(id -g)" data && sudo chown -R "$(id -u):$(id -g)" data/mail`) 또는 늘 `sudo -E` 로 — 같은 명령 다시 |
|
||||
| bootstrap 직후 멈춤 (`bootstrap 은 끝났지만 임시 비밀번호를 보관하지 못했습니다` 등) | [bootstrap 직후에 멈췄다면](#bootstrap-직후에-멈췄다면-다시-실행하기-전에) — 볼륨을 지우지 말고 원인을 고친 뒤 같은 명령(`--recovery`) 다시. config.json 으로 판단해 재시작만 한다 |
|
||||
| `재시작 후에도 bootstrap 모드입니다 (config.json 은 이미 있음)` | 설정 파일이 있는데 엔진이 bootstrap 모드로 떴다 — `docker logs "${PROJECT_NAME}-mail"` 의 기동 오류를 본다. `Bootstrap/set` 을 다시 부르지 않는다 |
|
||||
| `bootstrap 이 거부되었습니다` + `primaryKeyViolation` | 데이터 볼륨에는 이전 설치가 있는데 설정 볼륨에 config.json 이 없다 (예: `_mail-config` 만 지움). **새 설치**면 두 볼륨을 다 지우고 처음부터 (`docker volume rm "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data"`), **운영 중이던 서버**면 백업에서 `_mail-config` 를 복원한다 |
|
||||
| `사전 확인에 실패해 메일 엔진 설정을 바꾸지 않고 중단합니다` | 아래 줄의 항목을 고치고 다시 — 엔진 설정은 그대로다 |
|
||||
| `앱 네트워크 … 에 신뢰 대역에 있으면 안 되는 컨테이너가 붙어 있습니다` | `docker network disconnect "${PROJECT_NAME}_app-network" <컨테이너>`, compose 의 `networks` 확인 (frontend 는 edge 만). 개발은 경고만 |
|
||||
| `APP_NETWORK_SUBNET(…) — 접두 길이는 /24 ~ /28 …` · `네트워크 주소로 …` · `사설 대역 …` | 사설 대역의 네트워크 주소, `/24`~`/28` 로 (예: `172.16.71.0/24`). 바꾸면 `down` 후 `up -d` 로 네트워크를 다시 만든다 |
|
||||
| `메일 엔진의 관리자(admin@…)와 MAIL_ADMIN_LOCAL_PART(…) 가 다릅니다` | bootstrap 뒤에 `MAIL_ADMIN_LOCAL_PART` 만 바꿨다 — **bootstrap 전에만 바꿀 수 있다.** (1) env 를 엔진 관리자의 아이디로 되돌리고 `up -d backend` (가장 간단). (2) 꼭 바꿔야 하면: 복구 관리자로 엔진의 관리자 계정 아이디를 바꾼다(관리 화면 › 계정, 또는 `x:Account/set` `update {"<id>": {"name": "<새 아이디>"}}` — 주소가 함께 바뀐다) → env 를 같은 값으로 → `up -d backend` → setup 재실행 |
|
||||
| `…(MAIL_ADMIN_LOCAL_PART)로 로그인은 되지만 관리 권한이 없습니다` · `… 는 메일 엔진의 관리자 계정이 아닙니다` | `MAIL_ADMIN_LOCAL_PART` 가 직원 계정을 가리킨다 — 엔진의 관리자 아이디로 맞춘다 (메시지에 엔진의 관리자 목록이 나온다) |
|
||||
| `… 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP 429·5xx)` | 비밀번호를 덮어쓰지 않으려고 멈췄다. 429 면 몇 분 뒤, 5xx 면 `docker logs` 로 엔진 상태를 본 뒤 다시 |
|
||||
| `[주의] 게이트웨이로 관리자 … 를 대리 접근하면 관리 API 가 열립니다` | 알려진 남은 위험이다 (Stalwart 0.16 은 막을 수 없다) — [보안](#보안)의 게이트웨이 비밀번호 수칙을 지킨다. 실패가 아니다 |
|
||||
| `[주의] 관리자 역할 계정 … 가 백엔드 차단 목록에 없습니다` | 그 주소를 `MAIL_BLOCKED_ACCOUNTS` 에 넣고 `up -d backend`, 쓰지 않는 관리자라면 지운다 |
|
||||
| `관리자로 로그인할 수 없습니다` | 개발: `MAIL_RECOVERY_ADMIN` 을 채워 `up -d mail` 후 setup 재실행. 운영: "운영 배포" 1)·`up -d mail` → `--recovery` → 4)·5) |
|
||||
| `[재시작 필요]` · `[후속 조치]` (종료 코드 3) | 안내대로 한 뒤 같은 명령으로 다시 (운영 `--recovery` 뒤에는 `--recovery` 없이) |
|
||||
| `MAIL_TRUSTED_NETWORKS 의 … 가 APP_NETWORK_SUBNET(…) 보다 넓거나 그 밖에 있습니다` | `MAIL_TRUSTED_NETWORKS` 를 비우거나(= 서브넷) 서브넷 안의 더 좁은 대역으로 |
|
||||
| `앱 네트워크 … 의 서브넷(…)이 APP_NETWORK_SUBNET(…) 과 다릅니다` | 서브넷을 고정하기 전에 만든 네트워크 — `down` 후 `up -d` 로 다시 만들고 setup 재실행 |
|
||||
| 서브넷을 바꾼 뒤 백엔드가 `getaddrinfo ENOTFOUND db` 로 뜨지 않는다 | 네트워크만 새로 만들어지고 다른 컨테이너가 서비스 이름 별칭을 잃었다 — `up -d --force-recreate` (또는 `down` 후 `up -d`) |
|
||||
| `운영 env 파일에 MAIL_RECOVERY_ADMIN 가 들어 있습니다` 등 복구 관리자 오류 | 파일에서 지우고 셸에서 일회성으로만 준다 ("운영 배포") |
|
||||
| `… 에 복구 관리자(STALWART_RECOVERY_ADMIN)가 남아 있습니다` | `unset MAIL_RECOVERY_ADMIN` 후 `docker compose --env-file .env.production up -d mail` |
|
||||
| `백엔드 주소 … 가 신뢰 대역(…) 밖입니다` | `MAIL_TRUSTED_NETWORKS` 를 비우고(= `APP_NETWORK_SUBNET`) setup 재실행 |
|
||||
| `공개 443 차단 확인 실패` | setup 이 `ReloadSettings` 까지 했는지, `x:Http` 의 `allowedEndpoints` 가 위 식인지 (관리 화면 › Settings › HTTP) |
|
||||
| `Password is too weak` | 더 길고 무작위인 값으로 (예: `change-me-in-local-env` 는 통과, `devpassword1` 은 거부) |
|
||||
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*` |
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user