feat(docker): 대용량 첨부 — ClamAV 검사기 · 공개 내려받기 호스트 · 받기 동시 연결 상한

- ClamAV 1.4.6 LTS 를 둘로 나눴다: clamd 는 백엔드와만 닿는 internal 네트워크(바깥 길 없음 · 서명 볼륨 읽기 전용),
  freshclam 은 자기 바깥 길에만. 비-root · cap_drop ALL. 운영은 늘 뜨고 개발은 profile clamav.
  clamd.conf 는 한 파일 한도 2047M 까지 끝까지 검사하고 암호 · 한도 초과를 판정으로 알린다(실측표는 mail/README).
  검사 중에 죽으면 남던 임시 파일을 띄울 때 지운다.
- nginx 를 포털 · 공개 내려받기(files.) 두 server 로 가른다(SHARE_PUBLIC_URL 과 CORS_ORIGIN 의 호스트 비교 — 같으면 포털이 함께 연다).
  조각 PUT 17m, 받기는 버퍼링 · 압축 끔 + X-Accel-Buffering 넘김 + 머리글 버퍼 16k,
  limit_conn 은 X-Forwarded-For 마지막 칸으로 IP 마다 공개 10 · 앱 안 30, 전체 공개 400 · 앱 안 200.
  받기 location 은 /api/ 블록 안의 대소문자를 가리지 않는 정규식이다(리뷰 — /FILES/ 같은 변형이 상한을 비켜 갔다).
  접근 로그는 경로 모양과 43자 토큰 모양으로 토큰을 가린다.
