From d1ef0da488995610f0fd9e8d2215034808832b41 Mon Sep 17 00:00:00 2001 From: ttipo Date: Sat, 31 Oct 2026 17:45:23 +0900 Subject: [PATCH] =?UTF-8?q?feat(docker):=2012=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EB=B3=B4=EC=95=88=20=E2=80=94=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EC=9B=B9=ED=9B=85=20=C2=B7=20/api/internal=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=C2=B7=20=EC=9A=94=EC=B2=AD=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20400?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 메일 프로그램(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 --- .env.development.example | 4 + .env.production.example | 6 + .github/workflows/ci.yml | 76 +++++ docker-compose.yml | 6 + frontend/Dockerfile | 4 +- frontend/nginx-api-errors.conf | 17 +- frontend/nginx-internal-api-closed.conf | 21 ++ frontend/nginx-share-api-closed.conf | 4 +- frontend/nginx.conf | 39 ++- mail/README.md | 177 ++++++++++- mail/scripts/setup.mjs | 404 +++++++++++++++++++++++- 11 files changed, 739 insertions(+), 19 deletions(-) create mode 100644 frontend/nginx-internal-api-closed.conf diff --git a/.env.development.example b/.env.development.example index ffc1a83..110a8af 100644 --- a/.env.development.example +++ b/.env.development.example @@ -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 diff --git a/.env.production.example b/.env.production.example index a8d2249..be63457 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 스킬 "운영 환경") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fea8973..c957839 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 — diff --git a/docker-compose.yml b/docker-compose.yml index ee91c1b..9e7ba68 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 3b759b1..24fe2a6 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다. diff --git a/frontend/nginx-api-errors.conf b/frontend/nginx-api-errors.conf index 5599614..84655e3 100644 --- a/frontend/nginx-api-errors.conf +++ b/frontend/nginx-api-errors.conf @@ -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; diff --git a/frontend/nginx-internal-api-closed.conf b/frontend/nginx-internal-api-closed.conf new file mode 100644 index 0000000..aae1d07 --- /dev/null +++ b/frontend/nginx-internal-api-closed.conf @@ -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; +} diff --git a/frontend/nginx-share-api-closed.conf b/frontend/nginx-share-api-closed.conf index cdce83b..7a424e2 100644 --- a/frontend/nginx-share-api-closed.conf +++ b/frontend/nginx-share-api-closed.conf @@ -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; diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 339643b..7cd1d12 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; } diff --git a/mail/README.md b/mail/README.md index 8e30445..dd77532 100644 --- a/mail/README.md +++ b/mail/README.md @@ -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:/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_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:/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::8080 <메일 호스트>` → , [운영 배포](#운영-배포) 아래 "실제 직원 계정" 줄) — **직접 눌러 보지 못했다**: + +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 에 그대로 둔다. diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index fd50e90..cbebeaa 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -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}:${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 "보안")'); }