generated from ttipo/project-base
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:
+122
-1
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user