- 파일은 named volume <PROJECT_NAME>_files(개발도 — builder 단계가 /app/files 를 node 소유로 만든다), 백업하지 않는다.
- CI infra 잡: nginx 두 꼴 · 받기 경로 변형 시험(가짜 백엔드) · clamconf(-n 없이 기본값까지) · 틀린 SHARE_PUBLIC_URL 거부.
- mail/README: 악성 검사 · 공개 호스트 · NPM 칸(Advanced 다섯 줄) · 메모리 예산 · 문제 해결.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 00:37:21 +09:00
parent c5cc1a6705
commit 93147b88b7
19 changed files with 1176 additions and 103 deletions
+32
View File
@@ -91,6 +91,8 @@ DB_SYNCHRONIZE=false
# mail 메일 엔진 + Mailpit (기본) → COMPOSE_PROFILES=mail
# redis 캐시·세션 공유가 필요해질 때 → COMPOSE_PROFILES=mail,redis
# frontend vite 를 컨테이너로 띄워야 할 때 → COMPOSE_PROFILES=mail,frontend
# clamav 대용량 첨부의 악성 검사기(ClamAV — 메모리 1GB 남짓 · 처음에 서명 110MB 를 받는다)
# → COMPOSE_PROFILES=mail,clamav 와 함께 아래 "대용량 첨부" 의 CLAMAV_HOST=clamav (운영은 profile 없이 늘 뜬다)
#
# 일회성으로 켜려면: docker compose --profile redis ... up -d
#
@@ -108,6 +110,36 @@ COMPOSE_PROFILES=mail
UPLOAD_DATA_DIR=./data/uploads
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)")
#
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 메일 엔진이 아니라 백엔드의 볼륨(<PROJECT_NAME>_files — 개발도 named volume)에 올리고,
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다 — 비워 두어도 그 값이다.
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처(스킴 · 호스트[· 포트] — 경로 · 끝의 / 없이). 개발은 비운다 = CORS_ORIGIN 의 첫 주소(포털과 같은 호스트).
# 운영은 포털과 다른 호스트다(.env.production.example)
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 개발은 Docker Desktop 가상 디스크의 여유를 본다
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd). 개발은 비워 둔다 = 검사하지 않고 다 올리자마자 "검사하지 못함".
# 켜려면 COMPOSE_PROFILES=mail,clamav 와 함께 CLAMAV_HOST=clamav (compose 의 clamav 서비스, 포트 3310)
# ==========================================
SHARE_PUBLIC_URL=
SHARE_REPORT_ADDRESS=
SHARE_DOWNLOAD_LIMIT=500
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
MAIL_LARGE_FILE_MAX_COUNT=10
MAIL_LARGE_FILE_USER_QUOTA_GB=10
MAIL_LARGE_FILE_RETENTION_DAYS=30
FILE_STORAGE_MAX_TOTAL_GB=
FILE_STORAGE_MIN_FREE_GB=10
CLAMAV_HOST=
CLAMAV_PORT=3310
# ==========================================
# 메일 엔진 (Stalwart) — profile `mail`
#
+35 -1
View File
@@ -77,6 +77,8 @@ REDIS_DATA_DIR=./data/redis
# true·0·false 는 운영에서 기동이 거부된다.
# ==========================================
BACKEND_API_URL=https://portal.arasystem.it.kr/api
# ⚠️ 대용량 첨부의 공개 내려받기 호스트(아래 SHARE_PUBLIC_URL — files.)는 여기에 넣지 않는다 — 넣으면 공개 호스트 분리가 꺼져
# 포털에서도 공개 링크가 열린다(백엔드가 운영에서는 기동을 막는다). 공개 화면은 자기 호스트의 /api/public-links/ 만 부른다
CORS_ORIGIN=https://portal.arasystem.it.kr
TRUST_PROXY=2
@@ -100,7 +102,7 @@ DB_SYNCHRONIZE=false
# 선택적 서비스 (Compose profiles)
#
# 기본값 = mail — 메일 엔진(Stalwart)을 띄운다. 직원 로그인이 메일 엔진에 의존한다.
# (redis 는 기동하지 않는다)
# (redis 는 기동하지 않는다. 대용량 첨부의 악성 검사기 clamav · clamav-updater 는 profile 없이 늘 뜬다 — 아래 "대용량 첨부")
# 캐시·세션 공유가 필요해지면 redis 를 추가하면 컨테이너가 함께 뜬다.
# COMPOSE_PROFILES=mail,redis
# 일회성으로 켜려면: docker compose --profile redis ... up -d
@@ -133,6 +135,38 @@ FRONTEND_PRIVATE_BIND=
# ==========================================
UPLOAD_MAX_SIZE_MB=5
# ==========================================
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절, mail/README.md "악성 검사 (ClamAV)" · "대용량 첨부 — 운영")
#
# 일반 첨부 한도(아래 MAIL_ATTACHMENT_*)를 넘는 파일은 백엔드의 볼륨(<PROJECT_NAME>_files — 30일짜리 전송물이라 백업하지 않는다)에 올리고,
# 메일에는 받는 사람이 로그인 없이 여는 내려받기 링크를 넣는다. 아래 값은 백엔드 기본값과 같다(SHARE_PUBLIC_URL · CLAMAV_HOST 는 운영 필수).
# SHARE_PUBLIC_URL : 공개 내려받기 화면의 출처 — **운영 필수**, https, 경로 · 끝의 / 없이. 포털과 다른 호스트로 둔다:
# 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지 차단 목록에 오르는 위험을 떼어 놓고, 포털 쿠키도 그 호스트로 가지 않는다.
# DNS(가비아 files A → 외부 리버스 프록시 서버) · 외부 프록시의 Proxy Host(같은 사설 IP:8080, 접근 로그 끔)가 있어야 한다.
# ⚠️ 이 호스트를 위 CORS_ORIGIN 에 넣지 않는다(넣으면 분리가 꺼진다 — 백엔드가 기동을 막는다).
# ⚠️ 한 번 정하면 바꾸지 않는다 — 이미 보낸 메일의 링크가 이 주소다(옛 호스트가 남아 있어야 열린다)
# SHARE_REPORT_ADDRESS : 공개 화면의 신고 · 문의 주소 (비우면 postmaster@<MAIL_DOMAIN>)
# SHARE_DOWNLOAD_LIMIT : 파일마다 내려받기 상한(받기 시작한 수, 1~100000)
# MAIL_LARGE_FILE_MAX_SIZE_MB : 한 파일(MB, 1~2047 — 악성 검사기가 끝까지 검사할 수 있는 2GiB 아래, MAIL_ATTACHMENT_MAX_SIZE_MB 이상)
# MAIL_LARGE_FILE_MAX_COUNT : 메일 한 통에 새로 싣는 대용량 파일 수(1~20)
# MAIL_LARGE_FILE_USER_QUOTA_GB : 직원마다 동시에 보관하는 양(GB, 1~1000 — 올리는 중 · 임시 저장 포함)
# MAIL_LARGE_FILE_RETENTION_DAYS : 보낸 날(한국)부터 받을 수 있는 날 수(1~90)
# FILE_STORAGE_MAX_TOTAL_GB : 서버 전체 보관량(GB). 비우면 한도 없음 — 디스크 여유만 본다. 디스크를 메일 · DB 와 함께 쓰면 정해 둔다
# FILE_STORAGE_MIN_FREE_GB : 올리기를 만든 뒤에도 남길 디스크 여유(GB) — 도커 data-root 의 디스크(볼륨 · 악성 검사의 임시 파일이 함께 쓴다)
# CLAMAV_HOST · CLAMAV_PORT : 악성 검사기(clamd) — **운영 필수**. compose 의 clamav 서비스(호스트 이름만 — 스킴 · 포트 없이)
# ==========================================
SHARE_PUBLIC_URL=https://files.arasystem.it.kr
SHARE_REPORT_ADDRESS=
SHARE_DOWNLOAD_LIMIT=500
MAIL_LARGE_FILE_MAX_SIZE_MB=2047
MAIL_LARGE_FILE_MAX_COUNT=10
MAIL_LARGE_FILE_USER_QUOTA_GB=10
MAIL_LARGE_FILE_RETENTION_DAYS=30
FILE_STORAGE_MAX_TOTAL_GB=
FILE_STORAGE_MIN_FREE_GB=10
CLAMAV_HOST=clamav
CLAMAV_PORT=3310
# ==========================================
# 메일 엔진 (Stalwart) — profile `mail`
#
+122 -1
View File
@@ -180,14 +180,132 @@ jobs:
run: |
tag="ci-nginx-config:${{ github.run_id }}"
docker build --target nginx-config -t "$tag" frontend
# 공개 내려받기 호스트(11단계)를 따로 두지 않을 때 — 포털이 /s/ · /api/public-links/ 를 연다(nginx-share-open.conf · nginx-share-api-open.conf)
docker run --rm -e PROJECT_NAME=ci "$tag" nginx -t
# 잘못된 값은 기동을 멈춰야 한다 (frontend/nginx-env.envsh — 앞자리 0, 경로가 붙은 백엔드 주소)
# 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다
docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -t
rendered=$(docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag" nginx -T 2>/dev/null)
for want in 'server_name files.ci.test;' 'include /etc/nginx/snippets/share-closed.conf;' 'include /etc/nginx/snippets/share-api-closed.conf;' \
'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
# 잘못된 값은 기동을 멈춰야 한다 (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
fi
if docker run --rm -e NGINX_BACKEND_UPSTREAM=http://ci-backend:3000/ "$tag" nginx -t; then
echo "::error::경로가 붙은 NGINX_BACKEND_UPSTREAM 을 받아들였습니다 (거부해야 한다)"; exit 1
fi
for value in https://files.ci.test/ https://files.ci.test/s files.ci.test 'https://files.ci.test;return 200' https://files.ci.test.; do
if docker run --rm -e PROJECT_NAME=ci -e "SHARE_PUBLIC_URL=$value" "$tag" nginx -t; then
echo "::error::SHARE_PUBLIC_URL=$value 를 받아들였습니다 (거부해야 한다)"; exit 1
fi
done
# 대용량 받기 경로(11단계)를 실제 요청으로 본다 — 백엔드(Express)는 경로의 대소문자를 가리지 않아, 받기 location(동시 연결 수 · 버퍼링 끔 ·
# X-Accel-Buffering 넘김 · 머리글 버퍼 16k)과 포털이 닫은 공개 API 는 대소문자 · %2F · 겹 / 변형도 같은 곳을 타야 한다 (frontend/nginx.conf 머리말).
# 위 단계의 이미지에 가짜 백엔드(같은 컨테이너의 127.0.0.1:3000 — 늘 X-Accel-Buffering: no 를 붙인다. ?hold=1 이면 10MB 를 1KB/s 로,
# ?pad=1 이면 머리글 6KB)를 더해 네트워크 없이 띄운다. 그 머리글은 받기 location 을 탄 요청만 클라이언트까지 온다 — 그것으로 location 을 가른다
- name: 대용량 받기 경로 시험
run: |
base="ci-nginx-config:${{ github.run_id }}"
tag="ci-nginx-paths:${{ github.run_id }}"
name="ci-nginx-paths-${{ github.run_id }}"
ctx=$(mktemp -d)
cat > "$ctx/paths.sh" <<'EOF'
#!/bin/sh
P=http://127.0.0.1:8080
T=AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcd1
F=11111111-2222-4333-8444-555555555555
fails=0
# host path → "상태 xab|-"
probe() {
timeout 10 wget -S -O /dev/null --header "Host: $1" "$P$2" 2>&1 | awk '
/^ HTTP\// { s = $2 }
/ error: HTTP\// { for (i = 1; i <= NF; i++) if ($i ~ /^HTTP\//) s = $(i + 1) }
tolower($0) ~ /^ x-accel-buffering: no/ { x = "xab" }
END { print (s ? s : "none"), (x ? x : "-") }'
}
# host method path [머리글] → "상태 Content-Type code" — nginx 가 직접 끝낸 응답의 JSON
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'; } \
| 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 : "-") }'
}
check() { if [ "$got" = "$1" ]; then echo "ok $2 → $got"; else echo "FAIL $2 → $got (기대 $1)"; fails=$((fails + 1)); fi; }
expect() { got=$(probe "$2" "$3"); check "$1" "$2$3"; }
expect_raw() { got=$(raw "$2" "$3" "$4" "$5"); check "$1" "$3 $2$4 $5"; }
hold() { n=0; while [ "$n" -lt "$3" ]; do wget -q -O /dev/null --header "Host: $1" "$P$2?hold=1" >/dev/null 2>&1 & n=$((n + 1)); done; sleep 3; }
release() { killall wget >/dev/null 2>&1; sleep 2; }
PUBLIC="/api/public-links/$T/files/$F /api/Public-Links/$T/FILES/$F /api/PUBLIC-LINKS/$T/Files/$F /api//public-links/$T/files/$F /api/public%2Dlinks/$T/files/$F"
INAPP="/api/messages/M1/large-files/$F /api/Messages/M1/LARGE-FILES/$F /api/MESSAGES/M1/Large-Files/$F /api//messages/M1/large-files/$F /api/messages/M1%2Fx/large-files/$F"
echo "== 받기 location 을 탄다(X-Accel-Buffering) · 포털은 공개 API 를 닫는다 — 대소문자 · %2F · 겹 / 변형도"
for p in $PUBLIC; do expect "200 xab" files.ci.test "$p"; expect "404 -" portal.ci.test "$p"; done
for p in $INAPP; do expect "200 xab" portal.ci.test "$p"; done
expect "200 -" files.ci.test "/api/Public-Links/$T"
expect "200 -" portal.ci.test "/api/Messages/M1"
expect "200 xab" files.ci.test "/api/Public-Links/$T/files/$F?pad=1"
expect "200 xab" portal.ci.test "/api/Messages/M1/large-files/$F?pad=1"
echo "== nginx 가 끝내는 /api 응답은 JSON"
expect_raw "404 application/json RES_001" files.ci.test GET /api/auth/me
expect_raw "413 application/json FILE_002" files.ci.test POST /api/auth/login "Content-Length: 2000000"
expect_raw "404 application/json RES_001" portal.ci.test GET "/api/Public-Links/$T/files/$F"
echo "== 동시 연결 — 주소마다 공개 10 · 앱 안 30. 붙잡아 둔 뒤 변형으로 하나 더"
hold files.ci.test "/api/public-links/$T/files/$F" 10
for p in $PUBLIC; do expect "429 -" files.ci.test "$p"; done
expect_raw "429 application/json REQ_001" files.ci.test GET "/api/Public-Links/$T/files/$F"
expect "200 xab" portal.ci.test "/api/messages/M1/large-files/$F"
release
hold portal.ci.test "/api/messages/M1/large-files/$F" 30
for p in $INAPP; do expect "429 -" portal.ci.test "$p"; done
expect "200 xab" files.ci.test "/api/public-links/$T/files/$F"
release
echo "fails=$fails"
[ "$fails" -eq 0 ]
EOF
{ printf 'FROM %s\n' "$base"; cat <<'EOF'; } > "$ctx/Dockerfile"
RUN mkdir -p /tmp/stub && head -c 10485760 /dev/zero > /tmp/stub/big && echo ok > /tmp/stub/small \
&& pad=$(head -c 3000 /dev/zero | tr '\0' a) \
&& printf 'map $arg_hold $stub_rate { 1 1k; default 0; }\nmap $arg_hold $stub_file { 1 /big; default /small; }\nmap $arg_pad $stub_pad { 1 "%s"; default ""; }\nserver {\n listen 127.0.0.1:3000;\n access_log off;\n location / {\n root /tmp/stub;\n limit_rate $stub_rate;\n add_header X-Accel-Buffering no;\n add_header X-Pad1 $stub_pad;\n add_header X-Pad2 $stub_pad;\n try_files $stub_file =404;\n }\n}\n' "$pad" > /etc/nginx/conf.d/zz-stub.conf
COPY --chmod=755 paths.sh /usr/local/bin/paths.sh
EOF
docker build -t "$tag" "$ctx"
docker run -d --name "$name" --network none -e NGINX_BACKEND_UPSTREAM=http://127.0.0.1:3000 \
-e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test "$tag"
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; }
# 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf.
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만
# 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 —
# AlertExceedsMax · AlertEncrypted 가 꺼지면 한도를 넘거나 암호가 걸린 파일이 "깨끗함(OK)"으로 온다 (mail/README.md "악성 검사 (ClamAV)")
- name: ClamAV 설정 검사
run: |
touch backend/.env.production
# clamav · clamav-updater 가 같은 이미지라 두 줄 — 첫 줄을 awk 로 고른다(끝까지 읽는다). grep -m1 · head 는 먼저 끝나 pipefail 셸에서
# compose 의 SIGPIPE 가 결과를 뒤집을 수 있다 (위 compose 단계와 같은 까닭)
image=$(docker compose -f docker-compose.yml --env-file .env.production.example config --images | awk '/^clamav\/clamav:/ && !seen { print; seen = 1 }')
[ -n "$image" ] || { echo "::error::docker-compose.yml 에서 clamav/clamav 이미지를 찾지 못했습니다"; exit 1; }
tag="ci-clamav-config:${{ github.run_id }}"
printf 'FROM %s\nCOPY clamd.conf freshclam.conf /etc/clamav/\n' "$image" | docker build -t "$tag" -f- mail/clamav
conf=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav -n 2>&1)
printf '%s\n' "$conf"
if printf '%s\n' "$conf" | grep -E '^(ERROR|WARNING)' >/dev/null; then
echo "::error::clamconf 가 mail/clamav 의 설정을 읽지 못했습니다 (위 ERROR 줄)"; exit 1
fi
for want in 'StreamMaxLength = "2146435072"' 'MaxFileSize = "2146435072"' 'MaxScanSize = "4292870144"' 'MaxScanTime = "300000"' 'AlertExceedsMax = "yes"' 'AlertEncrypted = "yes"' 'ConcurrentDatabaseReload disabled' 'TCPSocket = "3310"'; do
printf '%s\n' "$conf" | grep -F "$want" >/dev/null || { echo "::error::clamd.conf 에 '$want' 가 없습니다 (mail/clamav/clamd.conf)"; exit 1; }
done
# -n 은 기본값과 같은 값을 숨긴다 — 기본값과 같지만 판정에 기대는 값(서명 다시 읽기 10분 · 진짜 서명이 휴리스틱보다 먼저)은
# -n 없이 clamd.conf 칸에서 본다 (기본값이 바뀐 판으로 올려도 알아차리게)
clamd=$(docker run --rm --user 100:101 --entrypoint clamconf "$tag" -c /etc/clamav 2>&1 | awk '/^Config file: /{ on = ($3 == "clamd.conf") } on')
for want in 'SelfCheck = "600"' 'HeuristicScanPrecedence disabled'; do
printf '%s\n' "$clamd" | grep -F -x "$want" >/dev/null || { echo "::error::clamd.conf 의 값이 '$want' 가 아닙니다 (mail/clamav/clamd.conf)"; exit 1; }
done
# 메일 전용 DNS 리졸버(mail-resolver)의 설정 검사 — compose 와 같은 이미지 · 같은 사용자(101:102)로 unbound-checkconf.
# 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말 — 잡 컨테이너의 경로는 호스트에 없다)
@@ -202,8 +320,11 @@ jobs:
- name: 정리
if: always()
run: |
docker rm -f "ci-nginx-paths-${{ github.run_id }}" || true
docker image rm -f "ci-nginx-paths:${{ github.run_id }}" || true
docker image rm -f "ci-nginx-config:${{ github.run_id }}" || true
docker image rm -f "ci-unbound-config:${{ github.run_id }}" || true
docker image rm -f "ci-clamav-config:${{ github.run_id }}" || true
# 게시 주소 확인이 중간에 끊겼을 때 남은 컨테이너 (보통은 run --rm 이 지운다)
docker ps -aq --filter "label=com.docker.compose.project=ci-bind-${{ github.run_id }}" | xargs -r docker rm -f || true
+2
View File
@@ -23,6 +23,8 @@
ci/runner-data/
# 업로드 파일 (로컬 저장) — 코드가 아니다
backend/uploads/
# 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다
backend/files/
# ==========================================
# 의존성/빌드 산출물
+2
View File
@@ -12,6 +12,8 @@ node_modules
dist
coverage
uploads
# 대용량 첨부 파일 (11단계 — 호스트에서 백엔드를 띄우면 FILE_STORAGE_DIR 기본값 ./files 에 쌓인다. 2GB 짜리가 빌드 컨텍스트에 실리지 않게)
files
logs
*.log
.env
+5 -2
View File
@@ -25,6 +25,9 @@ RUN npm ci
COPY --chown=node:node . .
RUN npm run build
# 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — dev 는 이 단계에 named volume <PROJECT_NAME>_files 를 붙인다.
# 새 볼륨은 이 디렉터리의 소유자(node)를 물려받는다 (없으면 root 소유로 생겨 node 가 쓰지 못한다). 빌드 컨텍스트의 files/ 는 .dockerignore 가 뺀다
RUN mkdir -p /app/files
FROM node:${NODE_VERSION}-alpine3.24 AS production
ARG NPM_VERSION
@@ -41,10 +44,10 @@ RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads) — node 소유로 만들어 둔다.
# 업로드 저장 위치(compose 의 UPLOAD_DIR=/app/uploads)와 대용량 첨부 파일 위치(FILE_STORAGE_DIR=/app/files — 11단계) — node 소유로 만들어 둔다.
# 운영 compose 는 여기에 named volume 을 붙이는데, 새 볼륨은 이 디렉터리의 소유자를 물려받아 node 가 쓸 수 있다.
# (호스트 디렉터리를 바인드 마운트하면 sudo docker compose 가 root 소유로 만들어 업로드가 실패한다)
RUN mkdir -p /app/uploads && chown node:node /app/uploads
RUN mkdir -p /app/uploads /app/files && chown node:node /app/uploads /app/files
EXPOSE 3000
+22
View File
@@ -83,6 +83,10 @@ services:
# 소스 바인드 마운트(/app) 위에 다시 얹는다. Windows·macOS 의 Docker Desktop 은 소유자 문제가 없고,
# 리눅스 호스트는 도커가 없는 디렉터리를 root 소유로 만들므로 처음 한 번 mkdir -p data/uploads 를 해 둔다
- ${UPLOAD_DATA_DIR:-./data/uploads}:/app/uploads
# 대용량 첨부 파일(11단계)은 개발도 base 그대로 named volume <PROJECT_NAME>_files(/app/files)다 — 호스트 디렉터리로 바꾸지 않는다:
# 2GB 까지의 파일을 Windows 바인드 마운트 너머로 쓰고 읽으면 느리고, 호스트 백신(Windows Defender)이 시험 파일(EICAR 등)을
# 지워 악성 검사 시험이 어긋난다. 새 볼륨은 builder 단계의 /app/files(node 소유)를 물려받는다 (backend/Dockerfile).
# 디스크 여유 검사(FILE_STORAGE_MIN_FREE_GB)는 Docker Desktop 가상 디스크의 여유를 본다
# dev 는 watch 컴파일(swc)과 타입 검사(tsc, nest-cli.json 의 typeCheck)가 앱과 함께 돌아
# base 의 1g 로는 OOM 으로 두 번 죽었다 → dev 에서만 2g. 운영 한도(1g)는 그대로다
deploy:
@@ -117,6 +121,24 @@ services:
# 개발도 같은 리졸버를 띄운다 — 엔진은 도커 DNS 를 거쳐(System) 바깥 이름을 이 리졸버에 묻는다(Mailpit 같은 도커 내부 이름은 도커 DNS 가 푼다)
restart: "no"
# ----------------------------------------
# 악성 검사 (ClamAV) — 개발은 기본으로 띄우지 않는다 (profile `clamav`, 운영은 늘 뜬다)
#
# 서명을 읽은 검사기가 메모리를 1GB 안팎 쓰고, 처음 기동은 서명(110MB 안팎)을 받는다. 끈 동안 백엔드(CLAMAV_HOST 비움)는
# 다 올린 대용량 파일을 바로 "검사하지 못함"으로 둔다 (계약 4절 "악성 검사"). 켜려면 루트 .env.development 에 둘 다:
# COMPOSE_PROFILES=mail,clamav
# CLAMAV_HOST=clamav
# 그다음 docker compose ... up -d (backend 가 새 값으로 다시 만들어진다). 서명은 볼륨 <PROJECT_NAME>_clamav-db 에 남아 다음에는 바로 뜬다
# ----------------------------------------
clamav:
profiles: ["clamav"]
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 unless-stopped 를 덮어씀)
restart: "no"
clamav-updater:
profiles: ["clamav"]
restart: "no"
# ----------------------------------------
# mailpit — 개발 전용 메일 수집기 (profile `mail`, 운영 compose 에는 없다)
#
+149 -4
View File
@@ -12,6 +12,10 @@ services:
# app-network backend ↔ db·redis·mail — 메일 엔진의 신뢰 대역이다. frontend 는 붙이지 않는다
# (붙이면 frontend 가 mail:8080 관리 API 에 닿고, 그 주소가 자동 차단 예외·게이트웨이 로그인 대역에 든다)
# mail-dns mail ↔ mail-resolver(메일 전용 DNS) 전용 — 바깥 길 없음. 리졸버의 바깥 조회는 mail-dns-egress 로만 (6번 참조)
# clamav backend ↔ clamav(악성 검사기) 전용 — 바깥 길 없음. 서명 갱신(clamav-updater)은 clamav-egress 로만 (7번 참조)
#
# 호스트 이름 둘 — 포털(CORS_ORIGIN)과 대용량 첨부의 공개 내려받기(SHARE_PUBLIC_URL, 11단계)를 server 하나씩으로 가른다
# (frontend/nginx.conf · nginx-env.envsh). 외부 리버스 프록시는 두 호스트를 모두 이 컨테이너의 8080 으로 넘기고 Host 를 그대로 넘긴다
# ----------------------------------------
frontend:
container_name: ${PROJECT_NAME}-frontend
@@ -32,7 +36,16 @@ services:
# 서비스 이름(backend)이 아니라 고유 컨테이너 이름 — proxy 네트워크를 다른 프로젝트와 함께 쓰면
# 그쪽의 backend 라는 이름으로 풀릴 수 있다 (nginx 는 붙은 모든 네트워크에서 이름을 찾는다)
- NGINX_BACKEND_UPSTREAM=http://${PROJECT_NAME}-backend:${BACKEND_PORT}
# 대용량 첨부의 공개 내려받기 호스트(11단계) — 백엔드와 같은 두 값으로 가른다: SHARE_PUBLIC_URL 의 호스트 이름이 포털(CORS_ORIGIN)과 다르면
# 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh)
- SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-}
- CORS_ORIGIN=${CORS_ORIGIN:-}
restart: unless-stopped
# 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다
ulimits:
nofile:
soft: 8192
hard: 8192
networks:
- edge
- proxy
@@ -115,19 +128,41 @@ services:
- MAIL_ATTACHMENT_MAX_SIZE_MB=${MAIL_ATTACHMENT_MAX_SIZE_MB:-20}
# 메일 한 통의 첨부 합계(MB) — 파일당 한도 이상이어야 한다
- MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB=${MAIL_ATTACHMENT_TOTAL_MAX_SIZE_MB:-25}
# 대용량 첨부 (11단계 — docs/mail/api-contract.md 4절 "환경 변수"). 비운 값은 백엔드 기본값이다 (검사: shared/config/env.validation.ts)
# 공개 내려받기 화면의 출처 — 운영 필수(https, 포털과 다른 호스트 권장 · 예 https://files.arasystem.it.kr). 비우면(개발) CORS_ORIGIN 의 첫 주소
- SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-}
- SHARE_REPORT_ADDRESS=${SHARE_REPORT_ADDRESS:-}
- SHARE_DOWNLOAD_LIMIT=${SHARE_DOWNLOAD_LIMIT:-}
- MAIL_LARGE_FILE_MAX_SIZE_MB=${MAIL_LARGE_FILE_MAX_SIZE_MB:-}
- MAIL_LARGE_FILE_MAX_COUNT=${MAIL_LARGE_FILE_MAX_COUNT:-}
- MAIL_LARGE_FILE_USER_QUOTA_GB=${MAIL_LARGE_FILE_USER_QUOTA_GB:-}
- MAIL_LARGE_FILE_RETENTION_DAYS=${MAIL_LARGE_FILE_RETENTION_DAYS:-}
- FILE_STORAGE_MAX_TOTAL_GB=${FILE_STORAGE_MAX_TOTAL_GB:-}
- FILE_STORAGE_MIN_FREE_GB=${FILE_STORAGE_MIN_FREE_GB:-}
# 파일을 두는 곳 — 아래 named volume <PROJECT_NAME>_files (루트 env 로 바꾸지 않는다)
- FILE_STORAGE_DIR=/app/files
# 악성 검사기(clamd) — 운영 필수: 아래 clamav 서비스(호스트 이름 clamav, 포트 3310). 비우면(개발 기본) 검사하지 않고 다 올리자마자 "검사하지 못함"
- CLAMAV_HOST=${CLAMAV_HOST:-}
- CLAMAV_PORT=${CLAMAV_PORT:-}
# 업로드 파일은 컨테이너 재생성과 무관하게 보존되어야 한다 — named volume <PROJECT_NAME>_uploads.
# 호스트 디렉터리(./data/uploads)를 쓰지 않는 이유: sudo docker compose 가 없는 디렉터리를 root 소유로 만들어
# backend(node)가 쓰지 못한다. 새 named volume 은 이미지의 /app/uploads(node 소유) 소유자를 물려받는다 (backend/Dockerfile).
# (개발은 docker-compose.dev.yml 이 ./data/uploads 바인드 마운트로 바꾼다)
# (메일 첨부는 업로드 중 /tmp/arasystem-mail-uploads 에 잠시 받는다 — 컨테이너 쓰기 계층 디스크.
# read_only 로 바꾸면 그 경로에 tmpfs 가 아닌 볼륨을 붙인다: tmpfs 는 메모리 한도에 잡힌다)
# 대용량 첨부 파일(11단계)은 named volume <PROJECT_NAME>_files — 같은 이유로 이미지의 /app/files(node 소유)를 물려받는다.
# 한 파일 2GB 까지 · 직원마다 10GB 까지 쌓인다 — 디스크 여유는 백엔드가 만들 때 본다(FILE_STORAGE_MIN_FREE_GB). 30일짜리 전송물이라 백업하지 않는다
# (개발도 같은 named volume 이다 — 업로드와 달리 호스트 디렉터리로 바꾸지 않는다, docker-compose.dev.yml)
volumes:
- uploads:/app/uploads
- files:/app/files
restart: unless-stopped
# 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다
# 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다.
# clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번)
networks:
- app-network
- edge
- clamav
depends_on:
db:
condition: service_healthy
@@ -325,6 +360,99 @@ services:
cpus: '0.25'
memory: 128m
# ----------------------------------------
# 7. 악성 검사 (ClamAV) — 대용량 첨부(11단계)의 검사기 · 운영은 늘 뜬다 (개발은 profile `clamav` — docker-compose.dev.yml)
#
# 백엔드가 다 올린 파일을 clamd 의 INSTREAM(TCP 3310)으로 흘려보내 판정을 받는다 (docs/mail/api-contract.md 4절 "악성 검사",
# backend modules/file-storage/clamav-client.ts). 설정 mail/clamav/ · 자세한 것 mail/README.md "악성 검사 (ClamAV)".
#
# 둘로 나눈다 — 바깥에서 온 파일을 여는 쪽과 바깥에 나가는 쪽을 가른다:
# clamav clamd(검사기). 백엔드와만 닿는 clamav 네트워크(internal) 하나에 붙는다 — 바깥 길이 없다(파일 해석기에 구멍이 나도
# 바깥 · 다른 컨테이너에 닿지 못하게). 서명 볼륨은 읽기 전용으로 붙이고 바뀐 서명을 스스로 다시 읽는다(clamd.conf 의 SelfCheck)
# clamav-updater freshclam(서명 갱신). 서명을 받아 볼륨에 쓴다 — 자기 바깥 길 clamav-egress 에만 붙고 백엔드 · 검사기와 잇지 않는다
# 이미지는 공식 clamav/clamav 의 LTS(1.4) 판을 digest 까지 고정한다(docker 스킬 "이미지 버전 고정") — _base 는 서명이 들지 않은 이미지다(서명은 볼륨에).
# 두 컨테이너 모두 이미지의 clamav 사용자(100:101) · 권한(capability) 없음 · no-new-privileges. 호스트 포트는 어떤 환경에서도 열지 않는다
# ----------------------------------------
clamav:
container_name: ${PROJECT_NAME}-clamav
# 1.4 는 ClamAV 의 LTS(2024-08 부터 3년). 1.4.6 은 보안 릴리스(2026-08-07). digest 는 2026-10-30 의 manifest list (이미지는 linux/amd64 만)
image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f
user: "100:101"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
# 이미지의 /init 은 root 로 소유자를 바꾸고 freshclam 까지 함께 띄운다 — 검사기만 바로 띄운다. PID 1 은 docker 의 tini(sh 는 exec 로 clamd 가 된다).
# 띄우기 전에 clamd 의 임시 파일(clamd.conf 의 TemporaryDirectory /tmp — clamav-<hex>.tmp)을 지운다: 검사 중에 죽으면(OOM 등) 받던 스트림 ·
# 푼 내용이 남고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다(2026-10-30 — 흘려보내는 중에 죽일 때마다 수백 MB 씩 남았다).
# 지우지 못해도(소유자가 다르면) clamd 는 띄운다 — 그 줄이 docker logs 에 남는다
init: true
entrypoint: ["/bin/sh", "-c", "rm -rf /tmp/clamav-*; exec clamd --foreground --config-file=/etc/clamav/clamd.conf"]
expose:
- "3310"
volumes:
- clamav-db:/var/lib/clamav:ro
- ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro
restart: unless-stopped
networks:
- clamav
# 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다).
# required: false — 서명이 오래됐다고(아래 updater 의 healthcheck) 검사기를 띄우지 않으면 검사가 통째로 서므로 경고만 내고 띄운다
depends_on:
clamav-updater:
condition: service_healthy
required: false
healthcheck:
# 이미지에 든 clamdcheck.sh — 3310 에 PING 을 보내 PONG 을 받는지. 서명을 다시 읽는 동안(30초 안팎)은 답하지 않는다 — 세 번까지 기다린다
test: ["CMD", "clamdcheck.sh"]
interval: 60s
timeout: 10s
retries: 3
start_period: 5m
# CPU — 검사 하나는 코어 하나만 쓴다(2GB 에 5분 안팎 — 2026-10-30 개발 PC). 백엔드가 한 번에 2개까지 보내므로 2개까지 쓰게 두고(서버가 2 vCPU 보다 적으면 낮춘다),
# 다른 컨테이너와 다툴 때는 절반의 몫(cpu_shares 512 — 기본 1024)만 받는다 — 검사가 웹메일 · 메일 엔진을 늦추지 않게
cpu_shares: 512
# 메모리 — 서명을 읽으면 1GB 안팎(2026-10 서명 363만 개). 다시 읽을 때 두 벌을 들지 않는다(clamd.conf 의 ConcurrentDatabaseReload no — 1GB 그대로).
# 검사하는 동안 받은 파일을 /tmp 에 써 그 페이지 캐시가 이 한도에 함께 잡힌다(다시 쓸 수 있는 메모리라 줄여 가며 쓴다). 예산은 mail/README.md "메모리 예산"
deploy:
resources:
limits:
cpus: '2.0'
memory: 2g
clamav-updater:
container_name: ${PROJECT_NAME}-clamav-updater
image: clamav/clamav:1.4.6_base@sha256:e73991068f2037e10cf15669dd3b48c24345658f5488430746b414c0f761270f
user: "100:101"
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
init: true
# freshclam 데몬 — 기동 때 한 번 받고 Checks(하루 12번)마다 묻는다. 멈추면 컨테이너가 끝나 재시작 정책을 탄다
entrypoint: ["freshclam", "--daemon", "--foreground", "--stdout", "--config-file=/etc/clamav/freshclam.conf"]
volumes:
- clamav-db:/var/lib/clamav
- ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro
restart: unless-stopped
networks:
- clamav-egress
healthcheck:
# 서명(main · daily)이 있고 daily 가 이틀 안에 바뀌었으면 healthy — 갱신이 이틀 넘게 멈추면 unhealthy(docker ps 로 보인다. 검사는 옛 서명으로 이어 간다).
# 처음 기동은 서명을 받는 몇 분 동안 실패해도 세지 않는다(start_period)
test: ["CMD-SHELL", "ls /var/lib/clamav/main.c[lv]d >/dev/null 2>&1 && find /var/lib/clamav -maxdepth 1 -name 'daily.c[lv]d' -mmin -2880 | grep -q ."]
interval: 60s
timeout: 10s
retries: 3
start_period: 10m
# 메모리 — 평소 수십 MB. 받은 서명을 바꿔 넣기 전에 읽어 보는 동안(freshclam.conf 의 TestDatabases) 잠시 커진다 —
# 처음 기동(서명 전체)이 가장 커서 0.7GB 남짓이었다(2026-10-30). 평소 갱신은 daily 만이라 그보다 작다
deploy:
resources:
limits:
cpus: '0.5'
memory: 1g
networks:
app-network:
# 이름·서브넷을 고정한다. 메일 엔진은 이 대역만 신뢰한다 — 게이트웨이 로그인·자동 차단 예외
@@ -359,17 +487,34 @@ networks:
# 도커가 기본 주소 풀(172.17~31.x · 192.168.x)에서 정한다. 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail/README.md)
name: ${PROJECT_NAME}_mail-dns-egress
driver: bridge
clamav:
# backend ↔ clamav(검사기) 전용. internal — 이 네트워크로는 바깥에 나가지 못한다(검사기는 바깥 길이 없다).
# clamd 는 암호화하지 않은 TCP 이고 접속을 가리지 않는다 — 다른 컨테이너를 붙이지 않는다. 주소로 믿는 곳이 없어 서브넷은 도커가 정한다
name: ${PROJECT_NAME}_clamav
driver: bridge
internal: true
clamav-egress:
# 서명 갱신(clamav-updater)이 database.clamav.net 에 묻는 길 — updater 혼자 붙는다. 서브넷은 도커 기본 주소 풀(172.17~31.x · 192.168.x)에서 —
# 서버의 사설망(VPC)이 그 대역이면 겹치지 않는지 본다 (mail-dns-egress 와 같다)
name: ${PROJECT_NAME}_clamav-egress
driver: bridge
proxy:
external: true
# 메일 설정(config.json)·저장소(RocksDB). 컨테이너 사용자(UID 2000)가 써야 하므로
# 바인드 마운트 대신 named volume 을 쓴다. 이름을 고정해 백업 명령이 디렉터리 이름에 좌우되지 않게 한다.
# 업로드도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다.
# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 파일이 사라진다.
# 업로드 · 대용량 첨부 파일도 같은 이유(컨테이너 사용자 node 가 써야 한다)로 named volume 이다. 악성 검사 서명은 clamav 사용자(100)가 쓴다.
# ⚠️ `docker compose down -v` 는 이 볼륨들까지 지운다 — 메일함 전체와 업로드 · 대용량 첨부 파일이 사라진다.
volumes:
mail-config:
name: ${PROJECT_NAME}_mail-config
mail-data:
name: ${PROJECT_NAME}_mail-data
uploads:
name: ${PROJECT_NAME}_uploads
name: ${PROJECT_NAME}_uploads
# 대용량 첨부 파일 (11단계) — 기록은 DB 에 있다. 백업하지 않는다(30일짜리 전송물 — mail/README.md "백업")
files:
name: ${PROJECT_NAME}_files
# 악성 검사 서명 (110MB 안팎 — 2026-10) — 지워도 clamav-updater 가 다시 받는다. 백업하지 않는다
clamav-db:
name: ${PROJECT_NAME}_clamav-db
+13 -1
View File
@@ -46,8 +46,20 @@ FROM nginxinc/nginx-unprivileged:1.30.6-alpine3.24 AS nginx-config
ENV NGINX_ENVSUBST_FILTER=^NGINX_ \
NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1
COPY nginx.conf /etc/nginx/templates/default.conf.template
# 템플릿 값 계산 (루트 env 의 첨부·업로드 한도 → nginx 본문 한도). 실행 권한이 있어야 entrypoint 가 source 한다
# 템플릿 값 계산 (루트 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 — 대소문자를 가리지 않는다)
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-api-errors.conf /etc/nginx/snippets/api-errors.conf
# 워커마다 연결 수 — 이미지 기본 1024 → 4096. 대용량 받기(버퍼링 끔 — 받기 하나가 연결 둘, 공개 400 · 앱 안 200 개까지) · 실시간 알림(브라우저마다 연결 둘을
# 1시간) · 포털 요청이 함께 버티게 (nginx.conf 의 http 단계 "동시 연결 수"). 워커 하나에 몰려도 모자라지 않게 워커마다 잡는다.
# 연결마다 파일 하나(fd)를 쓰므로 compose 가 frontend 의 nofile 을 8192 로 둔다. 이미지의 nginx.conf 꼴이 바뀌어 고치지 못하면 빌드를 멈춘다
RUN sed -i 's/^\([[:space:]]*worker_connections[[:space:]]\{1,\}\)[0-9]\{1,\};/\14096;/' /etc/nginx/nginx.conf \
&& grep -Eq '^[[:space:]]*worker_connections[[:space:]]+4096;' /etc/nginx/nginx.conf
# 운영 이미지 — compose 는 target 없이 빌드하므로 이 단계가 파일의 마지막이어야 한다
FROM nginx-config AS production-stage
+48
View File
@@ -0,0 +1,48 @@
# ==========================================
# /api 의 nginx 오류 응답 — 두 server(포털 · 공개 내려받기)가 넣는다 (nginx.conf). 템플릿이 아니다(/etc/nginx/snippets/api-errors.conf)
#
# message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(404·413·429·502·504 등)이 그대로 나간다 (2026-10-27 확인)
# ==========================================
# nginx 가 닫은 경로 404 — 공개 내려받기 server 의 /api/(공개 API 밖) · 포털의 공개 API(공개 호스트를 따로 둘 때). 없는 경로와 같은 RES_001
location @api_not_found {
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 200 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
}
# 본문 한도 초과 413 — 첨부 · 업로드 · 대용량 조각 · 공개 내려받기 server 의 /api/(1m)
location @api_payload_too_large {
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 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
}
# 동시 연결 수 초과 429 — 대용량 받기의 limit_conn (11단계 — nginx.conf 의 http 단계 "동시 연결 수"). 백엔드의 요청 수 한도와 같은 REQ_001
location @api_too_many_requests {
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 200 '{"success":false,"error":{"code":"REQ_001","message":"","traceId":"$request_id"}}';
}
# 백엔드 응답 없음 · 시간 초과 등 5xx
location @api_unavailable {
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 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
}
+71 -1
View File
@@ -64,4 +64,74 @@ esac
# 도커 내장 DNS — 15-local-resolvers.envsh 가 /etc/resolv.conf 에서 읽어 둔다 (Dockerfile 의 NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1)
NGINX_LOCAL_RESOLVERS="${NGINX_LOCAL_RESOLVERS:-127.0.0.11}"
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS
# ------------------------------------------
# 대용량 첨부의 공개 내려받기 호스트 (11단계 — docs/mail/api-contract.md 4절 "공개 호스트")
#
# SHARE_PUBLIC_URL(출처 — http(s)://호스트[:포트])의 호스트 이름을 포털(CORS_ORIGIN — 콤마로 여럿)의 호스트 이름들과 견준다.
# 백엔드(modules/file-sharing/file-sharing.settings.ts 의 dedicatedPublicHost)와 같은 판정이다 — 포트는 보지 않는다(nginx 는 포트 없는 Host 를 넘긴다):
# 다르면 공개 내려받기 server 의 이름 = 그 호스트 이름, 포털은 /s/ · /api/public-links/ 를 404 로 닫는다(share-closed · share-api-closed)
# 같거나 SHARE_PUBLIC_URL 이 비었으면 포털이 함께 연다(share-open · share-api-open), 공개 내려받기 server 는 어느 요청에도 맞지 않는 이름(share.invalid — RFC 2606)
# SHARE_PUBLIC_URL 의 모양이 틀리면 기동을 멈춘다 — 경로 · 공백 · ; 등이 server_name 에 들어가 설정을 깨거나 지시어를 끼워 넣지 않게.
# CORS_ORIGIN 의 모양은 백엔드가 본다(운영은 틀리면 백엔드가 뜨지 않는다) — 여기서는 읽을 수 있는 주소만 견준다.
# ------------------------------------------
# 출처 → 호스트 이름 (소문자, 포트 없이). 모양이 틀리면 아무것도 내지 않고 1
nginx_env_origin_host() {
case "$1" in
http://*) nginx_env_hostport="${1#http://}" ;;
https://*) nginx_env_hostport="${1#https://}" ;;
*) return 1 ;;
esac
# 영문 · 숫자 · . - : 만 — 경로(/) · 쿼리 · 공백 · 대괄호(IPv6) · 사용자 정보(@) 를 받지 않는다
case "$nginx_env_hostport" in
"" | *[!A-Za-z0-9.:-]*) return 1 ;;
esac
nginx_env_host="${nginx_env_hostport%%:*}"
nginx_env_port="${nginx_env_hostport#"$nginx_env_host"}"
case "$nginx_env_port" in
"") ;;
:0* | : | :*[!0-9]* | :??????*) return 1 ;;
esac
# 빈 이름 · 점으로 시작 · 끝(끝 점 — nginx 의 $host 는 끝 점을 떼어 백엔드와 어긋난다) · 연이은 점 · 하이픈으로 시작 · 끝
case "$nginx_env_host" in
"" | .* | *. | *..* | -* | *-) return 1 ;;
esac
printf '%s' "$nginx_env_host" | tr 'A-Z' 'a-z'
}
nginx_env_share_url="${SHARE_PUBLIC_URL:-}"
nginx_env_share_host=""
if [ -n "$nginx_env_share_url" ]; then
nginx_env_share_host="$(nginx_env_origin_host "$nginx_env_share_url")" \
|| nginx_env_fail "SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처(http(s)://호스트[:포트] — 경로 · 끝의 / · 끝 점 없이)여야 합니다 (예: https://files.arasystem.it.kr, 현재: \"$nginx_env_share_url\")."
fi
nginx_env_same_host=""
if [ -z "$nginx_env_share_host" ]; then
nginx_env_same_host=1
else
# 콤마로 나눠 하나씩 — 앞뒤 공백과 끝의 / 하나는 떼고 견준다 (백엔드도 출처로 고쳐 견준다)
while IFS= read -r nginx_env_origin; do
nginx_env_origin="$(printf '%s' "$nginx_env_origin" | tr -d ' ')"
nginx_env_origin="${nginx_env_origin%/}"
nginx_env_portal_host="$(nginx_env_origin_host "$nginx_env_origin")" || continue
if [ "$nginx_env_portal_host" = "$nginx_env_share_host" ]; then
nginx_env_same_host=1
fi
done <<NGINX_ENV_ORIGINS
$(printf '%s\n' "${CORS_ORIGIN:-}" | tr ',' '\n')
NGINX_ENV_ORIGINS
fi
if [ -n "$nginx_env_same_host" ]; then
NGINX_SHARE_SERVER_NAME="share.invalid"
NGINX_PORTAL_SHARE="open"
echo "nginx-env.envsh: 공개 내려받기(/s/ · /api/public-links/)를 포털과 같은 호스트에서 연다 (SHARE_PUBLIC_URL: \"${nginx_env_share_url}\")"
else
NGINX_SHARE_SERVER_NAME="$nginx_env_share_host"
NGINX_PORTAL_SHARE="closed"
echo "nginx-env.envsh: 공개 내려받기 호스트 ${nginx_env_share_host} — 포털에서는 /s/ · /api/public-links/ 를 닫는다"
fi
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS \
NGINX_SHARE_SERVER_NAME NGINX_PORTAL_SHARE
+14
View File
@@ -0,0 +1,14 @@
# ==========================================
# 포털에서는 대용량 첨부의 공개 API 를 열지 않는다 — 공개 호스트(SHARE_PUBLIC_URL)를 따로 둘 때 (11단계 — 계약 4절 "공개 호스트")
#
# 포털 server 의 location ^~ /api/ 블록 **안에** 넣는다(nginx.conf — nginx-env.envsh 의 NGINX_PORTAL_SHARE=closed).
# 템플릿이 아니다(/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 머리말)
# ==========================================
location ~* ^/api/public-links(?:/|$) {
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
return 404;
}
+47
View File
@@ -0,0 +1,47 @@
# ==========================================
# 대용량 첨부의 공개 API — /api/public-links/ (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기")
#
# 두 server 의 location ^~ /api/ 블록 **안에** 넣는다(nginx.conf) — 공개 내려받기 server 는 늘, 포털은 공개 호스트를 따로 두지 않을 때
# (nginx-env.envsh 의 NGINX_PORTAL_SHARE=open). 템플릿이 아니다 — /etc/nginx/snippets/share-api-open.conf 로 그대로 들어간다(Dockerfile).
# $backend_upstream 과 limit_conn 의 영역(download_public_*)은 nginx.conf 의 http 단계, @api_* 는 nginx-api-errors.conf.
# /api/ 블록의 add_header_inherit off 를 물려받는다 — 응답의 머리글(no-store · noindex · no-referrer · 파일의 CSP 등)은 백엔드가 정한다.
#
# 대소문자를 가리지 않는다(~*) — 백엔드(Express) 라우팅이 가리지 않아 /api/Public-Links/…/FILES/… 도 받기 처리기에 닿는다(nginx.conf 머리말).
# 정규식은 적은 차례로 처음 맞은 것을 쓴다 — 파일 받기를 링크 정보보다 먼저 둔다.
# ==========================================
# 파일 받기 GET·HEAD /api/public-links/{token}/files/{fileId} — 200 · 206(Range) · 416, 2GB 까지
# - 버퍼링을 끈다: 기본값이면 받는 쪽이 느릴 때 nginx 가 응답을 임시 파일(/tmp/proxy_temp)에 1GB 까지 받아 둔다 — 받는 사람마다.
# 백엔드가 보내는 X-Accel-Buffering: no 로도 꺼지지만 그 머리글에 기대지 않는다(실시간 알림과 같다)
# - X-Accel-Buffering 을 넘긴다: nginx 계열 외부 리버스 프록시(Nginx Proxy Manager 등)도 이 응답만 버퍼링을 끈다
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
# - 압축하지 않는다(gzip_types 에 application/octet-stream 이 없지만 목록을 넓혀도 이 경로는 그대로) · Range · If-Range 는 손대지 않고 넘긴다
# (nginx 는 프록시한 응답을 스스로 자르지 않는다 — 백엔드의 206 · 416 이 그대로 간다)
# - 시간: proxy_read_timeout 은 백엔드에서 "두 번 읽는 사이", send_timeout(기본 60s)은 받는 쪽에 "두 번 쓰는 사이"다 — 느려도 조금씩
# 받아 가면 끊지 않고, 받는 쪽이 60초 동안 하나도 받지 않으면 끊는다(브라우저 · 내려받기 관리자는 Range 로 이어 받는다 — mail/README.md 의 시험)
# - 동시 연결: 클라이언트 주소마다 10 · 공개 받기 전체 400 — 넘으면 429 REQ_001 (nginx.conf 의 http 단계 "동시 연결 수")
# - 토큰 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다(지금은 백엔드의 토큰 검사가 그 꼴을 404 로 막지만 기대지 않는다)
# - 같은 줄들이 nginx.conf 의 앱 안 받기(/api/messages/{id}/large-files/…)에 있다 — 한쪽을 고치면 다른 쪽도
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
location ~* ^/api/public-links/.+/files/ {
limit_conn download_public_ip 10;
limit_conn download_public_total 400;
error_page 413 @api_payload_too_large;
error_page 429 @api_too_many_requests;
error_page 500 502 503 504 @api_unavailable;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
gzip off;
proxy_pass_header X-Accel-Buffering;
proxy_pass $backend_upstream;
}
# 링크 정보 GET /api/public-links/{token}(JSON) 등 — 백엔드로 넘긴다. 공개 내려받기 server 의 /api/ 블록은 404 만 정하므로 여기서 다 적는다
location ~* ^/api/public-links(?:/|$) {
error_page 413 @api_payload_too_large;
error_page 500 502 503 504 @api_unavailable;
proxy_pass $backend_upstream;
}
+12
View File
@@ -0,0 +1,12 @@
# ==========================================
# 포털에서는 대용량 첨부의 공개 화면 /s/ 를 열지 않는다 — 공개 호스트(SHARE_PUBLIC_URL)를 따로 둘 때 (11단계 — 계약 4절 "공개 호스트")
#
# 포털 server 가 넣는다(nginx.conf — nginx-env.envsh 의 NGINX_PORTAL_SHARE=closed). 템플릿이 아니다(/etc/nginx/snippets/share-closed.conf).
# 공개 API(/api/public-links/)는 /api/ 블록 안의 nginx-share-api-closed.conf 가 닫는다(없는 경로와 같은 404 RES_001 JSON).
# 포털 주소로 온 공개 링크는 열지 않는다 — 털린 계정이 퍼뜨린 악성 파일로 포털 호스트까지 차단 목록에 오르지 않게.
# /S/… 처럼 대소문자가 다른 꼴은 SPA 로 가지만 화면 라우터가 공개 화면을 그리지 않는다(routes.ts 의 sensitive) — 받기는 API 쪽에서 닫혀 있다
# ==========================================
location ^~ /s/ {
return 404;
}
+27
View File
@@ -0,0 +1,27 @@
# ==========================================
# 대용량 첨부의 공개 화면 /s/<토큰> (11단계 — docs/mail/api-contract.md 4절 "공개 내려받기")
#
# 공개 내려받기 server 가 넣는다(nginx.conf). 공개 호스트를 따로 두지 않으면 포털 server 도 넣는다(nginx-env.envsh 의 NGINX_PORTAL_SHARE=open).
# 템플릿이 아니다 — /etc/nginx/snippets/share-open.conf 로 그대로 들어간다(Dockerfile). ${NGINX_*} 를 쓰지 않는다 — $page_csp 는 nginx.conf 의 http 단계.
# 공개 API(/api/public-links/)는 여기가 아니라 /api/ 블록 안의 nginx-share-api-open.conf 다(대소문자를 가리지 않게 — nginx.conf 머리말).
# 로그인 · 쿠키를 쓰지 않는 길이다 — 바깥 거래처의 브라우저 · 내려받기 관리자 · 받는 쪽 보안 장비가 부른다.
# ==========================================
# 화면 /s/<토큰> — SPA 의 공개 라우트(셸 · 로그인 없음 — 세션 복원을 건너뛴다). 파일은 토큰과 상관없이 index.html 하나다.
# server 단계의 보안 헤더를 물려받지 않고 여기서 다 쓴다 — 포털 server 에 들어갈 때(같은 호스트) 포털의 Referrer-Policy(strict-origin)와
# 두 줄이 되지 않게. 포털 화면과 다른 것: Referer 를 아예 보내지 않는다(주소에 토큰이 있다 — 화면에서 나가는 요청 · 내려받기 이동에 실리지 않게),
# 검색 엔진에 올리지 않는다. index.html 이라 매번 재검증한다(배포 뒤 지워진 해시 자산을 부르지 않게 — 포털의 /index.html 과 같은 까닭)
# 대소문자를 가린다 — 화면 라우터도 /s/ 만 공개 화면으로 그린다(routes.ts 의 sensitive). /S/… 는 공개 내려받기 server 에서는 404,
# 포털에서는 SPA 의 다른 화면이다(공개 화면이 그려지지 않는다)
location ^~ /s/ {
root /usr/share/nginx/html;
try_files /index.html =404;
add_header_inherit off;
add_header Cache-Control "no-cache" always;
add_header Referrer-Policy "no-referrer" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Content-Security-Policy $page_csp always;
}
+285 -90
View File
@@ -4,10 +4,27 @@
# 같은 주소(origin) 배포 — 브라우저는 화면과 같은 주소의 /api 를 부른다 (docs/mail/api-contract.md 머리말).
# 그래서 CORS 가 필요 없고 CSP connect-src 'self' 로 충분하다. 운영의 입구는 이 nginx 하나다.
#
# server 가 둘이다 (11단계 — 대용량 첨부의 공개 내려받기, 계약 4절 "공개 호스트"). 어느 쪽인지는 Host 로 가른다 —
# 외부 리버스 프록시는 두 호스트를 모두 이 8080 으로 넘기며 Host 를 그대로 둔다 (mail/README.md "Nginx Proxy Manager 설정"):
# 포털 default_server — 웹메일 화면과 /api. 공개 내려받기(/s/ · /api/public-links/)는 공개 호스트를 따로 두면 404 로 닫는다
# 공개 내려받기 SHARE_PUBLIC_URL 의 호스트 이름 — /s/<토큰>(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404
# 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh
#
# ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다.
# 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은
# 대소문자를 가리지 않는 정규식(~*)으로 /api/ 블록 안에 둔다 — 대소문자를 가리는 prefix location(^~ /api/messages 등)에 기대면 변형이 비켜 간다
# (2026-10-30 리뷰 — 받기 limit_conn 을 /FILES/ · /Messages/ 로 비켜 갔다). 백엔드의 대소문자 구분(겹 방어)에는 기대지 않는다.
# /API/… 처럼 /api/ 자체가 다르면 /api/ 블록에 들지 않아 백엔드로 가지 않는다(포털은 SPA 화면, 공개 내려받기는 404).
#
# ⚠️ 이 파일은 템플릿이다. 컨테이너가 뜰 때 nginx 이미지의 entrypoint 가 이 파일
# (/etc/nginx/templates/default.conf.template)의 ${NGINX_*} 만 envsubst 로 채워 /etc/nginx/conf.d/default.conf 를 만든다.
# 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB 등)에서 계산한다.
# 값은 nginx-env.envsh 가 루트 env(MAIL_ATTACHMENT_MAX_SIZE_MB·UPLOAD_MAX_SIZE_MB·SHARE_PUBLIC_URL·CORS_ORIGIN 등)에서 계산한다.
# nginx 변수($uri 등)는 NGINX_ 로 시작하지 않아 그대로 남는다 (Dockerfile 의 NGINX_ENVSUBST_FILTER).
# 두 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/ (연다 · 닫는다)
# api-errors.conf server 단계 — nginx 가 직접 끝내는 /api 오류의 JSON(@api_*)
# 이 파일은 http 단계에 들어간다(이미지의 /etc/nginx/nginx.conf 가 conf.d 를 http 안에서 읽는다) — 아래 map · proxy_* · gzip 은 두 server 가 물려받는다.
# ==========================================
# 접근 로그 — 쿼리를 남기지 않는다. 검색어(q)가 /api 주소뿐 아니라 화면 주소(/mail/search?q=…)에도 들어간다
@@ -17,20 +34,53 @@
# $uri 로 다시 짜지 않는다 — SPA 폴백(try_files … /index.html) 뒤에는 $uri 가 /index.html 이라 어느 화면이었는지 사라진다.
# - Referer: ? 앞까지만 남긴다. 화면 응답이 Referrer-Policy strict-origin 이라(아래 보안 헤더) 브라우저는 같은 주소의 요청에도
# origin 만 보내지만, 배포 전에 연 탭은 옛 정책(보던 화면 주소를 쿼리째)으로 계속 보내고 브라우저가 아닌 클라이언트는 무엇이든 보낸다.
# - 내려받기 토큰도 가린다 (11단계 — 계약 4절 "로그"). 토큰을 가진 누구나 파일을 받으므로 로그를 읽는 사람이 받지 못하게 — 요청 줄 · Referer 둘 다,
# 두 가지로 겹쳐 가린다:
# (1) 경로 모양 — /s/<…> · /api/public-links/<…> 의 그 칸을 길이와 상관없이 ***(대소문자 무시). 잘린 토큰(몇 글자 모자란 것)도 가린다
# (2) 토큰 모양 — 토큰은 base64url 43자다(backend modules/file-sharing/share-token.ts). 앞뒤가 [A-Za-z0-9_-] 가 아닌 딱 43자 덩어리,
# 그리고 %XY(퍼센트 인코딩) 바로 뒤의 43자 덩어리를 *** 로 — 경로 모양이 어긋나도(//s/T · /s//T · /./s/T · /%73/T · /x/../s/T ·
# /api//public-links/T · /api/public%2Dlinks/T · /api/public-links%2FT — nginx 는 정규화해 받지만 로그는 받은 그대로다) 가린다.
# 43자가 아닌 값(UUID 36자 · 메일 id · 엔진의 첨부 id 처럼 더 긴 것)은 가리지 않는다. 요청 줄은 두 번 본다(한 줄에 토큰이 둘이어도)
# 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴(/s/AbC%44ef…)은 가리지 못한다 — 그 토큰을 가진 본인만 만들 수 있는 꼴이다
# - 값이 없으면 지금처럼 "-" 다 (map 의 정규식은 빈 값에는 돌지 않아 "" 를 따로 적는다).
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
map $request $log_request_line {
# 오류 로그는 nginx 가 요청 줄·upstream·referrer 를 쿼리째(토큰째) 남긴다 — 장애 원인 줄을 잃지 않으려고 그대로 둔다 (docker 스킬 문서 "로그").
map $request $log_request_unqueried {
"" "-";
"~^(?<log_request_head>[^?]*)\?[^ ]*(?<log_request_tail>.*)" $log_request_head$log_request_tail;
default $request;
}
map $http_referer $log_referer {
map $log_request_unqueried $log_request_pathmasked {
"~*^(?<log_path_head>[^ ]+ (?:https?://[^/ ]+)?/(?:s|api/public-links)/)[^/ ]+(?<log_path_tail>.*)$" $log_path_head***$log_path_tail;
default $log_request_unqueried;
}
map $log_request_pathmasked $log_request_tokenmasked {
"~^(?<log_token1_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token1_tail>.*)$" $log_token1_head***$log_token1_tail;
default $log_request_pathmasked;
}
map $log_request_tokenmasked $log_request_line {
"~^(?<log_token2_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_token2_tail>.*)$" $log_token2_head***$log_token2_tail;
default $log_request_tokenmasked;
}
map $http_referer $log_referer_unqueried {
"" "-";
"~^(?<log_referer_head>[^?]*)\?" $log_referer_head;
default $http_referer;
}
map $log_referer_unqueried $log_referer_pathmasked {
"~*^(?<log_referer_path_head>(?:https?://[^/]+)?/(?:s|api/public-links)/)[^/]+(?<log_referer_path_tail>.*)$" $log_referer_path_head***$log_referer_path_tail;
default $log_referer_unqueried;
}
map $log_referer_pathmasked $log_referer {
"~^(?<log_referer_token_head>.*?)(?:(?<![A-Za-z0-9_-])|(?<=%[0-9A-Fa-f]{2}))[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])(?<log_referer_token_tail>.*)$" $log_referer_token_head***$log_referer_token_tail;
default $log_referer_pathmasked;
}
# 새 메일 확인(GET /api/mail-state — 화면이 보이는 동안 탭마다 1분에 한 번)은 성공(2xx·304)하면 접근 로그를 남기지 않는다 —
# 로깅 규칙 "반복되는 정상 흐름(헬스체크, 폴링)은 로그를 남기지 않는다". 실패(401·5xx 등, 오류용 이름 있는 location 포함)는 남긴다.
# 0 이면 server 단계 access_log 의 if= 가 그 줄을 쓰지 않는다. $uri 는 쿼리를 뺀 경로다 (이 요청은 내부 이동이 없다)
@@ -46,15 +96,118 @@ map "$request_method:$uri:$status" $log_access {
log_format main_rid '$remote_addr - $remote_user [$time_local] "$log_request_line" $status $body_bytes_sent '
'"$log_referer" "$http_user_agent" "$http_x_forwarded_for" rid=$request_id rt=$request_time';
# Content-Security-Policy — XSS 방어의 핵심 헤더. 두 server(포털 · 공개 내려받기)와 공개 화면(/s/)이 이 값 하나를 쓴다 (map 은 늘 같은 값)
#
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데,
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
# style 속성·data: 이미지 표시, CSP 위반 0건)
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다.
# 공개 내려받기 화면은 자기 호스트의 /api/public-links/ 만 부른다 — 'self' 그대로 맞다 (계약 4절 "공개 호스트")
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
map $host $page_csp {
default "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
}
# ----------------------------------------
# 대용량 첨부 받기의 동시 연결 수 (11단계) — 공개 받기(/api/public-links/<토큰>/files/…) · 앱 안 받기(/api/messages/<id>/large-files/…)
#
# 받기는 버퍼링을 끄고 흘려보낸다 — 받는 쪽이 느리면 연결 하나가 nginx 연결 둘(클라이언트 · 백엔드)과 백엔드의 파일을 그동안 잡는다.
# 토큰을 가진 사람이 느린 받기를 수백 개 열어 이 nginx(포털과 함께)를 멈추지 못하게 센다 — 넘으면 429 → @api_too_many_requests 의 REQ_001 JSON:
# 공개 받기 클라이언트 주소마다 10 · 공개 받기 전체 400
# 앱 안 받기 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200
# 전체 한도를 둘로 나눈 까닭 — 하나를 함께 쓰면 바깥 주소 몇십 개(IPv6 면 /64 하나)로 공개 받기가 그릇을 채워 직원의 앱 안 받기까지 모두 429 가 된다.
# 세는 것은 처리 중인 요청이다(쉬는 keep-alive 연결은 세지 않는다). 두 location 모두 대소문자를 가리지 않는다(위 머리말).
#
# 클라이언트 주소 — 이 nginx 앞에는 늘 외부 리버스 프록시가 있어 $remote_addr 은 프록시다. 프록시는 X-Forwarded-For 에 자기가 본
# 클라이언트 주소를 이어 붙인다(docker 스킬 "외부 리버스 프록시 요구사항") — 그 마지막 칸을 쓴다. 머리글이 없으면(프록시 없이 바로) 접속 주소.
# 프록시를 건너뛰고 바로 붙어 머리글을 꾸미면 주소마다 한도는 피하지만 경로마다의 전체 한도(400 · 200)는 그대로 걸린다(8080 은 ACG · proxy 네트워크가 프록시에만 연다).
# IPv6 는 주소 그대로 센다(백엔드처럼 /64 로 묶지 않는다 — 표기가 여러 꼴이라 map 으로 줄이지 않았다) — 그것도 전체 한도가 막는다.
# 거절은 접근 로그(429 · 토큰은 가려짐)로 본다 — 오류 로그에는 남기지 않는다(info — 이미지의 error_log 는 notice 부터. 남기면 토큰째 요청 줄이 쏟아진다).
# ⚠️ 연결 수의 그릇은 이미지 nginx.conf 의 worker_connections 다 — Dockerfile 이 워커마다 4096 으로 올린다(compose 의 nofile 8192).
# 받기 600 개(연결 1,200) + 실시간 알림(브라우저마다 연결 둘) + 포털 요청이 워커 하나에 몰려도 들어간다
# ----------------------------------------
map $http_x_forwarded_for $download_client {
"~(?:^|,) *(?<download_client_ip>[^ ,]+) *$" $download_client_ip;
default $remote_addr;
}
map $host $download_total_key {
default all;
}
limit_conn_zone $download_client zone=download_public_ip:10m;
limit_conn_zone $download_client zone=download_app_ip:10m;
limit_conn_zone $download_total_key zone=download_public_total:1m;
limit_conn_zone $download_total_key zone=download_app_total:1m;
limit_conn_status 429;
limit_conn_log_level info;
# ----------------------------------------
# /api → 백엔드 (같은 주소 배포) — 두 server 가 함께 쓴다
# ----------------------------------------
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
# (map 은 늘 같은 값이다 — server 마다 set 을 쓰지 않으려고)
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
map $host $backend_upstream {
default ${NGINX_BACKEND_UPSTREAM};
}
# 아래 proxy_* 는 두 server 의 /api 가 모두 물려받는다.
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
proxy_http_version 1.1;
proxy_set_header Connection "";
# 원래 Host 를 그대로 넘긴다 — 두 server(포털 · 공개 내려받기) 모두 이 값을 물려받는다(공개 내려받기 server 에서도 바꾸지 않는다).
# 백엔드가 이 값의 호스트 이름으로 공개 호스트를 가른다(modules/file-sharing/public-host.ts — 공개 호스트에서는 /api/public-links/ 만).
# ⚠️ 백엔드는 nginx 가 정규화한 $host 에 기댄다: 소문자 · 포트 없음 · 끝 점 없음, 요청 줄이 절대 주소(absolute-form)면 그 호스트가 이긴다 —
# 그리고 백엔드로 넘기는 요청 줄은 늘 경로만 있는 origin-form 이다. 이 nginx 를 거치지 않고 백엔드에 닿는 길을 만들거나 다른 프록시를 두면 같은 정규화를 해야 한다
proxy_set_header Host $host;
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
proxy_set_header X-Request-Id $request_id;
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie·Range·Content-Digest 는 손대지 않고 그대로 넘긴다.
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
# ----------------------------------------
# 압축 (Gzip) — 두 server 공통. 실시간 알림 · 대용량 받기는 각 location 에서 끈다
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ==========================================
# 포털 — 웹메일 화면과 /api (Host 가 공개 내려받기 호스트가 아닌 모든 요청)
# ==========================================
server {
# 비-root(nginx-unprivileged) 구동이라 1024 미만 포트를 쓸 수 없다
listen 8080;
server_name localhost;
listen 8080 default_server;
# 이름으로 고르지 않는다(default_server) — 공개 내려받기 server 의 이름과 겹치지 않게 비워 둔다
server_name _;
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
# 접근 로그 — 형식·조건은 위 map·log_format (쿼리 · 토큰 없음, 성공한 새 메일 확인은 남기지 않음). 모든 location 이 물려받는다
access_log /var/log/nginx/access.log main_rid if=$log_access;
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장 경로만 아래 /api 에서 늘린다
# 기본 요청 본문 한도 — 백엔드 JSON 파서 한도(1mb)와 같다. 업로드·발송·임시 저장·대용량 조각 경로만 아래 /api 에서 늘린다
client_max_body_size 1m;
# ----------------------------------------
@@ -74,42 +227,8 @@ server {
# 이 정책과 상관없이 그대로 붙는다. 다른 사이트로 가는 요청은 전과 같다 (origin 만, HTTPS → HTTP 는 보내지 않는다).
add_header Referrer-Policy "strict-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# Content-Security-Policy — XSS 방어의 핵심 헤더
#
# ⚠️ 프로젝트에 맞게 조정이 필요한 설정이다. 기본값 설명:
# - default-src 'self' : 기본적으로 같은 출처만 허용
# - style-src 'unsafe-inline' : 웹메일 본문 iframe(srcdoc) 때문이다 (아래). srcdoc 문서는 이 정책을 상속하는데,
# 그 안에는 앱이 넣는 <style>(본문 기본 서식)과 받은 메일의 style 속성이 있다 — 둘 다 인라인 스타일이다.
# (메일 원문의 <style> 요소는 서버·클라이언트 정화에서 지운다. 2026-10-27 운영 이미지로 확인:
# style 속성·data: 이미지 표시, CSP 위반 0건)
# 앱 화면 자체는 인라인 스타일이 필요 없다 — Vue 의 :style 은 CSSOM 으로 바꿔 CSP 대상이 아니고,
# 컴포넌트 CSS 는 /assets 의 파일로 나간다
# - connect-src 'self' : API 는 같은 주소의 /api 다 (같은 주소 배포). API 를 다른 주소로 옮기지 말 것 —
# CSP 뿐 아니라 쿠키(Path=/api, SameSite)와 출처 검사가 같은 주소를 전제로 한다
# - img-src data: : 메일 본문의 인라인 이미지(cid → data:)와 빌드가 인라인한 작은 이미지.
# blob: 은 쓰는 곳이 없어 뺐다 — 미리보기 등으로 createObjectURL 이미지를 쓰게 되면 그때 넣는다
# - font-src 'self' : 글꼴(Pretendard)은 npm 패키지를 번들에 넣어 /assets 의 파일로 내려준다 — 외부 CDN·data: 글꼴 없음
# (빌드가 4KB 미만 자산을 data: 로 인라인하는데, Pretendard 조각은 가장 작은 것도 8KB 라 해당 없다)
# - frame-ancestors 'none' : 클릭재킹 차단 (X-Frame-Options 의 최신 대체재)
#
# 웹메일 본문 iframe(MailBodyFrame, srcdoc)과의 관계 — 값을 바꿀 때 함께 확인할 것:
# - srcdoc 문서는 이 정책을 그대로 상속하고, 문서 안 meta CSP
# (default-src 'none'; img-src data:; style-src 'unsafe-inline')가 그 위에 겹쳐 적용된다 (둘 다 만족해야 한다).
# - 그래서 img-src 에서 data: 를, style-src 에서 'unsafe-inline' 을 빼면 메일 본문의 인라인 이미지·서식이 깨진다.
# - srcdoc 은 네트워크로 불러오는 문서가 아니라 frame-src·frame-ancestors 의 영향을 받지 않는다 (frame-src 추가 불필요).
#
# 외부 폰트·분석 스크립트·이미지 CDN 을 도입하면 해당 도메인을 명시적으로 추가한다.
# script-src 에 'unsafe-inline'/'unsafe-eval' 을 추가하면 CSP 의 의미가 사라지므로 피할 것.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# 값과 설명은 위 http 단계의 map $page_csp
add_header Content-Security-Policy $page_csp always;
# ----------------------------------------
# SPA 라우팅
@@ -144,29 +263,13 @@ server {
add_header Cache-Control "no-cache";
}
# ----------------------------------------
# /api → 백엔드 (같은 주소 배포)
# ----------------------------------------
# 백엔드 주소는 요청 때마다 도커 DNS 로 다시 푼다 — 백엔드 컨테이너를 다시 만들어 IP 가 바뀌어도 nginx 를 재시작할 필요가 없다.
# 주소는 서비스 이름(backend)이 아니라 고유 컨테이너 이름(<PROJECT_NAME>-backend)이다 (compose 가 준다) —
# 이 컨테이너는 다른 프로젝트와 함께 쓰는 proxy 네트워크에도 붙어 있어, 거기 backend 라는 이름이 있으면 그쪽으로 풀릴 수 있다.
resolver ${NGINX_LOCAL_RESOLVERS} valid=10s ipv6=off;
set $backend_upstream ${NGINX_BACKEND_UPSTREAM};
# 아래 proxy_* 는 /api 의 모든 location 이 물려받는다.
# ⚠️ 하위 location 에 proxy_set_header 를 하나라도 쓰면 여기 것을 물려받지 않는다 (add_header 와 같은 규칙)
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# 앞 단계(외부 리버스 프록시)가 보낸 값 뒤에 이어 붙인다 — 덮어쓰지 않는다 (백엔드 TRUST_PROXY 가 홉 수로 읽는다)
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 클라이언트가 보낸 값은 버리고 nginx 의 요청 ID 로 바꾼다 → 백엔드 traceId 와 이 nginx 로그의 rid 가 같다
proxy_set_header X-Request-Id $request_id;
# Origin·Sec-Fetch-Site(출처 검사)·Idempotency-Key(중복 발송 방지)·Cookie 는 손대지 않고 그대로 넘긴다.
# X-Forwarded-Proto 도 외부 프록시가 보낸 값을 그대로 둔다 (Secure 쿠키는 백엔드가 NODE_ENV 로 정한다).
# 대용량 첨부의 공개 화면 /s/<토큰>(11단계). 공개 호스트를 따로 두면 여기서는 404 로 닫고(nginx-share-closed.conf),
# 따로 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-open.conf). 고르는 것은 nginx-env.envsh 의 NGINX_PORTAL_SHARE.
# 공개 API /api/public-links/ 는 아래 /api/ 블록 안에서 같은 값으로 고른다(share-api-*.conf)
include /etc/nginx/snippets/share-${NGINX_PORTAL_SHARE}.conf;
location ^~ /api/ {
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다
# API 응답의 보안 헤더는 백엔드가 정한다 — 화면용 CSP 등을 겹쳐 붙이지 않는다 (안쪽 location 도 이 off 를 물려받는다)
add_header_inherit off;
proxy_pass $backend_upstream;
@@ -175,6 +278,9 @@ server {
error_page 413 @api_payload_too_large;
error_page 500 502 503 504 @api_unavailable;
# ⚠️ 이 안의 정규식 location 은 아래 prefix location(/api/messages 등)보다 먼저 정해진다 — prefix 에 ^~ 가 없을 때.
# 받기 경로가 걸리는 /api/messages 는 그래서 ^~ 없이 둔다 (^~ 면 /api/messages/… 가 아래 받기 location 을 비켜 간다)
# 헬스체크는 내부 전용 — 바깥에서 오면 없는 경로와 같은 404 (실패하면 DB 오류 문자열 등 원인이 담기기 때문이다).
# 대소문자·끝 / 를 가리지 않는 백엔드 라우팅에 맞춰 막는다. 내부 확인 방법은 docker 스킬 문서 "운영 환경".
location ~* ^/api/health(?:/|$) {
@@ -185,6 +291,49 @@ server {
return 404 '{"success":false,"error":{"code":"RES_001","message":"","traceId":"$request_id"}}';
}
# 공개 API /api/public-links/ (11단계) — 공개 호스트를 따로 두면 404 RES_001 로 닫고(nginx-share-api-closed.conf),
# 두지 않으면 공개 내려받기 server 와 같은 것을 연다(nginx-share-api-open.conf — 파일 받기는 공개 받기의 동시 연결 수 · 버퍼링 끔). 대소문자를 가리지 않는다
include /etc/nginx/snippets/share-api-${NGINX_PORTAL_SHARE}.conf;
# 대용량 첨부 받기 GET·HEAD /api/messages/{id}/large-files/{fileId} (11단계 — 계약 4절 "우리 웹메일에서 읽기") — 공개 받기와 같게 흘려보낸다
# (nginx-share-api-open.conf 의 파일 받기와 같은 줄들 — 한쪽을 고치면 다른 쪽도):
# - 동시 연결은 클라이언트 주소마다 30(사무실 NAT) · 앱 안 받기 전체 200 (위 http 단계). 넘으면 429 REQ_001
# - 버퍼링 · 압축을 끄고 X-Accel-Buffering 을 넘긴다 — 외부 프록시(NPM)도 이 응답을 버퍼에 쌓지 않는다(받는 사람마다 1GB 를 디스크에 쌓지 않게).
# Range 는 손대지 않고 넘긴다(206 그대로)
# - proxy_buffer_size 16k — 머리글 버퍼. 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이다(Content-Disposition 하나가 2.5KB) — 기본 4k 로는 빠듯하다
# 버퍼링을 끈 응답은 본문도 이 크기로 받아 넘긴다(기본 4k 보다 시스템 호출이 적다). proxy_busy_buffers_size 는 nginx 가 함께 맞추라고 하는 값이다 —
# 안 적으면 2 × 16k = 32k 가 proxy_buffers(8 × 4k)의 "하나 뺀 합" 28k 를 넘어 설정 검사가 멈춘다. 버퍼링을 끈 location 이라 proxy_buffers 는 쓰이지 않는다
# - 대소문자를 가리지 않는다(머리말). id 칸은 [^/]+ 가 아니라 .+ — nginx 는 %2F 를 / 로 풀어 location 을 고르고 백엔드는 한 칸으로 읽는다
# (지금은 백엔드의 id 검사가 그 꼴을 404 로 막지만 거기에 기대지 않는다)
# error_page 를 하나라도 쓰면 위의 것을 물려받지 않아 셋을 다 적는다
location ~* ^/api/messages/.+/large-files/ {
limit_conn download_app_ip 30;
limit_conn download_app_total 200;
error_page 413 @api_payload_too_large;
error_page 429 @api_too_many_requests;
error_page 500 502 503 504 @api_unavailable;
proxy_buffering off;
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
gzip off;
proxy_pass_header X-Accel-Buffering;
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
# 대용량 첨부의 조각 올리기 PUT /api/file-uploads/{id}/chunks/{n} (11단계 — 계약 4절 "올리기") — 조각 16MiB(계약 상수 — 마지막 조각만 짧다)
# + 여유 = 17m 고정. 계약 상수라 env 로 바꾸지 않는다(바꾸려면 계약 · 백엔드 · 화면을 함께). 그 밖의 /api/file-uploads(만들기 · 끝내기 · 상태 · 지우기)는
# JSON 이라 기본 1m 그대로다. 요청 본문은 nginx 가 다 받아 둔 뒤(proxy_request_buffering 기본 켬 — 느린 연결은 nginx 가 받는다, /tmp/client_temp)
# 백엔드에 넘긴다 — 백엔드가 디스크에 쓰고 내려쓰기(datasync)까지 기다린다. 백엔드 라우팅처럼 대소문자를 가리지 않는다.
# 오류 로그는 error 부터 — 16MiB 조각은 늘 메모리 버퍼(client_body_buffer_size)를 넘어 임시 파일로 받는데, nginx 는 그때마다
# "a client request body is buffered to a temporary file" 을 warn 으로 남긴다(조각마다 한 줄 — 2GB 파일이면 128줄). 버퍼를 키우지 않는다(동시 조각 수 × 16MiB 가 메모리에 앉는다)
location ~* ^/api/file-uploads/[^/]+/chunks/[^/]+$ {
client_max_body_size 17m;
proxy_read_timeout 90s;
error_log /var/log/nginx/error.log error;
proxy_pass $backend_upstream;
}
# 메일 첨부 업로드 — 파일당 MAIL_ATTACHMENT_MAX_SIZE_MB + 멀티파트 여유 1MB (nginx-env.envsh).
# 본문을 다 받은 뒤 백엔드가 메일 엔진에 올리는 시간까지 기다린다 (프론트의 업로드 한도는 5분)
location ^~ /api/mail-attachments {
@@ -199,8 +348,9 @@ server {
proxy_pass $backend_upstream;
}
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m)
location ^~ /api/messages {
# 메일 조회·수정·첨부 내려받기 — 메일 엔진 응답을 기다리는 시간 (본문 한도는 기본 1m).
# ^~ 를 붙이지 않는다 — 대용량 첨부 받기(/api/messages/{id}/large-files/…)는 위 정규식 location 이 가져가야 한다(위 ⚠️)
location /api/messages {
proxy_read_timeout 90s;
proxy_pass $backend_upstream;
}
@@ -247,8 +397,8 @@ server {
# 버퍼링을 끈다 — 기본 설정의 nginx 가 머리글을 첫 이벤트까지 붙잡지 않고 바로 보낸다 (docker 스킬 "외부 리버스 프록시 요구사항").
# 브라우저는 이 머리글을 쓰지 않는다.
# 머리글(proxy_set_header — 여기 쓰지 않는다) · 오류 응답(@api_unavailable 등) · 접근 로그는 위 /api 와 server 단계 것을 그대로 물려받는다.
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 이미지 기본 nginx.conf 를 따른다
# (워커마다 worker_connections 1024, 워커 수 auto).
# 열린 연결 하나가 nginx 연결 둘(클라이언트 쪽 · 백엔드 쪽)을 1시간까지 잡는다 — 수용량은 워커마다 worker_connections 4096
# (Dockerfile 이 이미지 기본 1024 에서 올린다 — 대용량 받기와 함께 버티게, 위 http 단계 "동시 연결 수"), 워커 수 auto.
location ^~ /api/mail-events {
proxy_buffering off;
proxy_cache off;
@@ -259,27 +409,9 @@ server {
}
}
# /api 의 nginx 오류 응답 — message 는 비운다 (문구는 클라이언트가 code 로 사전에서 찾는다, 레지스트리와 겹치지 않게).
# traceId 는 이 nginx 의 요청 ID(로그의 rid=)다. 이름 있는 location 은 server 단계라 화면용 헤더 상속을 끈다
# (접근 로그는 server 단계의 쿼리 없는 main_rid 를 그대로 물려받는다).
# return 200 은 본문만 정한다 — error_page 를 = 없이 썼으므로 상태 코드는 원래 값(413·502·504 등)이 그대로 나간다 (2026-10-27 확인)
location @api_payload_too_large {
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 200 '{"success":false,"error":{"code":"FILE_002","message":"","traceId":"$request_id"}}';
}
location @api_unavailable {
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 200 '{"success":false,"error":{"code":"SYS_001","message":"","traceId":"$request_id"}}';
}
# /api 의 nginx 오류 응답 — @api_not_found(404 RES_001) · @api_payload_too_large(413 FILE_002) · @api_too_many_requests(429 REQ_001) ·
# @api_unavailable(5xx SYS_001)
include /etc/nginx/snippets/api-errors.conf;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
@@ -293,3 +425,66 @@ server {
log_not_found off;
}
}
# ==========================================
# 공개 내려받기 — 대용량 첨부의 공개 호스트 (11단계 — 계약 4절 "공개 호스트", 운영 files.arasystem.it.kr)
#
# 로그인 없이 바깥 사람이 여는 곳이다. 여는 것은 셋뿐이다 — 화면 /s/<토큰>(SPA 의 공개 라우트 — 같은 묶음의 index.html) · 화면 자산(/assets/ · /favicon.ico) ·
# 공개 API /api/public-links/. 그 밖은 화면 · API 모두 404 — 털린 계정이 퍼뜨린 링크가 포털의 화면 · API 로 이어지지 않게 (백엔드도 같은 판정으로 막는다).
# 공개 호스트를 따로 두지 않으면 이 server 의 이름은 어느 요청에도 맞지 않는 share.invalid 이고(nginx-env.envsh), 포털이 같은 것을 연다.
# 포털 쿠키는 호스트 한정(Domain 없음)이라 이 호스트로 오지 않는다.
# ==========================================
server {
listen 8080;
server_name ${NGINX_SHARE_SERVER_NAME};
# 접근 로그 — 포털과 같은 형식(쿼리 없음 · 경로의 토큰은 ***)
access_log /var/log/nginx/access.log main_rid if=$log_access;
# 받기만 한다 — 본문을 받는 경로가 없다
client_max_body_size 1m;
# 보안 헤더 (화면용) — 포털과 같고 둘이 다르다: Referer 를 아예 보내지 않고(no-referrer — 화면 주소에 토큰이 있다),
# 검색 엔진에 올리지 않는다(X-Robots-Tag). 공개 API 는 location 에서 상속을 끈다(백엔드가 정한다)
add_header_inherit merge;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Content-Security-Policy $page_csp always;
root /usr/share/nginx/html;
# 화면 /s/<토큰> (포털이 같은 호스트일 때와 같은 파일)
include /etc/nginx/snippets/share-open.conf;
# 화면 자산 — 포털과 같은 캐시 (해시가 든 /assets/ 는 30일 immutable, 해시가 없는 favicon 은 매번 재검증)
location ^~ /assets/ {
expires 30d;
add_header Cache-Control "public, immutable";
}
location = /favicon.ico {
add_header Cache-Control "no-cache";
}
# API — 공개 API /api/public-links/ 만 백엔드로 넘기고(포털이 같은 호스트일 때와 같은 파일 — 대소문자를 가리지 않는다),
# 그 밖은 없는 경로와 같은 404 RES_001 JSON (@api_not_found). 본문 한도(1m)를 넘는 요청도 JSON 413 FILE_002 다.
# add_header 는 여기 두지 않는다 — 두면 안쪽의 백엔드로 넘기는 location 이 물려받는다(JSON 머리글은 @api_* 가 붙인다).
# return 은 안쪽 location 으로 물려지지 않는다 — 안쪽에 맞은 요청은 백엔드로 간다
location ^~ /api/ {
add_header_inherit off;
error_page 404 @api_not_found;
error_page 413 @api_payload_too_large;
include /etc/nginx/snippets/share-api-open.conf;
return 404;
}
# 그 밖의 화면 · 파일 (/ · /index.html · /mail/… 등) — 404
location / {
return 404;
}
# API 의 nginx 오류 응답 (포털과 같은 파일)
include /etc/nginx/snippets/api-errors.conf;
}
+197 -3
View File
@@ -7,7 +7,9 @@
- 설정값 SSOT: 루트 `.env.{APP_ENV}` 의 메일 섹션 ([env-structure](../.claude/rules/env-structure.md))
- 초기화·계정 구성: [`scripts/setup.mjs`](./scripts/setup.mjs) — 의존성 없음, 여러 번 실행해도 안전
- 이름은 루트 env 의 `PROJECT_NAME` 을 따른다 — 컨테이너 `${PROJECT_NAME}-mail`, 볼륨 `${PROJECT_NAME}_mail-config`·`_mail-data`,
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
네트워크 `${PROJECT_NAME}_app-network`(신뢰 대역)·`${PROJECT_NAME}_edge`(frontend ↔ backend).
대용량 첨부(11단계)는 볼륨 `${PROJECT_NAME}_files`(파일) · `_clamav-db`(악성 검사 서명), 컨테이너 `${PROJECT_NAME}-clamav` · `-clamav-updater`,
네트워크 `${PROJECT_NAME}_clamav`(backend ↔ 검사기) · `_clamav-egress`(서명 갱신) — [악성 검사 (ClamAV)](#악성-검사-clamav). 아래 명령은 셸에 한 번 읽어 둔 값을 쓴다:
```bash
export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.development)" # 운영은 .env.production
```
@@ -28,11 +30,22 @@
메일 엔진은 **app-network 대역 전체를 신뢰한다**(자동 차단 예외·게이트웨이 로그인). 그래서 app-network 에는 backend·db·redis·mail(개발은 mailpit 도)만
붙이고, 인터넷을 향한 frontend 는 edge 로만 backend 에 닿는다. setup 이 앱 네트워크에 붙은 컨테이너를 확인한다.
엔진의 DNS 는 메일 전용 리졸버(`mail-resolver`)가 맡는다 — mail 과 리졸버만 붙는 `mail-dns` 로 닿는다 ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)).
대용량 첨부(11단계)의 파일은 엔진이 아니라 backend 의 볼륨에 두고, 악성 검사기(`clamav`)는 backend 와만 닿는 `clamav` 네트워크에 둔다 —
app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악성-검사-clamav)).
```
브라우저(직원) ─▶ portal. ┐ ┌─(app-network)─▶ db · mail
받는 사람(바깥) ─▶ files. ┴▶ 외부 프록시 ─▶ frontend nginx ─(edge)─▶ backend ─┤ /app/files = 볼륨 <PROJECT_NAME>_files
(Host 로 server 를 가른다) └─(clamav — internal)─▶ clamav(clamd) ◀─ 볼륨 <PROJECT_NAME>_clamav-db(읽기 전용)
clamav-updater(freshclam) ─(clamav-egress)─▶ database.clamav.net
```
| 구성 요소 | 위치 | 비고 |
| --- | --- | --- |
| `mail` 서비스 | `docker-compose.yml` (profile `mail`) | named volume `<PROJECT_NAME>_mail-config`(config.json) · `<PROJECT_NAME>_mail-data`(RocksDB) |
| `mail-resolver` | `docker-compose.yml` (profile `mail`) · 설정 `mail/resolver/unbound.conf` | 엔진 전용 재귀 DNS(unbound) — mail 만 닿는 `mail-dns`(internal)와 자기 바깥 길 `mail-dns-egress` 에만 붙는다. 상태 없음(볼륨 없음) — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl) |
| `clamav` · `clamav-updater` | `docker-compose.yml` (운영은 늘 · 개발은 profile `clamav`) · 설정 `mail/clamav/clamd.conf` · `freshclam.conf` | 대용량 첨부의 악성 검사기(clamd)와 서명 갱신(freshclam) — 둘로 나눠 검사기는 바깥 길이 없다. 서명은 named volume `<PROJECT_NAME>_clamav-db` — [악성 검사 (ClamAV)](#악성-검사-clamav) |
| 대용량 첨부 파일 | backend 의 named volume `<PROJECT_NAME>_files` → `/app/files` (개발도 같다) | 기록은 DB. 30일짜리 전송물이라 백업하지 않는다 — [백업](#백업) · [대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트) |
| dev 오버라이드 | `docker-compose.dev.yml` | 운영 포트 제거, `127.0.0.1:${MAIL_ADMIN_PORT}` → 8080 만 |
| `mailpit` | `docker-compose.dev.yml` 전용 (profile `mail`) | 비-root(65534), UI `127.0.0.1:${MAILPIT_UI_PORT}` |
| setup 스크립트 | `mail/scripts/setup.mjs` | 아래 "setup 스크립트가 하는 일" |
@@ -77,6 +90,9 @@ node --env-file=.env.development mail/scripts/setup.mjs
- 출력의 `[생성]`·`[변경]`·`[유지]` 로 무엇이 바뀌었는지 보인다. 두 번째 실행부터는 전부 `[유지]` 여야 정상이다.
- ⚠️ **외부 발송 경로(Mailpit)는 setup 이 설정·리로드한 뒤에야 적용된다.** 그 전에 외부 주소로 보내면 실제 인터넷으로 나간다.
그래서 스크립트는 Mailpit 경로를 먼저 확정하고 나서 테스트 계정을 만든다. 시험 수신자는 예약 도메인(`example.net` 등)만 쓴다.
- **악성 검사기(ClamAV)는 개발에서 기본으로 띄우지 않는다**(메모리 1GB 남짓 · 처음 기동에 서명 110MB 를 받는다) — 그동안 다 올린 대용량 파일은 바로 "검사하지 못함"이다.
켜려면 `.env.development` 에 `COMPOSE_PROFILES=mail,clamav` 와 `CLAMAV_HOST=clamav` 를 두고 `up -d`(backend 가 새 값으로 다시 만들어진다) —
[악성 검사 (ClamAV)](#악성-검사-clamav). 서명은 볼륨에 남아 다음에는 바로 뜬다.
- 개발 메일 데이터를 버리고 처음부터:
```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development rm -sf mail mailpit
@@ -249,6 +265,10 @@ node --env-file=.env.development mail/scripts/setup.mjs
`data/` 가 그 사용자 소유여야 setup 이 bootstrap 임시 비밀번호를 보관할 수 있다 (`data/.gitkeep` 이 있어 클론 때 생긴다).
- **`MAIL_ADMIN_LOCAL_PART`** 를 bootstrap 전에 정한다 — mailcow 에 실제 `admin@` 메일함이 있으면 다른 아이디로 (bootstrap 뒤에는 바꿀 수 없다).
- **`MAIL_DIRECTORY_LOCAL_PART`**(사내 목록 계정, 기본 `mail-directory`)가 옮겨 올 메일함과 겹치지 않는다 — 겹치면 setup 이 그 계정을 건드리지 않고 실패한다 ([사내 목록 계정](#사내-목록-계정)).
- **대용량 첨부(11단계)** — `.env.production` 의 `SHARE_PUBLIC_URL`(공개 내려받기 호스트 — `https://files.arasystem.it.kr`, **`CORS_ORIGIN` 에 넣지 않는다**) ·
`CLAMAV_HOST=clamav`(둘 다 운영 필수 — 없으면 백엔드가 뜨지 않는다), 그 호스트의 DNS · 외부 프록시 Proxy Host
([대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트)), 악성 검사 서명 갱신의 바깥 HTTPS(`database.clamav.net`) ·
메모리 · 디스크([메모리 예산](#메모리-예산) — 8GB 서버에 스택 전체 + ClamAV). 첫 `up` 은 `clamav-updater` 가 서명을 받는 동안(1~3분) `clamav` 가 기다린다
복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다.
@@ -581,6 +601,161 @@ sudo docker logs "${PROJECT_NAME}-mail" 2>&1 | grep 'cannot validate DNSSEC'
---
## 대용량 첨부 (11단계 — 인프라)
일반 첨부 한도(20MB · 합계 25MB)를 넘는 파일은 메일 엔진이 아니라 **backend 의 볼륨**(`<PROJECT_NAME>_files` → `/app/files`)에 조각으로 올리고,
메일에는 받는 사람이 로그인 없이 여는 내려받기 링크(`SHARE_PUBLIC_URL/s/<토큰>`)를 넣는다. 다 올린 파일은 **ClamAV** 로 검사한다.
데이터 · 규칙은 [계약 4절](../docs/mail/api-contract.md) "대용량 첨부", env 는 루트 예시 env 의 "대용량 첨부" 칸이다. 이 절은 컨테이너 · 프록시 · 운영만 다룬다.
### 악성 검사 (ClamAV)
```
backend ─(clamav — internal · 바깥 길 없음)─▶ clamav clamd :3310 (INSTREAM) ◀── 볼륨 <PROJECT_NAME>_clamav-db (읽기 전용)
clamav-updater freshclam ──(clamav-egress)──▶ database.clamav.net (HTTPS · DNS)
└──────────────── 볼륨 <PROJECT_NAME>_clamav-db (씀) ──┘
```
| 항목 | 값 |
| --- | --- |
| 서비스 | `docker-compose.yml` 의 `clamav`(검사기 clamd) · `clamav-updater`(서명 갱신 freshclam). **운영은 profile 없이 늘 뜬다**(`CLAMAV_HOST` 가 운영 필수라 검사기가 없으면 검사가 서 버린다). 개발은 profile `clamav` — `COMPOSE_PROFILES=mail,clamav` + `CLAMAV_HOST=clamav` 일 때만 |
| 이미지 | 공식 `clamav/clamav:1.4.6_base` — digest `sha256:e7399106…`(2026-10-30 manifest list, linux/amd64 만). 1.4 는 ClamAV 의 **LTS**(2024-08 부터 3년 — 서명을 받을 수 있는 기간, <https://docs.clamav.net/faq/faq-eol.html>), 1.4.6 은 2026-08-07 보안 릴리스(2주가 지났다). `_base` 는 서명이 들지 않은 이미지다(서명은 볼륨에). 같은 판의 Debian 판(`_base-debian`)은 200MB 검사가 17% 느렸다(23.7초 · 20.3초) |
| 둘로 나눈 까닭 | clamd 는 바깥에서 온 파일(악의적인 압축 · 문서)을 연다 — 그 해석기에 구멍이 나도(2026 년에만 heap overflow CVE 여럿) 바깥 · 다른 컨테이너에 닿지 못하게 clamd 는 internal 네트워크 하나에만 둔다. 바깥에 나가는 것은 서명을 받는 freshclam 뿐이고, 그것은 backend · clamd 와 잇지 않는다. 새 서명은 clamd 가 10분마다(`SelfCheck`) 볼륨을 보고 다시 읽는다(`NotifyClamd` 를 쓰지 않는다) |
| 네트워크 | `clamav` — backend ↔ clamav 전용, **internal**. `clamav-egress` — updater 혼자. 둘 다 서브넷은 도커가 정한다(주소로 믿는 곳이 없다 — 기본 주소 풀 172.17~31.x · 192.168.x 이 서버 VPC 와 겹치지 않는지 본다). clamd 는 암호화 없는 TCP 이고 접속을 가리지 않으므로 다른 컨테이너를 붙이지 않는다. **app-network(신뢰 대역)에는 붙이지 않는다** |
| 실행 | 둘 다 이미지의 `clamav` 사용자(100:101) · 권한(capability) 모두 뺌 · `no-new-privileges` · `init: true`(PID 1 tini). 이미지의 `/init` 은 root 로 소유자를 바꾸고 freshclam 과 clamd 를 함께 띄우므로 쓰지 않고 `clamd --foreground` · `freshclam --daemon --foreground` 를 바로 띄운다(멈추면 컨테이너가 끝나 재시작 정책을 탄다). clamd 는 띄우기 전에 `/tmp` 의 `clamav-*`(지난번에 검사 중 죽으며 남긴 임시 파일)를 지운다 — 아래 "임시 디스크". 호스트 포트는 어떤 환경에서도 열지 않는다 |
| healthcheck | clamav — 이미지의 `clamdcheck.sh`(3310 PING → PONG). updater — 서명(main · daily)이 있고 daily 가 **이틀 안에** 바뀌었는지(멈추면 unhealthy 로 보인다 — 검사는 옛 서명으로 이어 간다). clamav 는 updater 가 healthy 가 된 뒤에 뜬다(`required: false` — 서명이 오래됐다고 검사기를 세우지 않는다) |
| 서명 갱신 | 하루 12번 묻고(DNS TXT `current.cvd.clamav.net`) 새 판이 있을 때만 받는다(cdiff). 받은 서명은 바꿔 넣기 전에 따로 읽어 본다(`TestDatabases yes` — 깨진 서명이 clamd 를 멈추지 않게). 처음 기동은 main · daily · bytecode 110MB(2026-10-30 — 서명 363만 개)를 받는 데 1분 안팎이었다 |
| 설정 | [`clamav/clamd.conf`](./clamav/clamd.conf) · [`clamav/freshclam.conf`](./clamav/freshclam.conf) — 이미지 기본 설정을 통째로 바꾼다(저장소는 umask 022 로 클론 — 컨테이너 사용자 100 이 읽어야 한다). 바꾼 뒤에는 `up -d --force-recreate clamav`(또는 `clamav-updater`). CI 의 infra 잡이 `clamconf` 로 읽고 아래 값이 그대로인지 본다 |
**한도 · 판정** (`clamd.conf`) — 한 파일 한도(`MAIL_LARGE_FILE_MAX_SIZE_MB`, 최대 2,047MB)까지 끝까지 검사한다:
| 설정 | 값 | 까닭 |
| --- | --- | --- |
| `StreamMaxLength` | 2047M | INSTREAM 으로 받는 양. 같으면 받고 1바이트 넘으면 `INSTREAM size limit exceeded. ERROR` — 백엔드는 이보다 큰 파일을 만들지 않는다 |
| `MaxFileSize` | 2047M | 한 파일(압축 안의 파일 포함). ClamAV 는 2GiB − 1 넘게는 검사하지 못한다(1.4.6 이 그 위를 2,147,483,645 로 줄이고 경고) |
| `MaxScanSize` | 4094M | 파일 하나에서 검사하는 양의 합(압축을 푼 내용까지). 푼 내용은 `/tmp`(컨테이너 쓰기 계층)에 잠시 쓴다 |
| `MaxScanTime` | 300000(5분) | 1.4 에서는 **ZIP 에만** 걸린다. 백엔드가 다 보낸 뒤 판정을 기다리는 시간(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분, 아래 "백엔드 쪽 시간")보다 짧게 둔다 — 넘으면 백엔드가 끊고 미뤄서 처음부터 다시 검사한다 |
| `AlertExceedsMax` · `AlertEncrypted` | yes · yes | **끄면 한도를 넘거나 암호가 걸린 파일이 `stream: OK`(깨끗함)로 온다**(아래 표의 "끈 때") |
| `ConcurrentDatabaseReload` | no | 서명을 다시 읽을 때 두 벌을 들지 않는다 — 1GB 그대로, 그 30초 안팎 동안 검사 · PING 이 기다린다 |
판정 실측 (2026-10-30, 개발 — clamd 1.4.6 · 위 설정. 백엔드와 같은 규약의 INSTREAM 시험 클라이언트, 그리고 백엔드로 끝까지):
| 보낸 것 | clamd 의 답 (`\0` 앞) | 백엔드 판정 |
| --- | --- | --- |
| 깨끗한 파일(18B · 10MB · 40MB · **2,047MB 무작위**) | `stream: OK` | clean |
| EICAR · EICAR 를 든 zip | `stream: Eicar-Test-Signature FOUND` | infected |
| 암호 zip(ZipCrypto · AES-256 — 안에 EICAR 가 있어도) | `stream: Heuristics.Encrypted.Zip FOUND` | unscannable |
| 암호 7z(내용만 · 머리글까지) | `stream: Heuristics.Encrypted.7Zip FOUND` | unscannable |
| 사용자 암호 pdf / 소유자 암호만 있는 pdf | `stream: Heuristics.Encrypted.PDF FOUND` / `stream: OK` | unscannable / clean |
| 20겹 zip · 파일 12,000개 zip | `stream: Heuristics.Limits.Exceeded.MaxRecursion FOUND` · `…MaxFiles FOUND` | unscannable |
| 풀면 2,100MB · 4,500MB 인 zip(0 으로 채움) | `stream: Heuristics.Limits.Exceeded.MaxScanTime FOUND`(5분) | unscannable |
| 풀면 600MB 인 zip | `stream: OK`(100초) | clean |
| 암호 항목 + EICAR 항목이 함께 든 zip · 한도 넘는 항목 + EICAR | `stream: Eicar-Test-Signature FOUND` — 진짜 서명이 휴리스틱보다 먼저다 | infected |
| `StreamMaxLength` 보다 큰 스트림(시험 clamd — 10M 에 20MB · 10M+1B) | `INSTREAM size limit exceeded. ERROR` 를 보내고 **FIN** — 클라이언트가 계속 보내면 clamd 쪽이 **RST**. 클라이언트가 답을 읽기 전에 RST 가 오면 답 없이 `EPIPE` 다(같은 시험에서 한 번은 답, 한 번은 `EPIPE`) | (백엔드 — 설정 오류로 검사 중 그대로 · error) |
| 끈 때(`AlertExceedsMax no`, 시험 clamd — MaxFileSize 5M) | 7MB 무작위 · 20겹 zip · 파일 12,000개 zip 모두 `stream: OK` | ⚠️ clean 이 된다 |
| ⚠️ 시험 clamd(MaxFileSize 5M · MaxScanSize 8M)에서 **zip 안의** 한도 넘는 항목(6MB · 600MB 의 0) | `AlertExceedsMax yes` 인데도 `stream: OK` | clean — 아래 "알려진 한계" |
- 로그는 `instream(<백엔드 주소>@<포트>): <이름> FOUND` 만 남는다(파일 이름 없음 · 깨끗한 파일은 남기지 않음). 백엔드는 판정 이름 · 파일 id 를 남긴다.
- **알려진 한계** — (1) 1.4.6 은 zip 안의 항목이 `MaxFileSize`·`MaxScanSize` 를 넘으면 그 항목을 건너뛰고 알리지 않을 때가 있다(위 표의 마지막 줄 — 운영 값에서는
풀면 2GB 가 넘는 zip 이 먼저 `MaxScanTime` 에 걸렸다). (2) 검사 한 번은 코어 하나로 **2,047MB 에 5분 안팎**이다(이 PC — 1분에 400MB, 운영 CPU 가 느리면 더 걸린다).
zip 밖의 압축(7z · tar.gz 등)은 시간 상한 없이 `MaxScanSize`(4GB)까지 푼다 — 4,094MB 를 코어 하나로 10분 안팎, 운영 CPU 가 2~3배 느리면 20~30분이다.
백엔드의 판정 대기(30분 + 1MB 마다 1초)가 그것을 덮는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다(그동안 "검사 중").
(3) 검사는 한 번이다 — 나중에 나온 서명으로 다시 보지 않는다(계약 4절).
- **백엔드 쪽 시간** (`backend/src/modules/file-storage/file-storage.constants.ts`) — 연결 10초, 흘려보내는 동안 아무것도 오가지 않으면 10분(clamd 는 받는 대로 임시 파일에 적는다),
다 보낸 뒤 판정까지 **30분 + 보낸 1MB 마다 1초**(`clamavVerdictTimeoutMs` — 2,047MB 면 3,847초 ≈ 64분). 연결에는 TCP keepalive(첫 대기 60초)를 켜서
컨테이너째 사라진 clamd(RST 도 오지 않는다)를 긴 판정 대기와 상관없이 1분 남짓에 알아차린다. 어느 쪽이든 끊기면 미뤄서 다시 한다.
- **CPU** — `cpus: 2.0`(백엔드가 한 번에 2개까지 보낸다) · `cpu_shares: 512`(다른 컨테이너와 다툴 때 절반 몫 — 웹메일 · 메일 엔진이 먼저). 서버가 2 vCPU 보다 적으면 `cpus` 를 낮춘다(넘으면 `up` 이 실패한다).
- **임시 디스크** — clamd 는 받은 스트림을 `/tmp` 에 다 쓴 뒤 검사하고 지운다(2,047MB 시험 — 2GB 썼다가 0 으로). 한 번에 2개 × (2GB + 푼 내용 4GB 까지)가 도커 data-root 의 디스크(볼륨과 같은 디스크)에 잠시 든다.
검사 중에 clamd 가 죽으면(OOM 등) 그 임시 파일(`/tmp/clamav-<hex>.tmp`)은 지워지지 않고, 재시작은 같은 쓰기 계층을 쓰므로 죽을 때마다 쌓인다 —
그래서 compose 가 clamd 를 띄우기 전에 `rm -rf /tmp/clamav-*` 한다(2026-10-30 시험 clamd: 800MB 를 흘려보내다 죽이기를 두 번 — 고치기 전에는 324MB → 681MB 로 쌓였고, 고친 뒤에는 다시 뜰 때마다 비었다).
`recreate` 없이 `restart` 만 해도 지운다.
```bash
# 상태 — 둘 다 healthy, 서명 판(daily 번호 · 날짜)
sudo docker compose --env-file .env.production ps clamav clamav-updater
sudo docker exec "${PROJECT_NAME}-clamav" sh -c 'echo VERSION | nc localhost 3310' # ClamAV 1.4.6/<daily 판>/<날짜>
sudo docker logs --tail 20 "${PROJECT_NAME}-clamav-updater" # daily.cld updated / database is up-to-date
# 판정 시험 — EICAR 는 파일로 두지 않는다(서버 백신이 지울 수 있다). 백엔드 컨테이너의 node 로 clamav 에 INSTREAM 한 번
sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav'),e=Buffer.from('WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo=','base64'),h=Buffer.alloc(4);h.writeUInt32BE(e.length);s.on('connect',()=>s.end(Buffer.concat([Buffer.from('zINSTREAM\0'),h,e,Buffer.alloc(4)])));s.on('data',d=>console.log(String(d)))"
# → stream: Eicar-Test-Signature FOUND
```
### 대용량 첨부 — 공개 내려받기 호스트
받는 사람은 포털과 **다른 호스트**(`SHARE_PUBLIC_URL` — 운영 `https://files.arasystem.it.kr`)에서 받는다. 털린 계정이 퍼뜨린 악성 파일로 포털 주소까지
차단 목록(세이프 브라우징 등)에 오르는 위험을 떼어 놓고, 포털 쿠키(호스트 한정)도 그 호스트로 가지 않는다. 외부 프록시는 포털과 **같은 사설 IP:8080** 으로 넘긴다 —
프론트 nginx 가 `Host` 로 server 를 가른다(`frontend/nginx.conf`):
| | 포털 (`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) |
| 백엔드 | 같은 판정으로 두 겹 막는다 — `Host` 의 호스트 이름이 공개 호스트면 `/api/public-links/` 만, 아니면 그것만 404(`modules/file-sharing/public-host.ts`) | |
- `SHARE_PUBLIC_URL` 이 비었거나 그 호스트 이름이 `CORS_ORIGIN` 에 있으면 따로 두지 않는다 — 포털이 둘 다 연다(개발 · 같은 호스트 운영. `nginx-env.envsh` 가 고르고 `docker logs` 첫 줄에 남긴다).
**운영은 넣지 않는다** — `CORS_ORIGIN` 에 공개 호스트를 넣으면 분리가 꺼진다(백엔드가 운영에서는 기동을 막는다).
`SHARE_PUBLIC_URL` 의 모양(출처 — 경로 · 끝의 `/` · 끝 점 없이)이 틀리면 frontend 가 뜨지 않는다(`nginx-env.envsh:` 줄).
- **조각 올리기** `PUT /api/file-uploads/{id}/chunks/{n}` 만 본문 **17m**(조각 16MiB + 여유 — 계약 상수라 env 로 바꾸지 않는다) · 읽기 90초. 요청 본문은 nginx 가 다 받아 둔 뒤 넘긴다(`/tmp/client_temp`).
그 밖의 `/api/file-uploads` · `/api/file-quota` · `/api/share-links` · `/api/share-link-lookups` 는 JSON 1m 그대로다. 조각 요청은 포털 호스트로 온다.
이 경로의 오류 로그는 `error` 부터다 — 조각마다 nginx 가 `a client request body is buffered to a temporary file`(warn)을 남겨 2GB 파일 하나에 128줄이 쌓였다(본문 버퍼는 키우지 않는다 — 동시 조각 수 × 16MiB 가 메모리에 앉는다).
- **받기** `/api/public-links/<토큰>/files/…`(공개) · `/api/messages/<id>/large-files/…`(앱 안) — 응답 버퍼링 · 압축을 끄고 `X-Accel-Buffering: no` 를 외부 프록시로 넘긴다(실시간 알림과 같다),
`Range` · `If-Range` 는 그대로 넘긴다(백엔드가 206 · 416). 머리글 버퍼는 16k(`proxy_buffer_size` — 한글 255자 파일 이름이면 응답 머리글이 3.5KB 쯤이라 기본 4k 로는 빠듯하다).
**동시 연결** — 클라이언트 주소마다 공개 10 · 앱 안 30(사무실 NAT), 전체는 공개 400 · 앱 안 200 을 **따로** 센다(공개 받기가 가득 차도 직원의 앱 안 받기는 된다). 넘으면 429 `REQ_001`(JSON).
클라이언트 주소는 `X-Forwarded-For` 의 마지막 칸(외부 프록시가 이어 붙인 값)이다 — 앞에 꾸민 값을 붙여도 마지막 칸으로 센다.
두 경로는 **대소문자 · `%2F` · 겹 `/` 를 가리지 않고** 같은 설정을 탄다 — 백엔드(Express)의 라우팅이 대소문자를 가리지 않아 `/api/Messages/…/LARGE-FILES/…` 도 받기 처리기에 닿기 때문이다
(nginx 의 대소문자를 가리는 prefix 에 기대면 한도 · 버퍼링 끔을 비켜 간다 — 2026-10-30 리뷰. CI 의 infra 잡 "대용량 받기 경로 시험"이 변형마다 실제 요청으로 본다).
그릇은 nginx 워커마다 `worker_connections` 4096(이미지 기본 1024 에서 올림 — `frontend/Dockerfile`) · frontend 의 열린 파일 수 8192(compose `ulimits`)다 —
받기 600 개(연결 1,200) · 실시간 알림(브라우저마다 연결 둘) · 포털 요청이 워커 하나에 몰려도 모자라지 않게
(2026-10-30 운영 이미지 시험: 받기 600 개를 붙잡아 둔 채 401번째 공개 · 201번째 앱 안 받기가 429, 그때도 다른 API · 실시간 알림은 200).
- **느린 받는 사람** (2026-10-30, 운영 이미지 · 개발 백엔드 · 40MB 파일): 조금씩이라도 받아 가면 끊지 않는다 — 초당 120KB 로 80초(읽기 제한 60초를 넘겨) 그대로 받았다.
`proxy_read_timeout` 은 받는 쪽이 느려 nginx 가 백엔드를 읽지 않는 동안에는 돌지 않았다(5초로 줄인 시험에서도 15초 동안 받았다). 받는 쪽이 **아무것도 받지 않으면** `send_timeout`(60초 — 기본값)에
끊긴다 — 소켓 버퍼만큼(5MB) 보낸 뒤 61.7초에 닫고 그 연결 수를 돌려준다(10초로 줄인 시험에서는 10.9초). 끊긴 받기는 브라우저 · 내려받기 관리자가 `Range` 로 이어 받는다.
- **접근 로그** — 토큰을 `***` 로 가린다(요청 줄 · Referer — 예 `"GET /api/public-links/***/files/<id> HTTP/1.1"`). 두 가지로 겹쳐 본다:
`/s/<…>` · `/api/public-links/<…>` 의 그 칸(대소문자 무시 · 길이 상관없음), 그리고 토큰 모양(base64url 딱 43자 — 앞뒤가 `[A-Za-z0-9_-]` 가 아닌 덩어리, `%XY` 바로 뒤의 덩어리)이면 경로 어디서든 —
nginx 는 정규화해 받지만 로그는 받은 그대로라 `//s/<토큰>` · `/./s/<토큰>` · `/%73/<토큰>` · `/x/../s/<토큰>` · `/api/public%2Dlinks/<토큰>` · `/api/public-links%2F<토큰>` 같은 꼴도 가린다.
43자가 아닌 값(UUID 36자 · 메일 id · 화면 자산 이름 — 가장 긴 것 35자)은 그대로 남는다. 토큰 안을 퍼센트 인코딩으로 쪼갠 꼴은 가리지 못한다(그 토큰을 가진 본인만 만든다).
오류 로그(502 · 504 등)는 지금 정책대로 요청 줄을 그대로 남긴다(운영 담당만 읽는다). 동시 연결 초과는 오류 로그에 남기지 않는다(접근 로그의 429 로 본다).
**외부 프록시 (서버 A 의 Nginx Proxy Manager)** — [포털 Proxy Host](#nginx-proxy-manager-설정)와 같은 칸으로 하나 더 만든다:
| 칸 | 값 |
| --- | --- |
| Domain Names | `files.arasystem.it.kr` (가비아 `files` A → 서버 A 공인 IP — 이미 있다) |
| Scheme · Forward Hostname / IP · Port | `http` · 서버 B 사설 IP · `8080` — 포털과 같은 곳(Host 는 NPM 이 그대로 넘긴다 — `proxy.conf` 의 `proxy_set_header Host $host`) |
| Cache Assets · Block Common Exploits · Websockets | 끔 · 끔 · 끔 (포털과 같다) |
| SSL | Let's Encrypt 새 인증서(`files.`) · Force SSL · HTTP/2 |
| Advanced | 포털과 같은 다섯 줄(`proxy_read_timeout 150s;` · `access_log off;` · `real_ip_header proxy_protocol;` · `proxy_buffer_size 16k;` · `proxy_busy_buffers_size 16k;` — [포털 Proxy Host](#nginx-proxy-manager-설정)). **`access_log off;` 는 꼭** — 경로에 내려받기 토큰이 있다(NPM 의 `proxy` 형식은 경로를 그대로 남긴다) |
- 포털 Proxy Host 의 본문 한도(NPM 기본 2000m)는 조각 17MB 보다 크다 — 줄였다면 17MB 이상, 읽기는 이미 150초다.
- 받기 응답은 우리가 넘기는 `X-Accel-Buffering: no` 를 NPM(nginx)이 따라 버퍼링하지 않는다 — `proxy_ignore_headers` 로 이 머리글을 무시하게 했다면 그 호스트에 `proxy_buffering off;` 를 더한다
(버퍼링하면 느린 받는 사람마다 NPM 이 임시 파일을 1GB 까지 만든다).
- 받기 응답의 머리글(한글 255자 파일 이름이면 3.5KB 쯤)은 NPM 의 머리글 버퍼에도 들어가야 한다 — NPM v2.16.0 은 버퍼 크기를 정하지 않아 nginx 기본(4k — 그 이미지의 페이지 크기 4096)이고,
넘치면 NPM 이 502(HTML)를 낸다. 그래서 두 Proxy Host 의 Advanced 에 `proxy_buffer_size 16k;` 와 **`proxy_busy_buffers_size 16k;` 를 함께** 넣는다 —
앞의 것만 넣으면 nginx 가 설정을 거절해(`"proxy_busy_buffers_size" must be less than the size of all "proxy_buffers" minus one buffer`) NPM 이 그 Proxy Host 를 Offline 으로 둔다
(2026-10-30, `jc21/nginx-proxy-manager:2.16.0` 이미지의 OpenResty 1.31.1.1 로 `nginx -t` — 한 줄만이면 실패, 두 줄이면 통과. 이미지의 `nginx.conf` · `conf.d/include/*.conf` 에는 `proxy_buffer*` 가 없다).
- 확인: 바깥에서 `curl -sI https://files.arasystem.it.kr/` → 404 · `curl -s https://files.arasystem.it.kr/api/auth/me` → 404 `RES_001` · `curl -s https://portal.arasystem.it.kr/api/public-links/x` → 404 `RES_001`,
보낸 메일의 링크(`/s/…`)가 열리고 받아진다. 서버 B 의 `sudo docker logs --tail 5 "${PROJECT_NAME}-frontend"` 에 토큰이 `***` 로 보인다.
### 메모리 예산
8GB 서버(서버 B) 하나에 스택 전체가 올라간다. 한도(compose `deploy.resources.limits.memory`)의 합이 실제 메모리를 넘지 않게 둔다 — 평소에는 한도보다 훨씬 적게 쓴다.
| 컨테이너 | 한도 | 평소 (2026-10-30 개발에서 잰 값) | 가장 클 때 |
| --- | --- | --- | --- |
| clamav (clamd) | 2g | 0.95GB — 서명 363만 개를 읽은 뒤 | 서명을 다시 읽을 때 1.0GB(두 벌을 들지 않는다), 2GB 검사 중 1.0~1.2GB + 받은 파일의 페이지 캐시(한도 안에서 줄여 쓴다) |
| clamav-updater (freshclam) | 1g | 수십 MB | 처음 기동(서명 전체를 받아 읽어 봄) 0.7GB, 평소 갱신은 daily 만 |
| mail | 1g | 0.1~0.3GB | |
| backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 |
| db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | |
| **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 |
- **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다.
한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다.
- 서명은 해마다 는다 — `docker stats` 의 clamav 가 한도의 80%(1.6GB)를 넘으면 한도를 올리거나(합을 다시 본다) 서버를 키운다.
- 디스크(블록 스토리지 100GB · 도커 data-root `/data/docker`) — 대용량 첨부 파일(직원마다 10GB · `FILE_STORAGE_MAX_TOTAL_GB`) · 메일 볼륨 · DB · 이미지 · ClamAV 의 임시 파일(검사 중 최대 12GB)이 함께 쓴다.
백엔드는 올리기를 만들 때 `FILE_STORAGE_MIN_FREE_GB`(10GB)를 남긴다 — 메일 볼륨이 자랄 몫을 생각해 `FILE_STORAGE_MAX_TOTAL_GB` 를 정해 둔다(예: 40).
---
## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저)
전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다.
@@ -962,6 +1137,8 @@ imapsync \
| --- | --- |
| `<PROJECT_NAME>_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** |
| `<PROJECT_NAME>_mail-data` | RocksDB — 메일·계정·설정 전체 |
| `<PROJECT_NAME>_files` | 대용량 첨부 파일(11단계) — **백업하지 않는다**(보낸 날부터 30일짜리 전송물 · 계약 4절). 기록(이름 · 크기 · 링크)은 DB 백업에 든다 — 복원한 뒤 바이트가 없는 파일은 받는 쪽에 "찾을 수 없음"이다. ⚠️ `docker compose down -v` 가 지운다 |
| `<PROJECT_NAME>_clamav-db` | 악성 검사 서명 — 백업하지 않는다. 지우면 `clamav-updater` 가 다시 받는다(1분 안팎) |
RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다.
멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다.
@@ -1187,7 +1364,7 @@ TRUST_PROXY=172.16.72.0/24,10.0.1.11 # <EDGE_NETWOR
| SSL › Force SSL | 켬 | |
| SSL › HTTP/2 Support | **켬** | 실시간 알림 연결이 HTTP/1.1 의 주소당 연결 6개 가운데 하나를 1시간 잡지 않게. NPM → frontend 는 HTTP/1.1 이다(`proxy_http_version 1.1`) |
| SSL › HSTS Enabled | 선택 | 켜면 브라우저가 이 주소를 https 로만 연다 |
| Advanced › Custom Nginx Configuration | 아래 세 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
| Advanced › Custom Nginx Configuration | 아래 다섯 줄 | 템플릿이 server 단계, 이 Proxy Host 의 `access_log` · `error_log` 뒤에 넣는다. `location /` 는 쓰지 않는다(쓰면 NPM 이 기본 location 을 빼고 그것을 쓴다) |
```nginx
# 첨부 올리기 — 프론트 nginx 가 백엔드를 150초까지 기다린다(/api/mail-attachments). NPM 기본은 http 단계 90s
@@ -1198,10 +1375,15 @@ access_log off;
# 사설 대역에서 온 접속의 X-Real-IP 를 클라이언트 주소로 받지 않는다 — NPM 기본은 받는다(nginx.conf 의 set_real_ip_from · real_ip_header).
# 이 호스트는 PROXY protocol 로 받지 않아 바꿔 넣을 주소가 없으므로 접속 주소가 그대로 남는다
real_ip_header proxy_protocol;
# 대용량 첨부 받기(11단계 — 앱 안 받기는 이 포털로 온다)의 응답 머리글이 한글 255자 파일 이름이면 3.5KB 쯤이다 — 기본 4k 로는 빠듯하다(넘치면 502).
# 두 줄을 함께 — 앞의 것만 넣으면 nginx 가 설정을 거절해 이 Proxy Host 가 Offline 이 된다(아래 표 "머리글 버퍼")
proxy_buffer_size 16k;
proxy_busy_buffers_size 16k;
```
- **`files.arasystem.it.kr`** — 11단계(대용량 첨부의 공개 내려받기, `SHARE_PUBLIC_URL`)부터 쓴다. 포털 호스트와 같은 사설 IP:8080 으로 Proxy Host 를 하나 더 만든다 —
위와 같은 칸 · 같은 고급 설정, 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다. 그 호스트에서 무엇을 열지는 11단계에서 정한다.
위와 같은 칸 · 같은 고급 설정(다섯 줄), 인증서는 `files.` 로 새로. **접근 로그는 꼭 끈다** — 경로에 내려받기 토큰이 있다.
그 호스트에서 여는 것 · 확인은 [대용량 첨부 — 공개 내려받기 호스트](#대용량-첨부--공개-내려받기-호스트).
- 고급 칸을 저장한 뒤 Proxy Host 가 Online 인지 본다 (NPM 이 저장할 때 `nginx -t` 를 돌리고, 실패하면 그 설정을 빼고 Offline 으로 오류를 보여 준다 — `backend/internal/nginx.js`).
v2.16.0 기본값과 이 포털에 필요한 것:
@@ -1218,6 +1400,7 @@ v2.16.0 기본값과 이 포털에 필요한 것:
| `X-Forwarded-For` | `proxy.conf` 의 `$proxy_add_x_forwarded_for` (이어 붙인다) | 이어 붙인다 | 그대로 |
| `X-Real-IP` 믿기 | `set_real_ip_from` 10/8 · 172.16/12 · 192.168/16 + `real_ip_header X-Real-IP` | 사설 대역에서 와도 접속 주소 그대로 | 고급 칸 `real_ip_header proxy_protocol;` |
| 접근 로그 | Proxy Host 마다 `proxy` 형식 — `"$request_uri"`(쿼리째) · Referer | 쿼리를 남기지 않는다 | 고급 칸 `access_log off;` |
| 머리글 버퍼 | 정하지 않음 → nginx 기본 `proxy_buffer_size` 4k · `proxy_buffers 8 4k` (이미지의 페이지 크기 4096 — 2026-10-30 이미지로 확인) | 대용량 받기의 응답 머리글 3.5KB + 여유 | 고급 칸 `proxy_buffer_size 16k;` + `proxy_busy_buffers_size 16k;` — 앞의 것만이면 `proxy_busy_buffers_size` 의 기본(2 × 16k)이 `proxy_buffers` 의 "하나 뺀 합"(28k)을 넘어 `nginx -t` 가 실패한다(NPM 2.16.0 이미지의 OpenResty 1.31.1.1 로 확인). 버퍼링을 끈 받기는 본문도 이 크기로 넘긴다 |
**Block Common Exploits**(`block-exploits.conf`)는 `$query_string` 을 대소문자 가려 보고, 걸리면 NPM 의 403(HTML — 표준 실패 JSON 이 아니다)을 낸다. 웹메일에서 걸리는 것:
@@ -1326,6 +1509,17 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend"
| `MAIL_SPAMHAUS_DQS_KEY 는 Spamhaus 가 준 26자 영문 · 숫자 키여야 합니다` · `운영의 MAIL_SPAMHAUS_DQS_KEY 는 셸이 아니라 …` | 키를 `.env.production` 에 따옴표 · 공백 없이 그대로 넣는다(셸에는 두지 않는다 — `unset MAIL_SPAMHAUS_DQS_KEY`) |
| `[주의] 스팸 차단 목록 …(STWT_…)을 찾지 못해 맞추지 않았습니다` | 엔진 기본 항목을 지웠거나 이름을 바꿨다 — 바꿨다면 그 항목의 조회 이름 · 켜짐을 관리 화면 › Settings › Spam Filter › DNSBL 에서 직접 본다 |
| 외부로 보낸 메일이 Mailpit 에 없다 | setup 의 `[확인] 설정 리로드 완료` 가 나왔는지. 로그: `docker logs "${PROJECT_NAME}-mail"` 의 `delivery.*`. `delivery.ip-lookup-failed … hostname = "mailpit"` 이면 개발 엔진의 DNS 가 `Custom` 이다 — setup 을 다시 돌리면 `System` 으로 돌아온다 |
| 대용량 파일이 오래 "검사 중"이다 · 백엔드 로그에 검사기 오류(세 번째부터 error) | 검사기가 멈췄거나 닿지 않는다 — 판정 없이 받게 되는 일은 없다(닫힌 쪽), 고치면 백엔드가 차례대로 다시 한다(1분부터 두 배씩 1시간까지). `sudo docker compose --env-file .env.production ps clamav clamav-updater`(둘 다 healthy) · `sudo docker logs --tail 30 "${PROJECT_NAME}-clamav"` · 백엔드에서 `sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').connect(3310,'clamav');s.on('connect',()=>s.write('zPING\0'));s.on('data',d=>{console.log(String(d));process.exit(0)});s.on('error',e=>{console.log(e.code);process.exit(1)})"` → `PONG`. `ENOTFOUND` 면 clamav 가 없다(`up -d`), `ECONNREFUSED` 면 서명을 읽는 중이거나 죽었다(로그). clamav 가 `Exited` 면 서명이 없거나(updater 를 먼저 본다) 메모리 한도(`docker inspect … OOMKilled`) — [악성 검사 (ClamAV)](#악성-검사-clamav) |
| 2GB 가까운 파일만 오래 "검사 중"이다 | 검사 한 번이 백엔드의 판정 대기(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분)를 넘는다 — 넘으면 끊고 미뤄서 처음부터 다시 검사한다. 코어 하나로 2GB 에 5분 안팎(개발 PC)이라 보통은 넉넉하다 — 운영 CPU 가 아주 느리거나 zip 밖의 큰 압축(7z · tar.gz — 시간 상한 없음, `MaxScanSize` 4GB 까지 푼다)이면 더 걸린다. 백엔드 로그의 `clamd 가 다 받은 뒤 …초 안에 판정을 보내지 않았습니다` · clamav 로그에 그 판정이 늦게 남는지 본다. 한 번에 둘을 검사하면 `cpus`(2.0)를 나눠 쓴다 — CPU 를 늘리거나 백엔드에 알린다 |
| `clamav-updater` 가 unhealthy · 서명이 오래됐다 (`echo VERSION \| nc localhost 3310` 의 날짜) | updater 의 healthcheck 는 daily 서명이 이틀 넘게 바뀌지 않으면 실패한다. `sudo docker logs --tail 50 "${PROJECT_NAME}-clamav-updater"` — 바깥 HTTPS · DNS(`database.clamav.net` · `current.cvd.clamav.net`)가 막혔거나(NCP ACG 아웃바운드 443 · 53), CDN 이 잦은 요청으로 잠시 막았거나(`You are on cool-down until …` — 그 시각까지 기다린다, 컨테이너를 되풀이해 다시 만들지 않는다), 판이 끝나(EOL) 서명을 주지 않는다(`clamav/clamav` 판을 올린다 — docker 스킬 "이미지 버전 고정"). 검사는 옛 서명으로 이어 간다 |
| `up` 이 `optional dependency "clamav-updater" failed to start` 경고를 내고 `clamav` 가 곧 끝난다 | 처음 기동에서 서명을 받지 못했다(updater 가 unhealthy — clamd 는 서명이 없으면 뜨지 않는다). updater 로그를 보고 고친 뒤 `up -d`. 서명 볼륨을 지웠다면 updater 가 다시 받는 1분 안팎을 기다린다 |
| 대용량 파일을 올릴 수 없다 — 507 `FILE_006` | 서버 총량(`FILE_STORAGE_MAX_TOTAL_GB`) 또는 디스크 여유(`FILE_STORAGE_MIN_FREE_GB`)에 걸렸다 — `df -h /data/docker` · `sudo docker system df -v`(볼륨 `<PROJECT_NAME>_files` 크기). 기간이 지나거나 끊은 링크의 파일은 한 시간마다 지워진다. ClamAV 가 검사하는 동안 `/tmp` 에 잠시 쓰는 몫(최대 12GB)도 같은 디스크다 |
| 받는 사람이 내려받기에서 429 `REQ_001` | 프론트 nginx 의 동시 연결 수 — 같은 주소에서 공개 받기 10개 · 앱 안 받기 30개, 또는 전체 공개 받기 400개 · 앱 안 받기 200개(따로 센다)를 넘었다(접근 로그의 429 — 대소문자를 바꾼 경로도 같은 수로 센다). 백엔드의 요청 수 한도(공개 받기 IP 당 1분 300회)와 같은 코드다 — 백엔드 로그에 없으면 nginx 쪽이다. 사무실 NAT 뒤에서 앱 안 받기가 잦으면 `frontend/nginx.conf` 의 수를 올린다(전체를 올리면 `worker_connections` · `nofile` 그릇도 다시 본다 — 받기 하나가 연결 둘) |
| 내려받기만 502(HTML — NPM 의 오류 화면)이고 다른 화면 · API 는 된다 | 외부 프록시(NPM)의 머리글 버퍼에 받기 응답 머리글이 넘쳤다 — 긴 한글 파일 이름이면 3.5KB 를 넘는다. 두 Proxy Host 의 Advanced 에 `proxy_buffer_size 16k;` · `proxy_busy_buffers_size 16k;` 가 있는지 본다([Nginx Proxy Manager 설정](#nginx-proxy-manager-설정)). NPM 의 오류 로그(NPM 컨테이너 안 `/data/logs/proxy-host-<번호>_error.log` — 2.16.0 템플릿)에 `upstream sent too big header` 가 남는다. 프론트 nginx 는 16k 다 — 프론트 nginx 의 502 면 JSON `SYS_001` 이다 |
| NPM 의 Proxy Host 가 Offline — `"proxy_busy_buffers_size" must be less than …` | Advanced 에 `proxy_buffer_size 16k;` 만 넣었다 — `proxy_busy_buffers_size 16k;` 를 함께 넣고 저장한다 |
| clamav 의 디스크(쓰기 계층)가 줄지 않는다 · `docker system df` 의 clamav 컨테이너가 크다 | 검사 중에 clamd 가 죽으며 남긴 임시 파일이다(`/tmp/clamav-<hex>.tmp` — 받던 스트림 · 푼 내용). compose 는 clamd 를 띄울 때마다 지우므로 다시 뜬 뒤에도 크면 지금 검사 중인 것이다(한 번에 2개 × 최대 6GB). `sudo docker exec "${PROJECT_NAME}-clamav" du -sh /tmp` · `ls -la /tmp`. 자주 죽는다면 까닭을 본다 — `docker inspect -f '{{.State.OOMKilled}} {{.RestartCount}}' "${PROJECT_NAME}-clamav"`(메모리 한도 — [메모리 예산](#메모리-예산)) · `docker logs` |
| frontend 가 뜨지 않는다 — `nginx-env.envsh: SHARE_PUBLIC_URL 은 공개 내려받기 화면의 출처…` | `.env.production` 의 `SHARE_PUBLIC_URL` 을 `https://호스트[:포트]` 로(경로 · 끝의 `/` · 끝 점 없이) 고친 뒤 `up -d frontend` |
| `up` 이 `all predefined address pools have been fully subnetted` 로 멈춘다 | 도커 기본 주소 풀(172.17~31.x · 192.168.x — 네트워크 31개)이 다 찼다 — 서브넷을 정하지 않은 네트워크(`clamav` · `clamav-egress` · `mail-dns-egress`)를 만들 자리가 없다. 쓰지 않는 네트워크를 지운다(`docker network prune` — 개발 PC 는 다른 프로젝트 · CI 의 것이 쌓인다) |
### 리졸버 장애 때
+66
View File
@@ -0,0 +1,66 @@
# ==========================================
# clamd — 대용량 첨부(11단계)의 악성 검사기 · docker-compose.yml 의 clamav 가 읽는다
# (계약 docs/mail/api-contract.md 4절 "악성 검사", mail/README.md "악성 검사 (ClamAV)")
#
# 백엔드가 다 올린 파일을 TCP 3310 의 INSTREAM 으로 흘려보내고 판정 한 줄(\0 으로 끝)을 받는다(backend modules/file-storage/clamav-client.ts).
# 이 컨테이너는 백엔드와만 닿는 internal 네트워크(<PROJECT_NAME>_clamav)에만 붙는다 — 바깥 길이 없다.
# 서명은 clamav-updater(freshclam)가 받아 둔 볼륨을 읽기 전용으로 읽는다.
#
# 판정 — 2026-10-30 ClamAV 1.4.6 · 이 설정으로 확인 (mail/README.md "악성 검사 (ClamAV)" 의 표):
# stream: OK 깨끗함 — 2,047MB 짜리도 끝까지(코어 하나로 5분 안팎)
# stream: Eicar-Test-Signature FOUND 위험(EICAR — 압축 안에 있어도 같다). 그 밖의 서명 이름도 같은 꼴
# stream: Heuristics.Encrypted.Zip FOUND 검사하지 못함 — 암호 zip(ZipCrypto · AES). 7z 는 .7Zip, 사용자 암호 pdf 는 .PDF
# stream: Heuristics.Limits.Exceeded.<한도> FOUND 검사하지 못함 — MaxFileSize · MaxRecursion · MaxFiles · MaxScanTime 등 아래 한도
# INSTREAM size limit exceeded. ERROR StreamMaxLength 를 넘는 스트림 — 답한 뒤 연결을 닫는다(FIN, 계속 보내면 RST)
# 같은 압축 안에 진짜 서명과 휴리스틱(암호 · 한도)이 함께 있으면 진짜 서명이 나온다(HeuristicScanPrecedence 기본값 끔)
#
# 이미지의 기본 설정을 이 파일로 통째로 바꾼다. compose 가 처음부터 비-root(이미지의 clamav 사용자 100:101)로 띄우므로
# 이 파일은 그 사용자가 읽을 수 있어야 한다(644). 바꾼 뒤에는 docker compose … up -d --force-recreate clamav
# CI(infra 잡)가 clamconf 로 이 파일을 읽고 아래 한도 · 경보 값이 그대로인지 본다
# ==========================================
DatabaseDirectory /var/lib/clamav
# INSTREAM 으로 받은 내용 · 압축을 푼 내용을 여기에 잠시 쓰고 검사가 끝나면 지운다 — 컨테이너 쓰기 계층(도커 data-root 의 디스크)이다.
# 한 번에 검사하는 수(백엔드가 2개) × (파일 2GB + 푼 내용 MaxScanSize 까지)만큼 든다. tmpfs 로 바꾸지 않는다 — 메모리 한도에 잡힌다
TemporaryDirectory /tmp
# 로그는 표준 출력으로만(docker logs) — 파일에 남기지 않는다. 깨끗한 파일은 남기지 않고(LogClean 기본값 끔),
# 판정은 "instream(<백엔드 주소>@<포트>): <이름> FOUND" — 파일 이름은 남지 않는다
Foreground yes
LogTime yes
# 받는 곳 — 컨테이너의 3310. 닿는 것은 백엔드뿐이다(internal 네트워크 · 호스트 포트 없음). 유닉스 소켓은 쓰지 않는다
TCPSocket 3310
# ------------------------------------------
# 한도 — 한 파일 한도(MAIL_LARGE_FILE_MAX_SIZE_MB, 최대 2,047MB = 2,146,435,072바이트)까지 끝까지 검사한다
# ------------------------------------------
# INSTREAM 으로 받는 양 — 이 값까지는 받는다(같으면 받는다 · 1바이트 넘으면 "INSTREAM size limit exceeded. ERROR").
# 한 파일 한도의 최대값과 같다 — 백엔드는 이보다 큰 파일을 만들지 않는다(env 는 2,047 을 넘지 못한다). 넘으면 설정 오류다
StreamMaxLength 2047M
# 한 파일(밖의 파일 · 압축 안의 파일)의 크기 — 넘는 것은 검사하지 않는다. ClamAV 는 2GiB − 1 바이트 넘게는 검사하지 못한다(1.4.6 은 그 위를 2,147,483,645 로 줄인다)
MaxFileSize 2047M
# 파일 하나에서 검사하는 양의 합 — 압축 · 문서 안을 풀어 검사하는 양까지 센다. 한 파일 한도의 두 배 — 풀면 커지는 압축도 대부분 끝까지 본다
MaxScanSize 4094M
# 압축(ZIP) 하나를 검사하는 시간 — 1.4 에서는 ZIP 에만 걸린다(풀면 수 GB 인 zip 은 이 시간에 "Heuristics.Limits.Exceeded.MaxScanTime").
# 백엔드가 다 보낸 뒤 판정을 기다리는 시간(30분 + 보낸 1MB 마다 1초 — 2,047MB 면 약 64분, backend file-storage.constants.ts 의 clamavVerdictTimeoutMs)보다
# 짧게 둔다 — 넘으면 백엔드가 끊고 미뤄서 처음부터 다시 검사한다. zip 이 아닌 압축(7z · tar.gz 등)에는 시간 상한이 없다(MaxScanSize 까지 푼다 —
# 4,094MB 를 코어 하나로 10분 안팎, 백엔드의 30분 몫이 이것을 덮는다)
MaxScanTime 300000
# MaxRecursion(압축 겹 17) · MaxFiles(압축 안 파일 10,000) 등 나머지 한도는 기본값
# 한도를 넘거나(Heuristics.Limits.Exceeded.*) 암호가 걸려(Heuristics.Encrypted.* — zip · 7z · rar · pdf) 끝까지 보지 못한 파일을 판정으로 알린다.
# ⚠️ 끄면 그런 파일이 "깨끗함(stream: OK)"으로 온다(확인함) — 백엔드는 이 두 이름을 "검사하지 못함"으로 가른다(받을 수는 있다)
AlertExceedsMax yes
AlertEncrypted yes
# ------------------------------------------
# 메모리 — 서명을 읽으면 1GB 안팎(2026-10 서명 363만 개). 서명을 다시 읽을 때(아래 SelfCheck) 두 벌을 들지 않는다 —
# 옛 서명을 버리고 새로 읽는 30초 안팎 동안 검사 · PING 이 기다린다(백엔드의 검사 차례는 그동안 쌓였다가 이어진다).
# 켜 두면(기본값) 새 서명을 다 읽을 때까지 두 벌을 든다(ClamAV 문서 — 2GB 남짓, 한도 2g 를 넘는다). 메모리 예산은 mail/README.md "메모리 예산"
# ------------------------------------------
ConcurrentDatabaseReload no
# 서명이 바뀌었는지 10분마다 본다 — 갱신(clamav-updater)이 알려 주지 않으므로(네트워크로 잇지 않는다) 이것이 새 서명을 읽는 길이다
SelfCheck 600
# libclamav 가 메모리를 얻지 못하면 끝낸다 — 재시작 정책으로 다시 뜬다(어정쩡한 상태로 남지 않게)
ExitOnOOM yes
+27
View File
@@ -0,0 +1,27 @@
# ==========================================
# freshclam — ClamAV 서명(바이러스 DB) 갱신 · docker-compose.yml 의 clamav-updater 가 읽는다 (mail/README.md "악성 검사 (ClamAV)")
#
# 서명을 받아 볼륨 <PROJECT_NAME>_clamav-db 에 쓴다. 검사기(clamd — compose 의 clamav)는 같은 볼륨을 읽기 전용으로 붙이고
# 바뀐 서명을 스스로 다시 읽는다(clamd.conf 의 SelfCheck) — 두 컨테이너는 네트워크로 잇지 않는다(NotifyClamd 를 쓰지 않는다).
# 바깥(database.clamav.net — HTTPS · DNS)에 나가는 것은 이 컨테이너뿐이다. 바깥에서 온 파일을 여는 검사기는 바깥 길이 없다.
#
# 이미지의 기본 설정을 이 파일로 통째로 바꾼다. compose 가 처음부터 비-root(이미지의 clamav 사용자 100:101)로 띄우므로
# 이 파일은 그 사용자가 읽을 수 있어야 한다(644 — 저장소를 umask 077 로 클론하지 않는다).
# 바꾼 뒤에는 docker compose … up -d --force-recreate clamav-updater (설정 파일만 바뀌면 compose 가 다시 만들지 않는다)
# ==========================================
DatabaseDirectory /var/lib/clamav
# 로그는 표준 출력으로만(docker logs — compose 가 --stdout 을 준다). 파일에 남기지 않는다
LogTime yes
Foreground yes
# 서명을 받는 곳 — ClamAV 공식 CDN. 새 판이 있는지는 DNS(TXT current.cvd.clamav.net)로 먼저 묻고, 있을 때만 받는다
DatabaseMirror database.clamav.net
# 하루 12번(2시간마다) 묻는다 — ClamAV 기본값. CDN 은 1시간에 한 번보다 자주 묻지 말라고 한다(넘으면 잠시 막는다 — 문제 해결 "서명 갱신 실패")
Checks 12
# 바뀐 만큼만 받는다(cdiff) — 서명 전체를 매번 받지 않게
ScriptedUpdates yes
# 받은 서명을 바꿔 넣기 전에 따로 읽어 본다 — 깨진 서명이 검사기를 멈추지 않게. 읽는 동안 메모리를 잠시 더 쓴다(compose 의 한도)
TestDatabases yes