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
+4
View File
@@ -184,6 +184,10 @@ MAIL_GATEWAY_PASSWORD=change-me-in-local-env
MAIL_DIRECTORY_LOCAL_PART=mail-directory
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망). 보통 바꿀 일이 없다
MAIL_JMAP_URL=http://mail:8080
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로 서명하고(HMAC-SHA256)
# 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다. 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다)
# 비우면 setup 이 웹훅을 두지 않는다(개발만 — 그동안 메일 프로그램 로그인 기록이 남지 않는다). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅")
MAIL_WEBHOOK_SECRET=changemeinlocalenv00000000000000
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도가 이 값 + 1MB 로 따라간다
# (그 앞에 외부 리버스 프록시를 두면 그쪽 한도도 맞춘다 — docker 스킬 "운영 환경")
MAIL_ATTACHMENT_MAX_SIZE_MB=20
+6
View File
@@ -216,6 +216,12 @@ MAIL_GATEWAY_PASSWORD=CHANGE-ME-BEFORE-DEPLOY
MAIL_DIRECTORY_LOCAL_PART=mail-directory
# 백엔드가 쓰는 내부 JMAP 주소 (compose 내부망)
MAIL_JMAP_URL=http://mail:8080
# 엔진 웹훅의 서명 키 (12단계 — 메일 프로그램(IMAP · SMTP) 로그인 기록) — **운영 필수**. 엔진이 로그인 이벤트를 백엔드로 보낼 때 본문을 이 값으로
# 서명하고(HMAC-SHA256) 백엔드가 같은 값으로 확인한다. setup 과 backend 가 이 값 하나를 읽는다 — 비었거나 CHANGEME 가 남아 있으면 둘 다 멈춘다.
# 영문 · 숫자로만 32자 이상 — 만들기: openssl rand -hex 24 (사람이 지은 값은 쓰지 않는다 — 엔진 관리 화면에 이 키의 지문이 보인다)
# 새 이미지를 띄우기(up) 전에 넣는다 — 없으면 백엔드가 env 검사에서 멈춘다. 바꾸면 backend 를 다시 띄우고(up -d backend) setup 을 다시 실행한다 —
# 그 사이에 엔진이 보낸 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅")
MAIL_WEBHOOK_SECRET=CHANGEMEBEFOREDEPLOY000000000000
# 첨부 1개 최대 크기(MB, 미설정 시 20). 프론트 nginx 의 본문 한도는 이 값 + 1MB 로 자동으로 따라간다.
# ⚠️ 그 앞의 외부 리버스 프록시 본문 한도는 직접 이 값 + 1MB 이상으로 둔다 —
# 작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 (docker 스킬 "운영 환경")
+76
View File
@@ -189,6 +189,16 @@ jobs:
'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;'; do
printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; }
done
# 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다
# (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if)
plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null)
for form in plain rendered; do
if [ "$form" = plain ]; then conf=$plain; else conf=$rendered; fi
n=$(printf '%s\n' "$conf" | awk '$1 == "include" && $2 == "/etc/nginx/snippets/internal-api-closed.conf;" { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::internal-api-closed.conf 를 넣은 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
n=$(printf '%s\n' "$conf" | awk '$1 == "if" && $2 == "($request_target_rejected)" { c++ } END { print c + 0 }')
[ "$n" -eq 2 ] || { echo "::error::원문 요청 대상 검사(if \$request_target_rejected)가 든 server 가 둘이 아닙니다 ($form: $n)"; exit 1; }
done
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소, 출처가 아닌 공개 내려받기 주소)
if docker run --rm -e PROJECT_NAME=ci -e MAIL_ATTACHMENT_MAX_SIZE_MB=08 "$tag" nginx -t; then
echo "::error::MAIL_ATTACHMENT_MAX_SIZE_MB=08 을 받아들였습니다 (거부해야 한다)"; exit 1
@@ -279,6 +289,72 @@ jobs:
for _ in $(seq 1 20); do docker exec "$name" wget -q -O /dev/null --header 'Host: portal.ci.test' http://127.0.0.1:8080/ 2>/dev/null && break; sleep 1; done
docker exec "$name" sh /usr/local/bin/paths.sh || { docker logs --tail 40 "$name"; exit 1; }
# nginx 가 닫는 경로를 두 server 에서 실제 요청으로 본다 (12단계 — 위 단계의 컨테이너를 그대로 쓴다). 가짜 백엔드는 무엇에든
# 200(application/octet-stream)이라, nginx 가 직접 끝낸 JSON(code)이면 백엔드에 넘기지 않은 것이다.
# - 엔진만 부르는 /api/internal/(엔진 웹훅, docs/mail/api-contract.md 5절) → 404 RES_001 — 대소문자 · 겹 / · %2F · %XX(글자를 퍼센트로) · %5C · .. ·
# absolute-form · 끝 / 없음 · 있음. 1m 넘는 본문은 413 FILE_002
# - 원문 대상의 날 \(경로) · # · 0x80 이상 바이트(0xA0) → 400 VAL_001 — /api/internal · /api/health · 공개 API · 화면 경로(server 단계 $request_target_rejected).
# nginx 는 그 꼴을 닫는 정규식에 맞추지 못하는데 백엔드의 Express(parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받았다(2026-10-31 리뷰). 쿼리의 \ 는 받는다
# - %5C 꼴의 /api/health · 공개 API → 404 RES_001 (닫는 정규식의 [/\x5c])
- name: 닫는 경로 시험
run: |
name="ci-nginx-paths-${{ github.run_id }}"
docker exec -i "$name" sh -s <<'EOF'
fails=0
# host method target [머리글] → "상태 Content-Type code". WAIT 이 있으면 표준 입력을 1초 열어 둔다 — busybox nc 는 표준 입력이 끝나면 닫아
# 백엔드 · 화면으로 간(늦게 오는) 응답을 받지 못한다
raw() {
{ printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf '%s\r\n' "$4"; printf 'Connection: close\r\n\r\n'; [ -n "$WAIT" ] && sleep 1; } \
| timeout 10 nc 127.0.0.1 8080 | tr -d '\r' | awk '
NR == 1 { s = $2 }
/^[Cc]ontent-[Tt]ype:/ { t = $2 }
/^\{/ { if (match($0, /"code":"[A-Z_0-9]+"/)) c = substr($0, RSTART + 8, RLENGTH - 9) }
END { print s, (t ? t : "-"), (c ? c : "-") }'
}
# 찍을 때 0x80 이상 바이트는 ~ 로
check() {
label=$(printf '%s' "$2" | tr '\200-\377' '~')
if [ "$got" = "$1" ]; then echo "ok $label → $got"; else echo "FAIL $label → $got (기대 $1)"; fails=$((fails + 1)); fi
}
expect() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
expect_wait() { got=$(WAIT=1 raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
BS=$(printf '\')
NB=$(printf '\240')
E=mail-engine-events
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
F=11111111-2222-4333-8444-555555555555
for host in portal.ci.test files.ci.test; do
echo "== $host — /api/internal 은 404"
for path in "/api/internal/${E}" "/api/INTERNAL/${E}" "/api/Internal/Mail-Engine-Events" "/api//internal/${E}" "/api/internal//${E}" \
"/api%2Finternal/${E}" "/api/%69nternal/${E}" "/api/%49NTERNAL/${E}" "/api/internal%5C${E}" "/api/internal%5c${E}" \
"/api/x/../internal/${E}" "/api/./internal/${E}" "http://${host}/api/internal/${E}" /api/internal /api/internal/ /api/Internal; do
expect "404 application/json RES_001" "$host" POST "$path"
done
expect "404 application/json RES_001" "$host" GET "/api/internal/${E}"
expect "413 application/json FILE_002" "$host" POST "/api/internal/${E}" "Content-Length: 2000000"
echo "== $host — 원문 대상의 날 \ · # · 0xA0 은 400"
for path in "/api/internal${BS}${E}" "/api/internal${BS}${E}#x" "/api/internal${BS}${E}${NB}" "/api${BS}internal/${E}" "/api/internal/${E}#x" "/api/internal/${E}${NB}" \
"/api/health${BS}" "/api/health${BS}#a" "/api/health#a" "/api/health${NB}" \
"/api/public-links${BS}${T}${BS}files${BS}${F}" "/api/public-links${BS}x#a" "/api/public-links/${T}#x" "/api/public-links/${T}${NB}"; do
expect "400 application/json VAL_001" "$host" POST "$path"
done
expect "404 application/json RES_001" "$host" GET "/api/health%5C"
done
echo "== 포털 — 공개 API 닫힘(%5C) · 화면 경로의 날 \ 은 400 · 쿼리의 \ 는 받는다 · 경계"
expect "404 application/json RES_001" portal.ci.test GET "/api/public-links%5C${T}"
expect "400 application/json VAL_001" portal.ci.test GET "/mail${BS}inbox"
expect_wait "200 text/html -" portal.ci.test GET "/mail/search?q=a${BS}b"
expect_wait "200 application/octet-stream -" portal.ci.test GET "/api/messages?q=a${BS}b"
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internals
expect_wait "200 application/octet-stream -" portal.ci.test GET /api/internal-x/y
expect_wait "200 text/html -" portal.ci.test GET "/API/internal/${E}"
echo "== 공개 호스트 — 공개 API 의 %5C 꼴은 404 · 화면 /s 의 날 \ 은 400"
expect "404 application/json RES_001" files.ci.test GET "/api/public-links%5C${T}"
expect "400 application/json VAL_001" files.ci.test GET "/s${BS}${T}"
echo "fails=$fails"
[ "$fails" -eq 0 ]
EOF
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
+6
View File
@@ -123,6 +123,12 @@ services:
# 사내 목록 계정 아이디 — mail setup 이 만든 비밀번호 없는 계정(<이 값>@MAIL_DOMAIN). 백엔드가 게이트웨이 대리 접근으로
# 엔진의 계정 목록(JMAP Principal)에서 직원 이름·주소만 읽는다 (docs/mail/api-contract.md 3절 "사내 목록" · mail/README.md)
- MAIL_DIRECTORY_LOCAL_PART=${MAIL_DIRECTORY_LOCAL_PART:-mail-directory}
# 엔진 웹훅(12단계 — 메일 프로그램 로그인 기록)의 서명 키 — 엔진이 보내는 묶음(POST /api/internal/mail-engine-events)의 HMAC-SHA256(X-Signature)을 이 값으로 본다.
# mail setup 이 같은 값으로 엔진 웹훅을 만든다. 운영 필수(shared/config/env.validation.ts). 바꾸면 setup 을 다시 실행하고 backend 를 다시 띄운다
# (docs/mail/api-contract.md 5절 "엔진 웹훅 받기" · mail/README.md "엔진 웹훅")
- MAIL_WEBHOOK_SECRET=${MAIL_WEBHOOK_SECRET:-}
# 앱 네트워크 대역 — 웹훅 받는 쪽이 접속 주소(소켓 — X-Forwarded-For 가 아니다)가 이 대역인지 본다. 아래 networks 의 app-network 서브넷과 같은 값이어야 한다
- APP_NETWORK_SUBNET=${APP_NETWORK_SUBNET:-172.16.71.0/24}
# 첨부 1개 최대 크기(MB). 앞단 리버스 프록시의 요청 본문 한도는 이보다 커야 한다
# (작으면 프록시가 먼저 413 을 내고 사용자에게는 FILE_002 로 보인다 — .claude/skills/docker/SKILL.md)
- MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20}
+3 -1
View File
@@ -49,11 +49,13 @@ COPY nginx.conf /etc/nginx/templates/default.conf.template
# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도, 공개 내려받기 호스트). 실행 권한이 있어야 entrypoint 가 source 한다
COPY --chmod=755 nginx-env.envsh /docker-entrypoint.d/16-nginx-env.envsh
# 두 server(포털 · 공개 내려받기 — 11단계)가 include 하는 location 묶음. 템플릿이 아니다 — envsubst 를 거치지 않고 그대로 읽는다.
# share-open · share-closed 는 server 단계(화면 /s/), share-api-open · share-api-closed 는 /api/ 블록 안(공개 API — 대소문자를 가리지 않는다)
# share-open · share-closed 는 server 단계(화면 /s/), share-api-open · share-api-closed 는 /api/ 블록 안(공개 API — 대소문자를 가리지 않는다),
# internal-api-closed 는 /api/ 블록 안(엔진만 부르는 /api/internal/ 을 닫는다 — 12단계, 두 server 모두)
COPY nginx-share-open.conf /etc/nginx/snippets/share-open.conf
COPY nginx-share-closed.conf /etc/nginx/snippets/share-closed.conf
COPY nginx-share-api-open.conf /etc/nginx/snippets/share-api-open.conf
COPY nginx-share-api-closed.conf /etc/nginx/snippets/share-api-closed.conf
COPY nginx-internal-api-closed.conf /etc/nginx/snippets/internal-api-closed.conf
COPY nginx-api-errors.conf /etc/nginx/snippets/api-errors.conf
# 워커마다 연결 수 — 이미지 기본 1024 → 4096. 대용량 받기(버퍼링 끔 — 받기 하나가 연결 둘, 공개 400 · 앱 안 200 개까지) · 실시간 알림(브라우저마다 연결 둘을
# 1시간) · 포털 요청이 함께 버티게 (nginx.conf 의 http 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다.
+16 -1
View File
@@ -1,5 +1,6 @@
# ==========================================
# /api 의 nginx 오류 응답 — 두 server(포털 · 공개 내려받기)가 넣는다 (nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/api-errors.conf)
# (잘못된 원문 요청 대상의 400 은 화면 경로에도 같은 JSON 이다 — 아래 /__request-target-rejected)
#
# message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
@@ -7,7 +8,8 @@
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(404·413·429·502·504 등)이 그대로 나간다 (2026-10-27 확인)
# ==========================================
# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때). 없는 경로와 같은 RES_001
# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때) ·
# 두 server 의 /api/internal/(엔진만 부르는 경로 — 12단계, internal-api-closed.conf). 없는 경로와 같은 RES_001
location @api_not_found {
add_header_inherit off;
default_type application/json;
@@ -37,6 +39,19 @@ location @api_too_many_requests {
return 200 '{"success":false,"error":{"code":"REQ_001","message":"","traceId":"$request_id"}}';
}
# 원문 요청 대상이 잘못된 400 — 경로의 날 역슬래시 · 날 # · 0x80 이상 바이트 (nginx.conf 의 $request_target_rejected — 두 server 의 server 단계 if 가
# 이리로 넘긴다). 화면 경로든 /api 든 같은 JSON 이다 — 브라우저는 이런 요청을 보내지 않는다. 입력 형식 오류의 VAL_001.
# internal 이라 바깥에서 이 경로를 바로 부르면 404 다
location = /__request-target-rejected {
internal;
add_header_inherit off;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Request-Id $request_id always;
return 400 '{"success":false,"error":{"code":"VAL_001","message":"","traceId":"$request_id"}}';
}
# 백엔드 응답 없음 · 시간 초과 등 5xx
location @api_unavailable {
add_header_inherit off;
+21
View File
@@ -0,0 +1,21 @@
# ==========================================
# 엔진만 부르는 백엔드 경로 /api/internal/ 를 바깥에 열지 않는다 (12단계 — docs/mail/api-contract.md 5절 "엔진 웹훅 받기")
#
# 두 server(포털 · 공개 내려받기)의 location ^~ /api/ 블록 **안에** 넣는다(nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/internal-api-closed.conf).
# 엔진 웹훅(POST /api/internal/mail-engine-events)은 메일 엔진이 app-network 로 backend 에 바로 붙어 부른다 — 이 nginx 를 거치지 않는다.
# 바깥에서 온 요청은 없는 경로와 같은 404 RES_001(JSON — @api_not_found)로 끝내고 백엔드에 넘기지 않는다
# (세 겹의 첫째 — 백엔드는 접속 주소가 앱 네트워크인지와 본문의 서명을 본다). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
# 닫는 꼴 (CI 의 infra 잡 "닫는 경로 시험"이 두 server 에서 실제 요청으로 본다):
# - 대소문자를 가리지 않는다(~*) — /api/Internal/… 도 닫는다
# - nginx 는 %XX 를 풀고 겹 / · . · .. 를 정리한 뒤 location 을 고르므로 /api//internal/… · /api/%69nternal/… · /api%2Finternal/… · /api/x/../internal/… 도 걸린다
# - [/\x5c] — %5C(인코딩한 역슬래시 — nginx 가 \ 로 풀어 고른다)도 닫는다
# - 날 \(경로) · 날 # · 0x80 이상 바이트가 든 원문 대상은 여기까지 오지 않는다 — server 단계에서 400 VAL_001 이다(nginx.conf 의 $request_target_rejected).
# nginx 는 그 꼴을 이 정규식에 맞추지 못하는데 백엔드(Express — parseurl 의 url.parse 되읽기)는 \ 를 / 로 바꿔 받는다(2026-10-31 리뷰)
# /api/ 블록의 다른 정규식 location 보다 먼저 넣는다 — 정규식은 적은 차례로 처음 맞은 것을 쓴다
# ==========================================
location ~* ^/api/internal(?:[/\x5c]|$) {
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
return 404;
}
+3 -1
View File
@@ -5,9 +5,11 @@
# 템플릿이 아니다(/etc/nginx/snippets/share-api-closed.conf). 없는 경로와 같은 404 RES_001(JSON — @api_not_found) —
# 백엔드도 같은 판정으로 막는다(두 겹 — modules/file-sharing/public-host.ts). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
# 대소문자를 가리지 않는다(~*) — /api/Public-Links/… 도 닫는다(백엔드 라우팅은 대소문자를 가리지 않는다 — nginx.conf 머리말)
# [/\x5c] — %5C(인코딩한 역슬래시 — nginx 가 \ 로 풀어 고른다)도 닫는다. 날 \(경로) · # · 0x80 이상 바이트는 server 단계에서 400 이다
# (nginx.conf 의 $request_target_rejected — 백엔드의 url.parse 되읽기가 \ 를 / 로 바꿔 이 닫기를 비켜 갔다, 2026-10-31 리뷰)
# ==========================================
location ~* ^/api/public-links(?:/|$) {
location ~* ^/api/public-links(?:[/\x5c]|$) {
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
return 404;
+38 -1
View File
@@ -23,6 +23,7 @@
# 두 server 가 함께 쓰는 location 은 /etc/nginx/snippets/ 의 파일이다(템플릿이 아니라 그대로 쓴다):
# share-open.conf · share-closed.conf server 단계 — 공개 화면 /s/ (연다 · 닫는다)
# share-api-open.conf · share-api-closed.conf /api/ 블록 안 — 공개 API /api/public-links/ (연다 · 닫는다)
# internal-api-closed.conf /api/ 블록 안 — 엔진만 부르는 /api/internal/ (늘 닫는다 — 12단계 엔진 웹훅)
# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*)
# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다.
# ==========================================
@@ -159,6 +160,25 @@ limit_conn_zone $download_total_key zone=download_app_total:1m;
limit_conn_status 429;
limit_conn_log_level info;
# ----------------------------------------
# 원문 요청 대상 검사 (12단계 리뷰 — 두 server 의 server 단계 if 가 쓴다, 400 VAL_001 JSON — nginx-api-errors.conf)
#
# 요청 줄의 대상(원문 $request_uri)에 아래가 있으면 location 을 고르기 전에 400 으로 끝낸다:
# - 경로(? 앞)의 날 역슬래시 \ — nginx 는 \ 를 경로 구분자로 보지 않아 /api/internal\… 이 닫는 정규식 location 에 맞지 않고 /api/ 로 넘어간다
# - 날 # (어디든) — nginx 는 # 뒤를 버리고 location 을 고른다
# - 0x80 이상 바이트 (어디든) — 퍼센트 인코딩하지 않은 비 ASCII (NBSP 0xA0 등)
# 백엔드로는 원문이 그대로 간다. 백엔드의 Express(parseurl)는 원문에 # · 0xA0 같은 글자가 있으면 옛 url.parse 로 다시 읽어 경로의 \ 를 / 로 바꾼다 —
# 그래서 /api/internal\mail-engine-events#x · /api/health\#… · /api/public-links\… 가 nginx 의 닫기를 비켜 백엔드 처리기에 닿았다(2026-10-31 리뷰).
# 브라우저는 이런 요청을 보내지 않는다 — 경로의 \ 는 / 로 바꾸고, 비 ASCII 는 퍼센트 인코딩하고, # 뒤(조각)는 보내지 않는다.
# 쿼리의 \ 는 막지 않는다 — 주소창에 친 검색어처럼 브라우저가 그대로 보낼 수 있고, url.parse 는 ? 앞의 \ 만 바꾼다. %5C(인코딩한 \)는 nginx 가 풀어
# location 을 고르므로 닫는 정규식들이 [/\x5c] 로 받는다(두 겹). 정규식의 \x5c 는 역슬래시, \x23 은 # 이다(설정 파일의 \ · # 을 피하려고)
# ----------------------------------------
map $request_uri $request_target_rejected {
"~^[^?]*\x5c" 1;
"~[\x23\x80-\xff]" 1;
default 0;
}
# ----------------------------------------
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
# ----------------------------------------
@@ -210,6 +230,11 @@ server {
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
client_max_body_size 1m;
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — location 을 고르기 전에 (위 http 단계의 map, nginx-api-errors.conf)
if ($request_target_rejected) {
rewrite ^ /__request-target-rejected last;
}
# ----------------------------------------
# 보안 헤더 (화면용)
#
@@ -281,9 +306,14 @@ server {
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
# 엔진만 부르는 /api/internal/(엔진 웹훅 — 12단계)은 바깥에 열지 않는다 — 404 RES_001(JSON), 대소문자를 가리지 않는다.
# 엔진은 app-network 로 backend 에 바로 붙는다(이 nginx 를 거치지 않는다). 공개 내려받기 server 도 같은 파일을 넣는다
include /etc/nginx/snippets/internal-api-closed.conf;
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
location ~* ^/api/health(?:/|$) {
# [/\x5c] — %5C(nginx 가 \ 로 풀어 고른다)도 막는다. 날 \ · # · 0x80 이상 바이트는 server 단계에서 400 이다(위 $request_target_rejected)
location ~* ^/api/health(?:[/\x5c]|$) {
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
@@ -444,6 +474,11 @@ server {
# 받기만 한다 — 본문을 받는 경로가 없다
client_max_body_size 1m;
# 원문 요청 대상에 날 \(경로) · # · 0x80 이상 바이트 → 400 VAL_001(JSON) — 포털과 같다 (http 단계의 map)
if ($request_target_rejected) {
rewrite ^ /__request-target-rejected last;
}
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
add_header_inherit merge;
@@ -476,6 +511,8 @@ server {
add_header_inherit off;
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
# 엔진만 부르는 /api/internal/(12단계) — 아래 return 404 로도 닫히지만 포털과 같은 파일로 따로 닫아 둔다(공개 API 파일이 바뀌어도 열리지 않게)
include /etc/nginx/snippets/internal-api-closed.conf;
include /etc/nginx/snippets/share-api-open.conf;
return 404;
}
+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 에 그대로 둔다.
+391 -13
View File
@@ -51,6 +51,9 @@
* — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로
* 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문).
* Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록")
* 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영).
* 리로드 뒤 메일 컨테이너 안에서 서명한 빈 묶음을 보내 받는 곳까지 닿는지 · 키가 맞는지 본다. 키가 비면 운영은 중단, 개발은 [주의] 뒤 웹훅을 끈다
* (mail/README.md "엔진 웹훅")
* 9. [개발] 외부 발송 경로 → Mailpit, 설정 리로드, 반영 확인
* (리로드 전에는 외부 주소로 보낸 메일이 실제 인터넷으로 나간다 — 그래서 10 보다 먼저 한다.
* 7 의 사내 목록 계정은 메일을 보내지도 받지도 못하므로 먼저 만들어도 된다)
@@ -67,7 +70,7 @@
* — 개발에서 docker 를 쓸 수 없는 환경의 재시작, 운영 --recovery 실행 뒤 복구 관리자 끄기
*/
import { spawnSync } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { createHmac, randomBytes, scryptSync } from 'node:crypto';
import { resolveTxt } from 'node:dns/promises';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { request as httpsRequest } from 'node:https';
@@ -448,6 +451,47 @@ const SPAMHAUS_DNSBL = [
},
];
/*
* 엔진 웹훅 — 메일 프로그램(IMAP · SMTP) 로그인 기록 (12단계 — 계약 docs/mail/api-contract.md 5절 "엔진 웹훅 받기" · "엔진 설정", mail/README.md "엔진 웹훅")
* 공개판 엔진에는 계정별 로그인 기록이 없어(x:Trace 는 엔터프라이즈 전용) 엔진이 인증 이벤트를 백엔드로 보내고 백엔드가 남긴다. 백엔드는 접속 주소(소켓 —
* 앱 네트워크 대역)와 본문 원문의 HMAC-SHA256(X-Signature — 키 MAIL_WEBHOOK_SECRET)을 보고 받는다.
* 엔진 쪽은 x:WebHook 하나다. 0.16.27 의 칸은 url · signatureKey(None · Value · EnvironmentVariable · File) · httpAuth · httpHeaders · throttle · timeout ·
* discardAfter · lossy · level · events · eventsPolicy · enable · allowInvalidCerts 이고 **이름 · 설명 칸이 없다**(소스 crates/registry/src/schema/structs.rs 의 WebHook) —
* setup 표시는 httpHeaders 의 머리글 하나(WEBHOOK_MARKER_HEADER)로 둔다. 읽으면 서명 키는 "****" 로만 온다(소스 schema/prelude.rs 의 secret_value) —
* 키가 바뀌었는지 알려고 그 머리글에 키의 지문(scrypt)을 함께 적는다. 칸에 객체를 통째로 보내면 events · httpHeaders 는 통째로 바뀐다(소스 types/map.rs · jmap/patch.rs).
* 엔진 기본값은 eventsPolicy exclude + 빈 목록(= 모든 이벤트) · level info · timeout 30초다 — 셋 다 바꾼다. level 은 이 판의 웹훅이 쓰지 않지만
* (소스 crates/common/src/config/telemetry.rs — apply_events 만) 새 판 문서는 쓴다(info 면 Debug 인 auth.failed · Trace 인 auth.mfa-required 가 빠진다).
* 바뀐 값은 ReloadSettings 뒤에 반영된다(2026-10-31 개발 엔진 실험 — 계약 5절의 근거).
* 웹훅의 HTTP 는 엔진의 x:DnsResolver 가 아니라 메일 컨테이너의 시스템 DNS(도커 DNS 127.0.0.11)로 이름을 푼다 — reqwest 의 기본 리졸버(hickory 기능을 켜지 않았다,
* crates/utils/Cargo.toml · src/http.rs). 그래서 운영처럼 엔진 DNS 가 Custom(리졸버에 바로 — 도커 이름을 모른다)이어도 backend 가 풀린다.
*/
const WEBHOOK_SECRET_ENV = 'MAIL_WEBHOOK_SECRET';
/** 서명 키 — 영문 · 숫자 32자 이상. Node --env-file 은 # 부터 자르고 compose 는 그대로 읽어 setup 과 backend 의 값이 어긋나지 않게 (계약 5절 "환경 변수") */
const WEBHOOK_SECRET_PATTERN = /^[A-Za-z0-9]{32,}$/;
/** 받는 곳 — compose 의 서비스 이름 backend. mail 과 backend 가 함께 붙는 네트워크는 app-network 하나라 엔진의 접속 주소가 그 대역이다(백엔드가 소켓 주소로 확인한다) */
const WEBHOOK_HOST = 'backend';
const WEBHOOK_PATH = '/api/internal/mail-engine-events';
/** 보내는 이벤트 — 엔진 기본(exclude + 빈 목록)은 모든 이벤트다 */
const WEBHOOK_EVENTS = ['auth.success', 'auth.failed', 'auth.mfa-required'];
/** 그 밖의 칸 — 묶음 1초 · 기다림 5초(백엔드는 곧바로 200) · 못 보낸 것은 5분까지 다시 보내고 버린다 · 대기열이 넘쳐도 버리지 않는다 · 수준 trace(위 머리말) */
const WEBHOOK_SETTINGS = { throttle: 1000, timeout: 5000, discardAfter: 300000, lossy: false, level: 'trace', eventsPolicy: 'include', allowInvalidCerts: false };
const WEBHOOK_HTTP_AUTH = { '@type': 'Unauthenticated' };
/** setup 표시 머리글 — 값은 `[setup.mjs] key=<소금>.<키 지문>`. 엔진이 보내는 요청마다 실린다 — 받는 쪽(백엔드)은 쓰지 않는다 */
const WEBHOOK_MARKER_HEADER = 'X-Arasystem-Setup';
/**
* 키 지문 — scrypt(키, 소금)의 12바이트 hex. 소금은 setup 이 웹훅을 쓸 때마다 새로 뽑는 12바이트(배포마다 다르다)이고 지문과 함께 머리글에 싣는다.
* 지문은 엔진 관리 화면 · 관리 API 에 보이고 백엔드로도 간다(백엔드는 키를 이미 안다). 키는 무작위(openssl rand -hex 24)여야 지문으로 되짚을 수 없다 —
* 사람이 지은 키를 쓰지 않는다. 짧은 키라도 한 번 견주는 데 수십 ms 가 들게 평범한 해시가 아니라 scrypt 이고, 소금이 배포마다 달라 미리 만든 표로 맞춰 볼 수 없다
*/
const WEBHOOK_KEY_SALT_BYTES = 12;
const WEBHOOK_KEY_FINGERPRINT_BYTES = 12;
/** 표시 머리글의 모양 — 소금 · 지문 모두 24자 hex. 옛 모양(`key=<지문>` — 소금 없음)은 맞지 않아 키가 바뀐 것으로 보고 한 번 맞춘다 */
const WEBHOOK_MARKER_PATTERN = /^\[setup\.mjs\] key=([0-9a-f]{24})\.([0-9a-f]{24})$/;
/** setup 이 받는 곳까지 닿는지 볼 때 보내는 묶음 — 엔진의 본문과 같은 꼴에 이벤트만 없다. 서명은 setup 이 같은 키로 만든다(받는 쪽은 200 으로 답한다 — 남길 것이 없다) */
const WEBHOOK_PROBE_BODY = '{"events":[]}';
/** 엔진이 맞는 비밀번호에 2단계 인증 코드를 더 요구할 때의 응답 — 402 "MFA code required"(틀린 비밀번호는 401 — 2026-10-31 개발 엔진 0.16.27, 계약 5절) */
const HTTP_MFA_REQUIRED = 402;
/** *.example 에서 복사한 흔적 (backend 의 env.validation.ts 와 같은 기준) */
const PLACEHOLDER_PATTERN = /change[-_]?me/i;
@@ -511,8 +555,8 @@ function readEnv(key) {
return value ? value : undefined;
}
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 */
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|MAIL_\w+|MAILPIT_\w+)$/;
/** 이 스크립트가 읽는 키 — 셸 값과 env 파일 값이 어긋나는지 검사할 대상 (BACKEND_PORT — 엔진 웹훅의 주소) */
const WATCHED_KEY = /^(APP_ENV|PROJECT_NAME|APP_NETWORK_SUBNET|BACKEND_PORT|MAIL_\w+|MAILPIT_\w+)$/;
/** Node --env-file 과 같은 규칙으로 읽는다 (따옴표 없는 값은 # 부터 주석 — 공백이 없어도) */
function parseEnvText(text) {
@@ -802,12 +846,47 @@ function loadConfig(argv) {
}
}
// 엔진 웹훅 서명 키 (12단계) — setup 과 backend 가 같은 값을 쓴다. 운영 필수, 개발은 비면 웹훅을 끈다(ensureEngineWebhook). 값은 어디에도 출력하지 않는다
const webhookSecretRaw = readEnv(WEBHOOK_SECRET_ENV);
let webhookSecret = null;
if (webhookSecretRaw) {
if (WEBHOOK_SECRET_PATTERN.test(webhookSecretRaw)) {
webhookSecret = webhookSecretRaw;
// 엔진 응답 · 오류 글에 실려 와도 출력에는 이름만 (redact)
hideInOutput(webhookSecret, `<${WEBHOOK_SECRET_ENV}>`);
} else {
errors.push(
`${WEBHOOK_SECRET_ENV} 는 영문 · 숫자로만 32자 이상이어야 합니다 (지금 ${webhookSecretRaw.length}자 — 값은 출력하지 않는다). ` +
'예: openssl rand -hex 24 — 바꾸면 backend 도 다시 띄운다.',
);
}
} else if (isProduction) {
errors.push(
`${WEBHOOK_SECRET_ENV} 가 설정되지 않았습니다 — 운영 필수(엔진 웹훅의 서명 키 · backend 와 같은 값 — 메일 프로그램 로그인 기록). ` +
'예: openssl rand -hex 24 (mail/README.md "엔진 웹훅").',
);
}
// 웹훅이 부를 백엔드 포트 — compose 의 backend PORT(=BACKEND_PORT)와 같다. 웹훅을 둘 때만 본다
const backendPortRaw = readEnv('BACKEND_PORT');
let backendPort = null;
if (webhookSecret) {
if (backendPortRaw && PORT_PATTERN.test(backendPortRaw) && Number(backendPortRaw) <= MAX_PORT) {
backendPort = Number(backendPortRaw);
} else {
errors.push(
`BACKEND_PORT 는 1~${MAX_PORT} 의 10진수(앞자리 0 없이)여야 합니다 — 엔진 웹훅의 주소 http://${WEBHOOK_HOST}:<BACKEND_PORT>${WEBHOOK_PATH} 에 쓴다 ` +
`(현재: ${backendPortRaw ?? '없음'}).`,
);
}
}
// 예시 값(change-me)이 남아 있으면 운영은 중단, 개발은 경고
const secrets = {
MAIL_ADMIN_PASSWORD: adminPassword,
MAIL_GATEWAY_PASSWORD: gatewayPassword,
MAIL_RECOVERY_ADMIN: recovery?.password,
MAIL_DEV_USER_PASSWORD: devUserPassword,
[WEBHOOK_SECRET_ENV]: webhookSecretRaw,
};
for (const [key, value] of Object.entries(secrets)) {
if (!value || !PLACEHOLDER_PATTERN.test(value)) continue;
@@ -937,6 +1016,9 @@ function loadConfig(argv) {
egressNetwork: `${projectName}_${RESOLVER_EGRESS_NETWORK}`,
},
spamhausKey,
// 엔진 웹훅 (12단계) — 키가 없으면(개발) 둘 다 null 이고 웹훅을 끈다 (ensureEngineWebhook)
webhookSecret,
webhookUrl: webhookSecret ? `http://${WEBHOOK_HOST}:${backendPort}${WEBHOOK_PATH}` : null,
attachmentTotalMb,
uploadQuotaFloor,
devUserPassword,
@@ -1394,6 +1476,13 @@ async function openClient(cfg, user, password) {
await res.body?.cancel();
return null;
}
if (res.status === HTTP_MFA_REQUIRED) {
// 비밀번호는 맞고 그 계정에 2단계 인증(OTP)이 켜져 있다 — 비밀번호만으로는 관리 API 에 들어갈 수 없다 (12단계)
await res.body?.cancel();
throw new SetupError(`${user} 에 2단계 인증(OTP)이 켜져 있어 비밀번호만으로 로그인할 수 없습니다 (HTTP ${HTTP_MFA_REQUIRED}).`, {
hint: 'setup 이 쓰는 계정(관리자 · 복구 관리자 · 게이트웨이)에는 2단계 인증을 켜지 않는다 — 관리 화면에서 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
});
}
if (!res.ok) throw new SetupError(`세션 조회 실패 (HTTP ${res.status})`);
const session = await res.json();
return { cfg, auth, session, accountId: session.primaryAccounts?.[STALWART_CAPABILITY] };
@@ -1863,11 +1952,18 @@ function passwordKey(account) {
/**
* 로그인 확인 — 2xx 면 맞고 401 이면 틀리다. 그 밖(429 시도 과다·5xx 등)은 판단할 수 없으므로 중단한다
* (틀린 것으로 보고 비밀번호를 덮어쓰거나, 맞는 것으로 보고 넘어가면 안 된다).
* 402 — 엔진은 맞는 비밀번호에만 "2단계 인증 코드가 더 필요하다"(402)를 주고 틀리면 401 이다(12단계). mfaMeansMatch(웹메일에서 2단계 인증을 켤 수 있는
* 테스트 직원)면 맞는 것으로 보고, 그 밖의 계정(게이트웨이)이면 중단한다 — 게이트웨이에 2단계 인증이 켜지면 백엔드의 대리 접근이 모두 막힌다
*/
async function passwordMatches(cfg, address, password) {
async function passwordMatches(cfg, address, password, { mfaMeansMatch = false } = {}) {
const status = await loginStatus(cfg, address, password);
if (status >= 200 && status < 300) return true;
if ((status >= 200 && status < 300) || (status === HTTP_MFA_REQUIRED && mfaMeansMatch)) return true;
if (status === 401) return false;
if (status === HTTP_MFA_REQUIRED) {
throw new SetupError(`${address} 에 2단계 인증(OTP)이 켜져 있습니다 (HTTP ${HTTP_MFA_REQUIRED}) — 아무것도 바꾸지 않고 중단합니다.`, {
hint: '이 계정에는 2단계 인증을 켜지 않는다 — 관리 화면 · 관리 API 로 그 계정의 OTP 를 지운 뒤 다시 실행하세요 (mail/README.md "휴대폰을 잃은 직원").',
});
}
throw new SetupError(`${address} 의 로그인 확인이 예상하지 못한 응답으로 끝났습니다 (HTTP ${status}). 아무것도 바꾸지 않았습니다.`, {
hint:
status === 429
@@ -1876,9 +1972,25 @@ async function passwordMatches(cfg, address, password) {
});
}
/**
* 게이트웨이 계정에 2단계 인증(OTP)이 켜져 있으면 아무것도 바꾸기 전에 멈춘다 (12단계). 대리 접근(<직원>%<게이트웨이>)은 게이트웨이 자신의
* 비밀번호 · OTP 를 확인하므로 백엔드의 대리 접근이 모두 402 로 막혀 웹메일 전체가 선다. 게이트웨이가 아직 없거나(처음 설치) 비밀번호가
* env 와 다르면 401 이라 지나간다(ensureAccount 가 맞춘다). 이 확인은 호스트(신뢰 대역)에서 하므로 자동 차단에 세지 않는다
*/
async function checkGatewayWithoutTwoFactor(cfg) {
if ((await loginStatus(cfg, cfg.gatewayAddress, cfg.gatewayPassword)) !== HTTP_MFA_REQUIRED) return;
throw new SetupError(
`게이트웨이 계정(${cfg.gatewayAddress})에 2단계 인증(OTP)이 켜져 있습니다 — 백엔드의 대리 접근이 모두 막혀(HTTP ${HTTP_MFA_REQUIRED}) 웹메일 전체가 선다. 아무것도 바꾸지 않고 중단합니다.`,
{
hint:
'게이트웨이의 OTP 를 지운 뒤 다시 실행하세요 — mail/README.md "휴대폰을 잃은 직원" 의 절차를 게이트웨이 주소로 한다. 게이트웨이에는 2단계 인증을 켜지 않는다.',
},
);
}
/**
* 계정을 env 값에 맞춘다. 저장된 비밀번호는 읽을 수 없으므로 실제 로그인으로 확인하고, 다르면 env 값으로 바꾼다.
* spec: { name, description, roles, password, allowedIps?, label }
* spec: { name, description, roles, password, allowedIps?, label, mfaMeansMatch? } — mfaMeansMatch 는 테스트 직원만 (passwordMatches)
*/
async function ensureAccount(client, cfg, domain, spec) {
const address = `${spec.name}@${domain.name}`;
@@ -1919,7 +2031,7 @@ async function ensureAccount(client, cfg, domain, spec) {
patch[`credentials/${key}/allowedIps`] = toTrueSet(spec.allowedIps);
changes.push('허용 IP');
}
if (!(await passwordMatches(cfg, address, spec.password))) {
if (!(await passwordMatches(cfg, address, spec.password, { mfaMeansMatch: spec.mfaMeansMatch === true }))) {
patch[`credentials/${key}/secret`] = spec.password;
changes.push('비밀번호');
}
@@ -3156,9 +3268,16 @@ async function checkGatewayAdminReach(admin, cfg) {
return reachable;
}
/** 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지 */
/**
* 개발 — 직원 계정으로 계정 목록(Principal/get)을 볼 수 없는지.
* 웹메일에서 2단계 인증을 켠 테스트 직원은 비밀번호만으로 로그인하지 못한다(402 — 12단계). 그때는 게이트웨이 대리 접근으로 본다 —
* 대리 접근 세션의 권한은 그 직원의 역할 그대로다(본인 Basic 과 같은 231개 — 2026-10-31 개발 엔진)
*/
async function checkPrincipalHidden(cfg, address) {
const client = await openClient(cfg, address, cfg.devUserPassword);
const viaGateway = (await loginStatus(cfg, address, cfg.devUserPassword)) === HTTP_MFA_REQUIRED;
const client = viaGateway
? await openClient(cfg, `${address}%${cfg.gatewayAddress}`, cfg.gatewayPassword)
: await openClient(cfg, address, cfg.devUserPassword);
const accountId = client?.session.primaryAccounts?.[MAIL_CAPABILITY];
if (!client || !accountId) {
problems.push(`${address} 로 로그인하지 못해 계정 목록 비공개를 확인하지 못했습니다.`);
@@ -3177,7 +3296,8 @@ async function checkPrincipalHidden(cfg, address) {
});
[[name, result]] = (await res.json()).methodResponses;
if (name === 'error' && result.type === 'forbidden') {
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden).`);
const how = viaGateway ? ' — 2단계 인증이 켜져 있어 게이트웨이 대리 접근으로 봄' : '';
log(`[확인] 직원(${address})은 계정 목록을 볼 수 없습니다 (Principal/get → forbidden${how}).`);
return;
}
await sleep(POLL_INTERVAL_MS / 2);
@@ -3284,6 +3404,236 @@ async function printDnsZone(client, domain) {
log('-----');
}
// ============================================================
// 엔진 웹훅 — 메일 프로그램 로그인 기록 (12단계 — WEBHOOK_SECRET_ENV 머리말 · mail/README.md "엔진 웹훅")
// ============================================================
function webhookKeyFingerprint(secret, salt) {
return scryptSync(secret, salt, WEBHOOK_KEY_FINGERPRINT_BYTES).toString('hex');
}
/** 새 표시 머리글 값 — 쓸 때마다(만들기 · 고치기) 새 소금으로 키 지문을 적는다(엔진은 서명 키를 **** 로만 돌려준다 — WEBHOOK_KEY_SALT_BYTES) */
function newWebhookMarker(secret) {
const salt = randomBytes(WEBHOOK_KEY_SALT_BYTES).toString('hex');
return `${MANAGED_MARK} key=${salt}.${webhookKeyFingerprint(secret, salt)}`;
}
/** 엔진의 표시 머리글이 지금 키의 지문인가 — 머리글의 소금으로 다시 계산해 견준다. 옛 모양 · 다른 키면 false */
function webhookMarkerMatches(value, secret) {
const match = WEBHOOK_MARKER_PATTERN.exec(String(value ?? ''));
return Boolean(match) && webhookKeyFingerprint(secret, match[1]) === match[2];
}
/** setup 이 둔 웹훅인가 — 표시 머리글(키 지문과 상관없이 [setup.mjs] 로 시작) */
function isOwnWebhook(hook) {
return String(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER] ?? '').startsWith(MANAGED_MARK);
}
/** 웹훅 주소의 경로 — 주소가 아니면 null */
function webhookUrlPath(url) {
try {
return new URL(url).pathname;
} catch {
return null;
}
}
/** setup 이 두는 웹훅 — 만들기 · 고치기 모두 이 값 전체를 보낸다 (events · httpHeaders 는 통째로 바뀐다 · 표시 머리글은 새 소금) */
function desiredWebhook(cfg) {
return {
enable: true,
url: cfg.webhookUrl,
signatureKey: { '@type': 'Value', secret: cfg.webhookSecret },
httpAuth: WEBHOOK_HTTP_AUTH,
httpHeaders: { [WEBHOOK_MARKER_HEADER]: newWebhookMarker(cfg.webhookSecret) },
events: toTrueSet(WEBHOOK_EVENTS),
...WEBHOOK_SETTINGS,
};
}
/**
* 엔진의 웹훅이 setup 의 값과 다른 칸 — 같으면 빈 배열. 서명 키는 형식(Value)과 표시 머리글의 키 지문으로 본다 —
* ⚠️ 관리 화면에서 서명 키만 바꾸면(머리글은 그대로) 알아채지 못한다. 엔진은 키를 **** 로만 돌려주기 때문이다
*/
function webhookDrift(hook, cfg) {
const drift = [];
if (hook.enable !== true) drift.push('꺼짐');
if (hook.url !== cfg.webhookUrl) drift.push('주소');
const keyChanged =
hook.signatureKey?.['@type'] !== 'Value' || !webhookMarkerMatches(hook.httpHeaders?.[WEBHOOK_MARKER_HEADER], cfg.webhookSecret);
if (keyChanged) drift.push('서명 키');
if (!keyChanged && stableJson(Object.keys(hook.httpHeaders ?? {})) !== stableJson([WEBHOOK_MARKER_HEADER])) drift.push('HTTP 머리글');
if (stableJson(hook.httpAuth ?? null) !== stableJson(WEBHOOK_HTTP_AUTH)) drift.push('HTTP 인증');
if (!sameTrueSet(hook.events, WEBHOOK_EVENTS)) drift.push('이벤트');
for (const [key, value] of Object.entries(WEBHOOK_SETTINGS)) {
if (hook[key] !== value) drift.push(key);
}
return drift;
}
/**
* 엔진 웹훅을 맞춘다 — setup 표시로 찾아 없으면 만들고, 다르면 고치고, 같으면 둔다. 저장만 한다(main 의 리로드 뒤에 반영 — checkEngineWebhook 이 본다).
* 키가 없으면(개발 — 운영은 loadConfig 가 이미 멈췄다) 만들지 않고 있던 것은 끈다. 표시 없이 같은 받는 곳으로 가는 웹훅은 사람이 만든 것으로 보고 [주의] 만 낸다.
* 돌려주는 값: { state: 'ready' | 'disabled' | 'absent', id, keyChanged } — keyChanged 는 이번 실행에서 엔진에 서명 키를 넣었는가(만들기 · 키가 바뀜)
*/
async function ensureEngineWebhook(client, cfg) {
const hooks = await listAll(client, 'WebHook', [
'enable',
'url',
'signatureKey',
'httpAuth',
'httpHeaders',
'events',
'eventsPolicy',
'level',
'throttle',
'timeout',
'discardAfter',
'lossy',
'allowInvalidCerts',
]);
const own = hooks.filter(isOwnWebhook);
const lookalikes = hooks.filter((hook) => !isOwnWebhook(hook) && webhookUrlPath(hook.url) === WEBHOOK_PATH);
if (lookalikes.length > 0) {
warn(
`백엔드의 웹훅 받는 곳(${WEBHOOK_PATH})으로 가는 웹훅이 setup 표시 없이 ${lookalikes.length}개 있습니다(id ${lookalikes.map((hook) => hook.id).join(', ')}) — ` +
'사람이 만든 것으로 보고 건드리지 않습니다. 같은 이벤트가 두 번 가거나 다른 키로 서명돼 백엔드가 거절한다 — 관리 화면에서 지우세요 (mail/README.md "엔진 웹훅").',
);
}
if (!cfg.webhookSecret) {
warn(
`${WEBHOOK_SECRET_ENV} 가 비어 있어 엔진 웹훅을 두지 않습니다 — 메일 프로그램(IMAP · SMTP) 로그인 기록과 앱 비밀번호의 마지막 사용이 남지 않는다. ` +
'무작위 값(openssl rand -hex 24)을 넣고 setup 을 다시 실행한 뒤 backend 를 다시 띄운다 (mail/README.md "엔진 웹훅").',
);
const enabled = own.filter((hook) => hook.enable !== false);
if (enabled.length > 0) {
await setObjects(client, 'WebHook', { update: Object.fromEntries(enabled.map((hook) => [hook.id, { enable: false }])) });
log(`[변경] 엔진 웹훅 — 끔 (${WEBHOOK_SECRET_ENV} 비어 있음 · ${enabled.length}개)`);
}
return { state: own.length > 0 ? 'disabled' : 'absent', id: null, keyChanged: false };
}
const target = `${cfg.webhookUrl} (${WEBHOOK_EVENTS.join(' · ')} — 서명 키 ${WEBHOOK_SECRET_ENV})`;
if (own.length === 0) {
const result = await setObjects(client, 'WebHook', { create: { webhook: desiredWebhook(cfg) } });
log(`[생성] 엔진 웹훅 → ${target}`);
return { state: 'ready', id: result.created.webhook.id, keyChanged: true };
}
const [hook, ...extra] = own;
if (extra.length > 0) {
// 같은 이벤트가 여러 번 간다 — 하나만 둔다
await setObjects(client, 'WebHook', { destroy: extra.map((item) => item.id) });
log(`[삭제] 엔진 웹훅 — setup 표시가 같은 것 ${extra.length}개 (하나만 둔다)`);
}
const drift = webhookDrift(hook, cfg);
if (drift.length === 0) {
log(`[유지] 엔진 웹훅 → ${target}`);
} else {
await setObjects(client, 'WebHook', { update: { [hook.id]: desiredWebhook(cfg) } });
log(`[변경] 엔진 웹훅 — ${drift.join(', ')} → ${target}`);
}
return { state: 'ready', id: hook.id, keyChanged: drift.includes('서명 키') };
}
/**
* 메일 컨테이너 안(엔진과 같은 자리 · 같은 시스템 DNS)에서 받는 곳으로 서명한 빈 묶음(WEBHOOK_PROBE_BODY)을 보낸다 — { code, error }.
* 서명은 표준 입력으로 넘긴다(curl -H @-) — 키로 만든 값이 docker exec 의 명령줄(도커 이벤트 · ps)에 남지 않게. docker 를 쓸 수 없으면 null
*/
function postWebhookProbeInsideMail(cfg) {
if (!cfg.docker) return null;
const signature = createHmac('sha256', cfg.webhookSecret).update(WEBHOOK_PROBE_BODY).digest('base64');
const result = spawnSync(
'docker',
[
'exec',
'-i',
cfg.container,
'curl',
'-sS',
'-o',
'/dev/null',
'-w',
'%{http_code}',
'--max-time',
'5',
'-H',
'@-',
'-H',
'content-type: application/json',
'--data-binary',
WEBHOOK_PROBE_BODY,
cfg.webhookUrl,
],
{ input: `X-Signature: ${signature}\n`, encoding: 'utf8' },
);
if (result.error) return { code: null, error: result.error.message };
const code = Number(result.stdout?.trim());
return {
code: Number.isInteger(code) && code > 0 ? code : null,
error: result.status === 0 ? null : (result.stderr ?? '').trim().split(/\r?\n/).pop()?.slice(0, 200) || `종료 코드 ${result.status}`,
};
}
/**
* 리로드 뒤 — 엔진에 남은 웹훅이 setup 의 값 그대로인지 다시 읽고, 받는 곳까지 닿는지 · 백엔드의 키가 맞는지 메일 컨테이너에서 서명한 빈 묶음으로 본다.
* 2xx 면 [확인] — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다는 뜻이다. 엔진에 든 키는 읽을 수 없어 표시 머리글의 지문으로만 안다(webhookDrift).
* 401(키 · 접속 주소가 맞지 않음) · 404(받는 곳이 없는 백엔드)는 운영은 실패, 개발은 [주의](backend 를 다시 띄우기 전일 수 있다).
* 다만 이번 실행에서 엔진에 키를 넣었으면(webhook.keyChanged — 키를 바꿈 · 처음 만듦) backend 는 아직 옛 키일 수 있어 401 도 [주의] + 다음 명령이다.
* 닿지 않으면(백엔드가 아직 뜨는 중 등) 몇 번 다시 해 보고 [주의] — 다시 실행하면 된다
*/
async function checkEngineWebhook(client, cfg, webhook) {
if (webhook.state !== 'ready') return;
const [hook] = (await call(client, 'x:WebHook/get', { ids: [webhook.id] })).list ?? [];
const drift = hook ? webhookDrift(hook, cfg) : ['없어짐'];
if (drift.length > 0) {
problems.push(`엔진 웹훅이 리로드 뒤 setup 의 값과 다릅니다(${drift.join(', ')}) — 그사이 관리 화면 등에서 바꿨는지 보고 다시 실행하세요.`);
return;
}
log(`[확인] 엔진 웹훅 켜짐 — ${cfg.webhookUrl} · ${WEBHOOK_EVENTS.join(' · ')} · 수준 trace (리로드 뒤 다시 읽음)`);
let probe = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
probe = postWebhookProbeInsideMail(cfg);
if (!probe || probe.code !== null) break;
await sleep(POLL_INTERVAL_MS);
}
const compose = cfg.isProduction
? 'sudo docker compose --env-file .env.production'
: 'docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development';
// 401 — 새 키로 다시 만든다(compose 의 environment). 404 — 새 코드가 떠야 한다: 운영은 이미지를 다시 만들고(frontend 의 nginx 스니펫도 함께), 개발은 소스가 붙어 있어 다시 띄우기만
const restartBackend = `${compose} up -d backend`;
const reloadBackendCode = cfg.isProduction ? `${compose} up -d --build backend frontend` : `${compose} restart backend`;
const fail = (message) => (cfg.isProduction ? problems.push(message) : warn(message));
if (!probe) {
log('[건너뜀] docker 를 쓸 수 없어 엔진 웹훅이 받는 곳까지 닿는지 확인하지 못했습니다.');
} else if (probe.code !== null && probe.code >= 200 && probe.code < 300) {
log(`[확인] 엔진 웹훅 — 메일 컨테이너에서 서명한 빈 묶음을 백엔드가 받았다 (HTTP ${probe.code} — 백엔드 쪽(받는 곳 · 접속 주소 · 키)이 맞다)`);
} else if (probe.code === 401 && webhook.keyChanged) {
warn(
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — 이번 실행에서 엔진에 ${WEBHOOK_SECRET_ENV} 를 넣었고 backend 는 아직 옛 키(또는 키 없음)로 떠 있다. ` +
`backend 를 다시 띄운 뒤(${restartBackend}) setup 을 한 번 더 실행해 [확인] 을 본다 — 그 사이 메일 프로그램 로그인 기록은 빠질 수 있다 (mail/README.md "엔진 웹훅").`,
);
} else if (probe.code === 401) {
fail(
`엔진 웹훅의 받는 곳이 서명한 묶음을 거절했습니다(HTTP 401) — backend 의 ${WEBHOOK_SECRET_ENV} 가 setup 과 다르거나(키를 바꾼 뒤 backend 를 다시 띄우지 않음) ` +
`backend 가 엔진의 접속 주소를 앱 네트워크(APP_NETWORK_SUBNET)로 보지 못한다. backend 를 다시 띄운 뒤(${restartBackend}) setup 을 다시 실행하세요.`,
);
} else if (probe.code === 404) {
fail(
`엔진 웹훅의 받는 곳(${WEBHOOK_PATH})이 백엔드에 없습니다(HTTP 404) — 12단계 전의 backend 가 떠 있다. ` +
`새 코드로 backend 를 띄운 뒤(${reloadBackendCode}) setup 을 다시 실행하세요.`,
);
} else if (probe.code !== null) {
warn(`엔진 웹훅의 받는 곳이 서명한 빈 묶음에 HTTP ${probe.code} 로 답했습니다 — 200 이어야 한다 (백엔드 로그를 본다: docker logs ${cfg.backendContainer}).`);
} else {
warn(
`메일 컨테이너에서 엔진 웹훅의 받는 곳(${cfg.webhookUrl})에 닿지 못했습니다(${probe.error}) — backend 가 뜨는 중이면 잠시 뒤 setup 을 다시 실행한다. ` +
`그동안의 이벤트는 엔진이 5분까지 다시 보낸다 (docker logs ${cfg.backendContainer}).`,
);
}
}
// ============================================================
// 시범 공존 — 기존 서버(mailcow)와 함께 (PILOT_HOST_ENV_KEY 머리말 · mail/README.md "파일럿 공존")
// ============================================================
@@ -4469,12 +4819,28 @@ function authenticatedRcptFromBackend(cfg, user, recipient) {
/**
* 개발 — 인증한 세션(465, 테스트 직원)은 이 엔진에 없는 사내 주소를 받는가 (RCPT 250 뒤 RSET — 보내지 않는다).
* 웹메일(JMAP 제출)도 같은 RCPT 를 지나 기존 서버로 간다. 운영은 직원 비밀번호가 없어 하지 않는다 (mail/README.md "파일럿 공존" 의 확인 명령으로 본다).
* candidates — 테스트 직원 주소들. 2단계 인증을 켠 직원은 비밀번호만으로 SMTP AUTH 를 하지 못한다("334 5.7.8 … multi-factor" — 12단계) — 비밀번호
* 로그인이 402 가 아닌 첫 직원을 고르고, 모두 켜져 있으면 [건너뜀] 이다(allowRelaying 문제로 잘못 알리지 않게)
*/
async function checkPilotAuthenticatedRelay(cfg, user) {
async function checkPilotAuthenticatedRelay(cfg, candidates) {
if (!containerNetworks(cfg.backendContainer)) {
warn(`${cfg.backendContainer} 가 없어 인증한 세션의 확인(465 RCPT)을 건너뜁니다 — 백엔드를 띄운 뒤 다시 실행하면 확인한다.`);
return;
}
let user = null;
for (const address of candidates) {
if ((await loginStatus(cfg, address, cfg.devUserPassword)) !== HTTP_MFA_REQUIRED) {
user = address;
break;
}
}
if (!user) {
log(
`[건너뜀] 시범 공존 — 인증한 세션의 확인(${DEV_SUBMISSION_PORT} RCPT): 테스트 직원(${candidates.join(', ')})이 모두 2단계 인증을 켜 비밀번호만으로 SMTP AUTH 를 할 수 없다 — ` +
'한 사람의 2단계 인증을 끄면 다음 실행이 확인한다.',
);
return;
}
const probe = pilotProbeAddress(cfg);
let reply = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
@@ -4539,6 +4905,8 @@ async function main() {
}
await waitForHttp(cfg, STARTUP_TIMEOUT_MS);
// 게이트웨이에 2단계 인증이 켜져 있으면 웹메일 전체가 선다 — 아무것도 바꾸기 전에 멈춘다 (12단계)
await checkGatewayWithoutTwoFactor(cfg);
// 앱 네트워크 · 메일 전용 리졸버 확인(아무것도 바꾸기 전 — 어긋나면 멈춘다)과 Spamhaus 키 시험
const dnsCheck = preflight(cfg);
const admin = await ensureAdmin(cfg);
@@ -4594,6 +4962,8 @@ async function main() {
// 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영)
const engineDnsChanged = await ensureEngineDns(admin, cfg);
const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus);
// 엔진 웹훅 — 인증 이벤트를 백엔드로 (메일 프로그램 로그인 기록 — 12단계, 아래 리로드로 반영 · 키가 없으면(개발) 끈다)
const webhook = await ensureEngineWebhook(admin, cfg);
// 개발은 외부 발송 경로를 Mailpit 으로 — 메일을 보낼 수 있는 계정을 만들기 전에 리로드까지 끝낸다
if (!cfg.isProduction) await ensureDevRelay(admin, keepPilotRoute);
await reloadSettings(admin);
@@ -4612,6 +4982,7 @@ async function main() {
if (systemRecipientsRejected) await checkSystemRecipientsRejected(cfg, systemRecipients);
if (pilot) await checkPilotSenderGuard(cfg);
checkEngineDnssec(cfg, engineDnsChanged);
await checkEngineWebhook(admin, cfg, webhook);
const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg);
const devUsers = [];
@@ -4627,13 +4998,15 @@ async function main() {
description: user.description,
roles: { '@type': 'User' },
password: cfg.devUserPassword,
// 웹메일에서 2단계 인증을 켠 테스트 직원(402)은 비밀번호가 맞는 것으로 본다 — 덮어쓰지 않는다 (12단계)
mfaMeansMatch: true,
});
devUsers.push(`${address} (${user.description})`);
}
await checkImpersonation(cfg, `${DEV_USERS[0].name}@${domain.name}`);
await checkPrincipalHidden(cfg, `${DEV_USERS[0].name}@${domain.name}`);
// 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에)
if (pilot) await checkPilotAuthenticatedRelay(cfg, `${DEV_USERS[0].name}@${domain.name}`);
// 시범 공존 — 인증한 세션은 엔진에 없는 사내 주소를 받는가 (테스트 직원이 있어야 하므로 계정을 맞춘 뒤에 · 2단계 인증이 꺼진 직원으로)
if (pilot) await checkPilotAuthenticatedRelay(cfg, DEV_USERS.map((user) => `${user.name}@${domain.name}`));
}
if (cfg.isProduction || cfg.printDns) await printDnsZone(admin, domain);
@@ -4669,6 +5042,11 @@ async function main() {
const how = cfg.isProduction ? '--recovery 로 한 번 실행하면 만들고 맞춘다' : `${RECOVERY_ENV_KEY} 가 있어야 만들고 맞춘다`;
log(` 사내 목록 ${directory.address} — 준비되지 않음(위 [주의] · [실패]) · ${how} — ${directoryFallback}`);
}
if (webhook.state === 'ready') {
log(` 웹훅 ${cfg.webhookUrl} — ${WEBHOOK_EVENTS.join(' · ')} · 서명 키 ${WEBHOOK_SECRET_ENV} (메일 프로그램 로그인 기록)`);
} else {
log(` 웹훅 꺼짐 (${WEBHOOK_SECRET_ENV} 비어 있음 — 메일 프로그램 로그인 기록이 남지 않는다)`);
}
if (gatewayReachesAdmin) {
log(' 남은 위험 게이트웨이는 관리자도 대리 접근할 수 있다 — 게이트웨이 비밀번호 = 관리자급 (백엔드가 관리자 계정을 막는다, mail/README.md "보안")');
}