feat(docker): 12단계 계정 보안 — 엔진 웹훅 · /api/internal 닫기 · 요청 대상 400

메일 프로그램(IMAP · SMTP) 로그인 기록을 엔진 웹훅으로 받는다.
- 루트 env MAIL_WEBHOOK_SECRET(setup · backend 공용, 운영 필수)과 backend 의 APP_NETWORK_SUBNET
- setup 이 엔진 웹훅을 맞추고(표시 머리글 — 소금 + 키 지문) 서명한 빈 묶음으로 받는 곳을 확인한다
  키를 바꾼 실행 · 게이트웨이 OTP · 테스트 직원의 402 를 따로 다룬다
- nginx 두 server 가 /api/internal/ 을 닫고, 요청 대상의 날 \(경로) · # · 0x80 이상은 location 전에 400
  Express 가 이런 대상을 다시 읽어 닫은 경로(/api/internal · /api/health)가 백엔드에서 열렸다(2026-10-31 리뷰)
- CI "닫는 경로 시험"(두 server × 세 경로의 변형) · 지시어만 세는 정적 검사
- mail/README: 웹훅 · 12단계 전부터 운영 중인 서버를 올리는 차례 · 휴대폰을 잃은 직원(관리 API 명령)
엔진 DNS 가 Custom(운영)이어도 웹훅은 backend 를 푼다 — 메일 컨테이너가 도커 DNS 를 쓴다(개발에서 확인).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 17:45:23 +09:00
parent c31442e639
commit d1ef0da488
11 changed files with 739 additions and 19 deletions
+175 -2
View File
@@ -136,6 +136,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 ([엔진 한도](#엔진-한도) "임시 업로드 한도").
**엔진 DNS · Spamhaus** — 엔진의 DNS(`x:DnsResolver`)를 메일 전용 리졸버로(운영은 리졸버에 바로 `Custom`, 개발은 도커 DNS 를 거쳐 `System` — Mailpit 이름 때문),
스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
**엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:<BACKEND_PORT>/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는
`x:WebHook` 하나를 맞춘다(setup 표시 머리글로 찾는다 — 키가 비면 운영은 중단, 개발은 `[주의]` 뒤 있던 것을 끈다 — [엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)).
모두 9 의 리로드로 반영된다
9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다.
운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 —
@@ -143,7 +145,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
게이트웨이로 관리자를 대리 접근해 관리 API 가 열리는지도 데이터 없는 호출로 본다 — 열리면 매번 `[주의]` ([보안](#보안)).
시스템 계정의 RCPT 도 메일 컨테이너 안에서 자기 네트워크 주소의 25 로 보내 550 인지 본다(RCPT 까지만 — 메일은 보내지 않는다).
보낸 사람은 빈 봉투(`MAIL FROM:<>`)다 — 사내 도메인 밖이라 시범 공존의 MAIL FROM 위조 막기에 걸리지 않고, 어긋나 DATA 까지 가더라도 반송이 생기지 않는다.
엔진의 이번 기동 로그에 "DNS resolver cannot validate DNSSEC"(DANE 꺼짐) 경고가 없는지도 본다 — 리로드는 이 경고를 남기지 않아 기동 로그만 볼 수 있다
엔진의 이번 기동 로그에 "DNS resolver cannot validate DNSSEC"(DANE 꺼짐) 경고가 없는지도 본다 — 리로드는 이 경고를 남기지 않아 기동 로그만 볼 수 있다.
엔진 웹훅은 리로드 뒤 다시 읽어 맞는지 보고(`[확인] 엔진 웹훅 켜짐`), 메일 컨테이너 안에서 서명한 빈 묶음을 받는 곳에 보내 200 인지 본다
(`[확인] 엔진 웹훅 — … 받았다` — 401 · 404 는 운영은 실패, 개발은 `[주의]`. 이번 실행에서 엔진에 키를 넣었으면 401 도 `[주의]` 와 다음 명령 — [엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록))
10. **[개발] 외부 발송 경로** — Relay `mailpit:1025` + 발송 전략(사내 도메인은 로컬, 나머지는 mailpit) — 9 의 리로드로 반영된 것을 확인한 뒤에만 11
(7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 그보다 먼저 만들어도 된다)
11. **[개발] 테스트 직원 계정** → 게이트웨이 대리 접근, 직원의 계정 목록 조회 차단(`Principal/get` → forbidden)까지 자체 확인
@@ -164,6 +168,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
(넓을수록 사무실 LAN·VPN 같은 실제 클라이언트 주소와 겹친다. `/28` 보다 좁으면 컨테이너 주소가 모자란다).
- `MAIL_ADMIN_LOCAL_PART` 는 백엔드와 같은 형식 규칙(영문·숫자·`.`·`_`·`-`, 앞뒤는 영문·숫자, 64자 이하, 대소문자 무시)이다.
로그인 확인은 2xx 만 "맞음", 401 만 "틀림"으로 보고 그 밖(429·5xx)은 중단한다 — 비밀번호를 잘못 덮어쓰지 않게.
402(비밀번호는 맞고 그 계정에 2단계 인증이 켜져 있음 — 12단계)를 "맞음"으로 보는 것은 **테스트 직원뿐**이다 — 웹메일에서 2단계 인증을 켠 테스트 직원의 비밀번호를
덮어쓰지 않고, 그 직원의 계정 목록 비공개 확인은 게이트웨이 대리 접근으로, 시범 공존의 인증 발송 확인은 2단계가 꺼진 직원으로 본다(모두 켜져 있으면 `[건너뜀]`).
**게이트웨이가 402 면 아무것도 바꾸기 전에 멈춘다** — 백엔드의 대리 접근이 게이트웨이 자신의 2단계 인증에 걸려 모두 막히고 웹메일 전체가 선다.
관리자 · 복구 관리자에 2단계 인증이 켜져 있어도 멈춘다(비밀번호만으로 관리 API 에 들어가지 못한다).
- 운영에서는 `change-me` 예시 값을 거부하고, `MAIL_PUBLIC_URL` 이 경로 없는 `https://` 가 아니면 실패, 발송 전략이 Mailpit 을 가리키면 실패로 보고한다.
- 운영의 복구 관리자 규칙 — env 파일에 있으면 실패, 셸에 있는데 `--recovery` 가 없으면 실패,
`--recovery` 실행은 끝에 "복구 관리자 끄기"를 안내하고 종료 코드 3, 그 뒤 일반 실행은 mail 컨테이너에 복구 관리자가 남아 있으면 실패.
@@ -180,6 +188,8 @@ node --env-file=.env.development mail/scripts/setup.mjs
| `MAIL_ATTACHMENT_MAX_SIZE_MB` | 첨부 1개 최대 크기 (기본 20). 프론트 nginx 본문 한도는 이 값 + 1MB 로 자동, 외부 프록시는 직접 — [docker 스킬](../.claude/skills/docker/SKILL.md) "운영 환경" |
| `MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB` | 메일 한 통의 첨부 합계 (기본 25, 파일당 한도 이상). setup 이 엔진의 임시 업로드 크기 합계를 이 값의 20배 이상으로 맞추므로 첨부 한도를 바꾸면 setup 도 다시 실행한다 ([엔진 한도](#엔진-한도)) |
| `MAIL_DIRECTORY_LOCAL_PART` | 사내 목록 계정 아이디 (기본 `mail-directory`) — setup 과 같은 값을 읽는다 ([사내 목록 계정](#사내-목록-계정)) |
| `MAIL_WEBHOOK_SECRET` | 엔진 웹훅의 서명 키(12단계 — 영문 · 숫자 32자 이상, 운영 필수) — setup 이 같은 값으로 엔진 웹훅을 만든다. 바꾸면 setup 과 backend 를 함께 ([엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)) |
| `APP_NETWORK_SUBNET` | 앱 네트워크 대역 (기본 `172.16.71.0/24`) — 웹훅 받는 쪽이 접속 주소(소켓)가 이 대역인지 본다 |
- 로그인 확인: `GET {MAIL_JMAP_URL}/jmap/session` + Basic `<직원 주소>:<비밀번호>` → 200 / 401
- 메일함 접근: Basic `<직원 주소>%<MAIL_GATEWAY_USER>` : `MAIL_GATEWAY_PASSWORD`
@@ -269,6 +279,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
`CLAMAV_HOST=clamav`(둘 다 운영 필수 — 없으면 백엔드가 뜨지 않는다), 그 호스트의 DNS · 외부 프록시 Proxy Host
([대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트)), 악성 검사 서명 갱신의 바깥 HTTPS(`database.clamav.net`) ·
메모리 · 디스크([메모리 예산](#메모리-예산) — 8GB 서버에 스택 전체 + ClamAV). 첫 `up` 은 `clamav-updater` 가 서명을 받는 동안(1~3분) `clamav` 가 기다린다
- **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다).
운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다).
직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라))
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
@@ -313,6 +326,14 @@ node --env-file=.env.production mail/scripts/setup.mjs
([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
**리졸버 전에 띄운 서버**(이 기능 전 배포)는 `sudo docker compose --env-file .env.production up -d`(mail 이 다시 만들어진다 — 몇 초 끊긴다) 뒤 5)와 같은 형태로 setup 을 한 번 돌린다.
`up` 이 `optional dependency "mail-resolver" failed to start` 경고를 내면 mail 은 떴지만 리졸버가 unhealthy · 멈춤이다 — [리졸버 장애 때](#리졸버-장애-때).
- **엔진 웹훅(12단계)** — 5)의 출력에 `[유지] 엔진 웹훅 → http://backend:3000/api/internal/mail-engine-events …`(처음은 `[생성]`), `[확인] 엔진 웹훅 켜짐 — …`,
`[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP 200 …)` 가 있어야 한다. 3)은 백엔드가 아직 뜨는 중이면 마지막 줄이 `[주의] … 닿지 못했습니다` 일 수 있다(5 에서 본다) —
나머지 확인은 [계정 보안 — 운영 확인](#운영-확인-12단계)
- **12단계 전부터 운영 중인 서버** — 차례가 중요하다(키보다 새 이미지를 먼저 띄우면 백엔드가 env 검사에서 멈춰 웹메일 전체가 선다):
1. `.env.production` 에 `MAIL_WEBHOOK_SECRET` 을 넣는다 — `printf 'MAIL_WEBHOOK_SECRET=%s\n' "$(openssl rand -hex 24)" >> .env.production`(값은 화면에 찍지 않는다. 파일 끝에 줄바꿈이 없으면 앞 줄에 붙으니 먼저 `tail -c1 .env.production | xxd` 로 `0a` 인지 본다)
2. `sudo docker compose --env-file .env.production up -d --build` — backend(마이그레이션 `AddAccountSecurity` 가 뜰 때 적용된다)와 frontend(`/api/internal/` 을 닫는 nginx 스니펫 · 잘못된 요청 대상의 400)를 함께 새로 만든다
3. backend 가 healthy 가 된 뒤(`sudo docker compose --env-file .env.production ps backend`) 3)처럼 `MAIL_ADMIN_URL` 을 구해 setup(복구 관리자 없이 — 5 와 같은 형태) → `[생성] 엔진 웹훅` · `[확인] 엔진 웹훅 — … 받았다`
4. [운영 확인 (12단계)](#운영-확인-12단계) — 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다
- 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다.
- **TLS 인증서 이름을 `MAIL_HOSTNAME` 하나로 정한다** (2026-10-31 운영에서 겪음). bootstrap 이 켠 도메인의 자동 인증서(ACME)는 이름을 정하지 않으면 기본 묶음 —
`mta-sts` · `autoconfig` · `autodiscover` · `ua-auto-config` · MX 이름 · 호스트 이름 — 을 **한 주문**으로 요청하고, ACME 는 하나라도 실패하면 전체가 실패한다.
@@ -695,7 +716,8 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
| | 포털 (`portal.` — default_server) | 공개 내려받기 (`files.` — `SHARE_PUBLIC_URL` 의 호스트 이름) |
| --- | --- | --- |
| 화면 | 웹메일 전부. `/s/…` 는 404 | `/s/<토큰>`(SPA 의 공개 라우트 — `index.html`, `no-cache` · `X-Robots-Tag: noindex, nofollow` · `Referrer-Policy: no-referrer`) · `/assets/` · `/favicon.ico` 만. 그 밖(`/` · `/index.html` · `/mail/…`)은 404 |
| API | `/api/…` 전부. `/api/public-links/…` 는 404 `RES_001`(JSON — 대소문자 변형 `/api/Public-Links/…` 도) | `/api/public-links/…` 만(대소문자를 가리지 않는다). 그 밖의 `/api/…` 는 404 `RES_001`, 1m 넘는 본문은 413 `FILE_002`(둘 다 JSON) |
| API | `/api/…` 전부. `/api/public-links/…` 는 404 `RES_001`(JSON — 대소문자 변형 `/api/Public-Links/…` · `%5C` 도) | `/api/public-links/…` 만(대소문자를 가리지 않는다). 그 밖의 `/api/…` 는 404 `RES_001`, 1m 넘는 본문은 413 `FILE_002`(둘 다 JSON) |
| 두 호스트 공통 | 원문 요청 대상에 날 `\`(경로) · `#` · 0x80 이상 바이트가 있으면 location 을 고르기 전에 400 `VAL_001`(JSON) — 백엔드가 그 꼴을 다르게 읽어 닫기를 비켜 갔다 ([계정 보안](#엔진-웹훅--메일-프로그램-로그인-기록) "잘못된 요청 대상은 400") | |
| 백엔드 | 같은 판정으로 두 겹 막는다 — `Host` 의 호스트 이름이 공개 호스트면 `/api/public-links/` 만, 아니면 그것만 404(`modules/file-sharing/public-host.ts`) | |
- `SHARE_PUBLIC_URL` 이 비었거나 그 호스트 이름이 `CORS_ORIGIN` 에 있으면 따로 두지 않는다 — 포털이 둘 다 연다(개발 · 같은 호스트 운영. `nginx-env.envsh` 가 고르고 `docker logs` 첫 줄에 남긴다).
@@ -763,6 +785,157 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne
---
## 계정 보안 (12단계 — 인프라)
2단계 인증(인증 앱 TOTP) · 앱 비밀번호 · 로그인 기록. 데이터 · 규칙은 [계약 5절](../docs/mail/api-contract.md) "계정 보안", env 는 루트 예시 env 의 `MAIL_WEBHOOK_SECRET` 이다.
엔진(0.16.27)이 코드 확인 · 앱 비밀번호를 맡고, 백엔드가 그 앞의 규칙(첫 코드 확인 · 다시 쓴 코드 막기 · 실패 한도 · 웹메일에서 앱 비밀번호 거절)과 로그인 기록을 맡는다.
이 절은 엔진 설정 · 프록시 · 운영만 다룬다.
### 엔진 웹훅 — 메일 프로그램 로그인 기록
```
메일 프로그램 ─(993 IMAPS · 465 SMTPS)─▶ mail ─(웹훅 · app-network · 1초 묶음)─▶ backend POST /api/internal/mail-engine-events → 로그인 기록(90일)
X-Signature = base64(HMAC-SHA256(본문, MAIL_WEBHOOK_SECRET))
바깥 ─▶ 외부 프록시 ─▶ frontend nginx /api/internal/… → 404 RES_001 (두 server 모두 — 백엔드로 넘기지 않는다)
```
엔진에는 계정별 로그인 기록이 없어(공개판 — `x:Trace` 는 엔터프라이즈 전용) 엔진이 인증 이벤트를 백엔드로 보내고 백엔드가 남긴다. 웹메일 로그인은 백엔드가 스스로 남긴다(엔진에는 백엔드 IP 로 보인다).
| 항목 | 값 |
| --- | --- |
| 엔진 객체 | `x:WebHook` 하나 — 관리 화면 Settings › Telemetry › Webhooks. setup 이 만들고 맞춘다(여러 번 돌려도 같다). **관리 화면에서 고치지 않는다** — 다음 setup 이 되돌린다 |
| 받는 곳 | `http://backend:<BACKEND_PORT>/api/internal/mail-engine-events` — compose 의 서비스 이름. mail 과 backend 가 함께 붙는 네트워크는 app-network 하나라 엔진의 접속 주소가 그 대역이다 |
| 이벤트 | `auth.success` · `auth.failed` · `auth.mfa-required` 만(`eventsPolicy: include`) — 엔진 기본(`exclude` + 빈 목록)은 **모든 이벤트**다 |
| 그 밖의 칸 | `level: trace`(이 판은 웹훅에 수준을 쓰지 않지만 새 판 문서는 쓴다 — info 면 debug 인 `auth.failed` · trace 인 `auth.mfa-required` 가 빠진다) · `throttle` 1초(묶음) · `timeout` 5초 · `discardAfter` 5분 · `lossy: false` · HTTP 인증 없음 |
| 서명 | 본문 원문의 HMAC-SHA256 을 base64 로 `X-Signature` 에 — 키 `MAIL_WEBHOOK_SECRET`(`openssl rand -hex 24` 로 만든 값 — **사람이 지은 키는 쓰지 않는다**). 키는 엔진 설정에 그대로 저장된다 — 관리 화면 · 관리 API 는 `****` 로 가리지만 메일 볼륨 백업에는 있다 |
| setup 표시 | `x:WebHook` 에는 이름 · 설명 칸이 없다 — 머리글 `X-Arasystem-Setup: [setup.mjs] key=<소금>.<키 지문>` 을 setup 표시로 쓴다. 키는 읽으면 `****` 라 키가 바뀌었는지는 이 지문(scrypt — 소금은 setup 이 웹훅을 쓸 때마다 새로 뽑는다)으로 안다. 엔진이 보내는 요청마다 실린다(백엔드는 쓰지 않는다). ⚠️ **관리 화면에서 서명 키만 바꾸면(머리글은 그대로) setup 이 알아채지 못한다** — 키는 env 와 setup 으로만 바꾼다 |
| 받는 쪽 (세 겹) | 앞단 nginx 가 `/api/internal/` 을 404 `RES_001`(두 server · 대소문자 · `%2F` · `%5C` · 겹 `/` 변형 — `frontend/nginx-internal-api-closed.conf`), 원문 요청 대상에 날 `\`(경로) · `#` · 0x80 이상 바이트가 있으면 location 을 고르기 전에 400 `VAL_001`(두 server — 아래) → 백엔드가 접속 주소(소켓)가 `APP_NETWORK_SUBNET` 인지 → 서명. 하나라도 아니면 401 `AUTH_001` (계약 5절 "엔진 웹훅 받기"). CI 의 "닫는 경로 시험"이 두 server 에서 실제 요청으로 본다 |
| 이름 풀기 | 웹훅은 엔진의 DNS(`x:DnsResolver` — 운영은 리졸버에 바로)가 아니라 메일 컨테이너의 시스템 DNS(도커 DNS)로 `backend` 를 푼다(엔진의 HTTP 라이브러리 기본값 — 0.16.27 소스). 개발 엔진의 DNS 를 운영처럼 Custom 으로 바꿔 확인했다(아래 "확인") |
- **받는 것** — IMAP(993) · SMTP(465) 로그인의 성공 · 실패 · "2단계 필요"(2단계를 켠 계정에 비밀번호로). 엔진 HTTP 리스너의 이벤트(웹메일 확인 · 게이트웨이 대리 접근)도 오지만 백엔드가 버린다.
엔진은 서버 전체의 이벤트를 보낸다 — 사내 직원이 아닌 주소의 실패 등은 백엔드가 거른다 (계약 5절).
- **끊기면 잃는 기록** — 엔진의 보낼 대기열은 메모리에 있다. 백엔드가 **5분 넘게** 받지 못하면(`discardAfter`) 그 묶음을 버리고, **엔진을 다시 띄우면** 보내지 못한 것이 사라진다 —
그동안의 메일 프로그램 로그인 기록과 앱 비밀번호의 "마지막 사용"이 빠진다(웹메일 로그인 기록은 백엔드가 남기므로 그대로). 백엔드가 멈춘 동안 엔진 로그에
`WARN Webhook collector error (telemetry.webhook-error) … error sending request` 가 보낼 것이 있는 동안 1초마다 남고, 5분이 지나 버릴 때 같은 이벤트로 `Discarded stale events`(버린 수)가 남는다 —
백엔드가 돌아오면 멎는다. `lossy: false` 라도 엔진의 다른 일을 막지 않는다 — 엔진은 구독자에게 기다리지 않고 넘기고(가득 차면 그 묶음을 메모리에 더 쌓는다), 5분 지난 것은 버린다(0.16.27 소스)
(`lossy: true` 면 쌓인 묶음이 32,768개를 넘을 때 error 수준만 남겨 로그인 이벤트를 버린다).
- **키를 바꾸면** — 차례: `.env.production` 의 값을 바꾸고(`openssl rand -hex 24`) → `sudo docker compose --env-file .env.production up -d backend`(백엔드가 새 키로 확인) →
setup(엔진이 새 키로 서명 — `[변경] 엔진 웹훅 — 서명 키` · `[확인] … 받았다`). 둘 사이에 엔진이 보낸 묶음은 백엔드가 401 로 거절한다(엔진은 5분까지 다시 보낸다) —
둘을 이어서 하고, 그 몇 분의 메일 프로그램 기록은 빠질 수 있다고 본다. setup 을 먼저 돌렸으면 받는 곳 확인이 401 인데, 이번 실행에서 엔진에 키를 넣었으므로
실패가 아니라 `[주의]` 와 다음 명령(`up -d backend` 뒤 setup 한 번 더)이다 — 그대로 한 뒤 `[확인] … 받았다` 를 본다.
- **키가 비면** — 운영은 setup · 백엔드 모두 멈춘다(운영 필수). 개발은 setup 이 `[주의]` 를 내고 있던 웹훅을 끈다(`enable: false` — 지우지 않는다). 키를 넣고 다시 돌리면 켠다.
- **setup 의 확인** — 리로드 뒤 다시 읽어 setup 의 값 그대로인지(`[확인] 엔진 웹훅 켜짐`), 그리고 메일 컨테이너 안(엔진과 같은 자리 · 같은 DNS)에서 서명한 빈 묶음 `{"events":[]}` 을
보내 200 인지(`[확인] 엔진 웹훅 — … 받았다 (HTTP 200 — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다)`). 엔진에 든 키는 읽을 수 없어 표시 머리글의 지문으로만 안다(위 표).
401(키 · 접속 주소)은 운영은 실패, 개발은 `[주의]`(이번 실행에서 키를 넣었으면 운영도 `[주의]` — 위 "키를 바꾸면"), 안내는 `up -d backend`.
404(받는 곳이 없는 12단계 전 backend)는 운영은 실패로 `up -d --build backend frontend`, 개발은 `[주의]` 로 `restart backend` 를 안내한다. 닿지 않으면 몇 번 다시 해 본 뒤 `[주의]`.
서명은 docker exec 의 명령줄이 아니라 표준 입력으로 넘긴다(도커 이벤트 · `ps` 에 남지 않게).
- **잘못된 요청 대상은 400** (두 server 모두 — `frontend/nginx.conf` 의 `$request_target_rejected`) — 원문 대상에 날 `\`(경로) · 날 `#` · 0x80 이상 바이트가 있으면 location 을 고르기 전에
400 `VAL_001`(JSON — 화면 경로도 같다)로 끝낸다. nginx 는 그 꼴을 닫는 정규식(`/api/internal` · `/api/health` · 공개 API)에 맞추지 못하고 원문 그대로 넘기는데,
백엔드의 Express(parseurl)는 `#` · NBSP(0xA0) 같은 글자가 있으면 옛 `url.parse` 로 다시 읽어 경로의 `\` 를 `/` 로 바꾼다 — `POST /api/internal\mail-engine-events#x` 가
개발 백엔드의 웹훅 가드(401)까지 닿았다(2026-10-31 리뷰 — 같은 원인으로 `/api/health` · 포털의 공개 API 닫기도 11단계 전부터 넘어갔다).
브라우저는 이런 요청을 보내지 않는다(경로의 `\` 는 `/` 로 바꾸고, 비 ASCII 는 퍼센트 인코딩하고, `#` 뒤는 보내지 않는다). **쿼리의 `\` 는 받는다**(주소창에 친 검색어 —
`url.parse` 는 `?` 앞의 `\` 만 바꾼다). `%5C` 는 nginx 가 `\` 로 풀어 고르므로 닫는 정규식이 `[/\x5c]` 로 받는다(두 겹).
- setup 표시 없이 같은 받는 곳으로 가는 웹훅이 있으면 `[주의]` 만 낸다(사람이 만든 것으로 본다 — 같은 이벤트가 두 번 가거나 다른 키로 서명돼 거절된다) — Settings › Telemetry › Webhooks 에서 지운다.
- **게이트웨이에는 2단계 인증을 켜지 않는다** — 대리 접근은 게이트웨이 자신의 비밀번호 · OTP 를 확인하므로 백엔드의 대리 접근이 모두 402 로 막혀 웹메일 전체가 선다.
setup 은 게이트웨이가 402 면 아무것도 바꾸기 전에 멈춘다 — 아래 "휴대폰을 잃은 직원"의 절차를 게이트웨이 주소로 해서 끈다.
- 확인 (2026-10-31, 개발 엔진 0.16.27):
- setup — 처음 실행 `[생성]` → 다시 돌리면 `[유지]`(바뀐 것 0줄). 이벤트를 하나 더하고 `timeout` 을 바꾼 뒤 → `[변경] … 이벤트, timeout`,
키 지문을 바꾼 뒤 → `[변경] … 서명 키`, 키를 비우고 → `[주의]` · `[변경] … 끔`, 되돌리면 `[변경] … 꺼짐`(다시 켬) → `[유지]`. 읽으면 서명 키는 `"****"` 다.
옛 모양의 표시 머리글(소금 없음)은 한 번 `[변경] … 서명 키` 로 맞추고 그다음은 `[유지]`. 다른 키로 돌리자 받는 곳 확인이 401 → `[주의] … 이번 실행에서 엔진에 … 넣었고` ·
원래 키로 되돌리자 `[확인] … 받았다 (HTTP 200 …)`. 게이트웨이를 OTP 를 켠 임시 계정으로 가리키자 첫 줄 다음에 `[실패] 게이트웨이 계정(…)에 2단계 인증(OTP)이 켜져 있습니다` — 바꾼 것 없음.
- 배달 — 앱 비밀번호로 IMAP · SMTP 로그인, 틀린 비밀번호로 IMAP 로그인을 하자 로그인 기록에 imap 성공(앱 비밀번호 이름) · smtp 성공 · imap 비밀번호 틀림이 생겼고
앱 비밀번호의 "마지막 사용"도 채워졌다(메인).
- 운영 모양의 엔진 DNS — 개발 엔진의 `x:DnsResolver` 를 운영처럼 Custom(리졸버 `172.16.73.254` 의 UDP · TCP)으로 바꾸고 리로드한 뒤 IMAP 틀린 비밀번호 한 번 →
백엔드 기록에 `imap · 실패 · wrongPassword · 172.16.71.2` 가 생겼고 엔진의 웹훅 오류는 없었다. 메일 컨테이너의 `/etc/resolv.conf` 는 도커 DNS(`127.0.0.11`)이고
`backend` 는 앱 네트워크 주소(`172.16.71.5`)로 풀린다. setup 으로 System 으로 되돌렸다.
- 백엔드가 떠 있지 않을 때 — 리로드 뒤 틀린 비밀번호 로그인 한 번에 엔진이 받는 곳으로 보내려다 위의 warn 을 1초마다 남겼다.
### 휴대폰을 잃은 직원 — 2단계 인증 끄기
복구 코드는 없다 — 인증 앱을 쓸 수 없게 된 직원은 운영자가 엔진에서 그 직원의 OTP 를 지운다. **직원 비밀번호 없이 된다.** 끄면 그 직원은 비밀번호만으로 로그인하고, 웹메일 설정에서 새 휴대폰으로 다시 켠다
(직원 본인은 끄지 못한다 — 엔진이 끌 때 지금 코드를 요구한다).
먼저 본인인지 확인한다(전화 · 대면 등 — 이 절차는 2단계 인증을 비켜 가는 길이다). 바꾸는 것은 그 계정의 비밀번호 자격 증명에 붙은 OTP 하나뿐이다 — 비밀번호 · 앱 비밀번호 · 웹메일 세션은 그대로다.
**1) 관리 API** (2026-10-31 개발 엔진에서 아래 명령 그대로 확인 — 관리자 Basic, 직원 비밀번호 없음)
8080(관리 API)은 바깥에 열지 않는다 — 메일 호스트에서 backend 컨테이너의 node 로 앱 네트워크의 `http://mail:8080` 에 붙는다(관리자 아이디 · 도메인 · 주소는 그 컨테이너의 env 를 쓴다).
관리자 비밀번호는 화면에서 받아 환경변수로만 넘긴다 — 명령줄 · 셸 기록 · `ps` 에 남지 않는다(`docker exec -e 이름` 은 값을 명령줄에 싣지 않는다).
```bash
# 메일 호스트의 저장소 폴더에서 (docker 그룹이면 sudo --preserve-env=… 없이 docker exec 부터)
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" # 개발은 .env.development
read -rp '끌 직원의 주소: ' OTP_TARGET
read -rsp '관리자 비밀번호(MAIL_ADMIN_PASSWORD): ' OTP_ADMIN_PW; echo
export OTP_TARGET OTP_ADMIN_PW
sudo --preserve-env=OTP_TARGET,OTP_ADMIN_PW docker exec -i -e OTP_TARGET -e OTP_ADMIN_PW "${PROJECT_NAME}-backend" node - <<'EOF'
const base = process.env.MAIL_JMAP_URL || 'http://mail:8080';
const admin = `${(process.env.MAIL_ADMIN_LOCAL_PART || 'admin').trim().toLowerCase()}@${process.env.MAIL_DOMAIN}`;
const auth = 'Basic ' + Buffer.from(`${admin}:${process.env.OTP_ADMIN_PW}`).toString('base64');
const target = (process.env.OTP_TARGET || '').trim().toLowerCase();
const using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
(async () => {
const session = await fetch(`${base}/jmap/session`, { headers: { authorization: auth } });
if (session.status !== 200) throw new Error(`관리자 로그인 실패 (HTTP ${session.status})`);
const accountId = (await session.json()).primaryAccounts['urn:stalwart:jmap'];
const call = async (method, args) => {
const res = await fetch(`${base}/jmap/`, {
method: 'POST',
headers: { authorization: auth, 'content-type': 'application/json' },
body: JSON.stringify({ using, methodCalls: [[method, { accountId, ...args }, 'c']] }),
});
const [[name, result]] = (await res.json()).methodResponses;
if (name === 'error') throw new Error(`${method}: ${result.type}`);
return result;
};
let account = null;
for (let position = 0; !account; position += 200) {
const { ids } = await call('x:Account/query', { position, limit: 200 });
if (ids.length === 0) break;
const { list } = await call('x:Account/get', { ids, properties: ['name', 'emailAddress', 'credentials'] });
account = list.find((item) => (item.emailAddress || '').toLowerCase() === target) || null;
}
if (!account) throw new Error(`계정이 없습니다: ${target}`);
const [key, credential] = Object.entries(account.credentials || {}).find(([, item]) => item['@type'] === 'Password') || [];
if (!key) throw new Error('비밀번호 자격 증명이 없습니다');
if (!credential.otpAuth) return console.log(`이미 꺼져 있습니다 — ${target}`);
const result = await call('x:Account/set', { update: { [account.id]: { [`credentials/${key}/otpAuth`]: null } } });
if (!result.updated || !(account.id in result.updated)) throw new Error(`바꾸지 못했습니다: ${JSON.stringify(result.notUpdated)}`);
const { list } = await call('x:Account/get', { ids: [account.id], properties: ['credentials'] });
console.log(list[0].credentials[key].otpAuth ? `아직 켜져 있습니다 — ${target}` : `껐습니다 — ${target} (자격 증명 ${key})`);
})().catch((error) => { console.error(`[실패] ${error.message}`); process.exitCode = 1; });
EOF
unset OTP_ADMIN_PW OTP_TARGET
```
- 하는 일: 관리자의 `x:` 계정(`primaryAccounts["urn:stalwart:jmap"]`)으로 `x:Account/query` · `get`(주소 `emailAddress` 로 찾는다) → 그 계정의 `credentials` 에서 `"@type":"Password"` 인 칸(보통 `"0"` —
OTP 가 켜져 있으면 `"otpAuth":"****"`)의 `otpAuth` 만 `x:Account/set` 의 `{"credentials/<번호>/otpAuth": null}` 로 지우고 다시 읽어 본다. `secret` 은 보내지 않는다(`"****"` 를 보내도 엔진이 원래 값을 둔다).
- 확인한 것: 시험 직원(`seojun.park`)에 직원 경로(게이트웨이 대리 접근의 `x:AccountPassword/set` — `currentSecret` + `otpAuth.otpUrl`)로 OTP 를 켜자 Basic 로그인이 402,
위 명령(개발 — `arasystem-backend`)을 돌리자 `껐습니다 — …` 와 함께 `otpAuth` 가 없어지고 Basic 로그인이 200 이었다. 다시 돌리면 `이미 꺼져 있습니다`.
켤 때의 주소는 계약대로 이름 `ARA SYSTEM`(띄어쓰기)이었고 엔진이 그 주소로 코드를 확인했다(직원 경로로 끄기도 됨). 끝나고 처음 상태(OTP 꺼짐 · 앱 비밀번호 0개 · 비밀번호 그대로)로 되돌렸다.
- 웹메일의 "켠 때"(`enabledAt`)는 서버가 엔진에서 꺼진 것을 보면 지운다 (계약 5절 "2단계 인증").
**2) 관리 화면** (운영은 SSH 터널 — `ssh -L 8180:<mail 컨테이너 IP>:8080 <메일 호스트>` → <http://localhost:8180/admin/>, [운영 배포](#운영-배포) 아래 "실제 직원 계정" 줄) — **직접 눌러 보지 못했다**:
1. Management › Directory › **Accounts** 에서 그 직원을 연다.
2. Authentication 의 **Credentials** 에서 비밀번호 자격 증명(Password)을 연다.
3. Secrets 의 **OTP Authentication URI** 칸(켜져 있으면 `****`)을 **비우고** 저장한다. 같은 묶음의 Password 칸(`****`)은 건드리지 않는다 — 그대로 두면 엔진이 원래 값을 지킨다.
4. 확인 — 그 직원이 비밀번호만으로 웹메일에 로그인된다(코드를 묻지 않는다). 위 1)의 명령을 다시 돌리면 `이미 꺼져 있습니다` 다.
메뉴 · 칸 이름은 개발 엔진의 관리 화면이 그리는 스키마(`/api/schema` 의 layouts · forms)에서 찾았다. 칸을 비우면 화면이 `null` 을 보낸다(관리 화면 코드 — 비울 수 있는 글 칸) — 1)과 같은 요청이다.
⚠️ Account › Credentials › **Password**(직원 셀프서비스 화면)의 Disable 은 지금 코드를 요구한다 — 휴대폰을 잃은 직원에게는 쓸 수 없다.
### 운영 확인 (12단계)
- setup — `[확인] 엔진 웹훅 켜짐 — …` 과 `[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP 200 …)` 두 줄이 있다 ([운영 배포](#운영-배포) 5).
- 메일 프로그램으로 한 번 로그인한다(아웃룩 · 휴대폰 메일 앱 — IMAP 993) → 웹메일 설정 › 로그인 기록에 그 시각의 IMAP 줄(바깥 주소)이 생긴다.
안 생기면 엔진 로그(`sudo docker logs --since 10m "${PROJECT_NAME}-mail" 2>&1 | grep -i webhook`)와 백엔드 로그를 본다.
- 바깥에서 막혔는지 — `curl -s -X POST https://portal.arasystem.it.kr/api/internal/mail-engine-events` → 404 `RES_001`(JSON), `/api/Internal/…` · `/api/internal%5Cmail-engine-events` 도 같다.
날 역슬래시 꼴은 400 이다 — `curl -s 'https://portal.arasystem.it.kr/api/health\'` → 400 `VAL_001`(JSON — 외부 프록시가 원문 그대로 넘기는지도 함께 본다).
- 직원 안내 — 2단계 인증을 켜면 **아웃룩 · 휴대폰 메일 앱은 비밀번호로 연결되지 않는다** → 웹메일 설정 › 앱 비밀번호에서 만든 비밀번호로 다시 연결한다(사용자 이름은 메일 주소 그대로).
웹메일 비밀번호를 바꾸면 앱 비밀번호가 모두 지워진다 — 그때도 다시 만든다. 휴대폰을 잃으면 운영자에게 문의한다(위 절).
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.