Files
arasystem/frontend/nginx-env.envsh
ttipo 73babeaa2c feat(docker): 13단계 운영 기반 — 백업 · 감시 · 관리자 단단히 · 휴지통 30일 · 메일 프로그램 자동 설정
전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다.
- 관리자 단단히(setup): 관리자 역할에서 impersonate 를 빼고 관리자 허용 IP 를 앱 네트워크로 — 관리자 비밀번호로 바깥 993 · JMAP 에서 직원 메일함에 대리 로그인되던 길을 막았다.
  운영에서 처음 걸 때는 되돌릴 복구 관리자가 있을 때만 건다(관리자 잠김 방지).
- 휴지통 · 스팸 30일 정리를 KST 03:00 으로 명시. imapsync 로 옮긴 메일도 옮긴 때부터 센다(임시 엔진 실측).
- restic 백업(호스트 systemd 03:30 — 메일 볼륨 warm → 엔진 멈춤 9초 안팎 → final, pg_dump, 업로드, env) · 복원(새 서버 차례를 빈 클론에서 실측) · 15분 점검 · 부팅 되살리기, Healthchecks 하트비트.
- 메일 프로그램 자동 설정: frontend nginx 의 autoconfig · autodiscover 고정 응답(엔진 것은 POP3 를 알리고 입력을 되돌려 쓴다).
- 모든 서비스 docker 로그 보존, DB 이미지 판 · digest 고정.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:44:44 +09:00

195 lines
12 KiB
Bash

#!/bin/sh
# ==========================================
# nginx 템플릿(nginx.conf)에 넣을 값을 만든다 — nginx 이미지의 docker-entrypoint 가 envsubst 전에 source 한다
# (Dockerfile 이 /docker-entrypoint.d/16-nginx-env.envsh 로 넣는다. 실행 권한이 있어야 읽힌다)
#
# 본문 한도는 백엔드 한도와 같은 루트 env 에서 만든다 → 루트 env 한 곳만 고치면 백엔드와 nginx 가 함께 바뀐다.
# compose 는 정의되지 않은 변수를 빈 문자열로 넣으므로 빈 값은 백엔드 기본값으로 대신한다.
# 값이 잘못됐으면 기본값으로 조용히 넘어가지 않고 컨테이너 기동을 멈춘다 (fail-fast — .claude/rules/env-structure.md).
#
# ⚠️ source 되는 파일이라 entrypoint 셸과 이름·옵션을 함께 쓴다 — set -e/-u 를 걸지 않고, 이름은 nginx_env_ 로 시작한다.
# exit 하면 entrypoint 가 끝나 컨테이너가 뜨지 않는다 (그게 의도다).
# ==========================================
nginx_env_fail() {
echo "nginx-env.envsh: $1" >&2
exit 1
}
# $1(변수 이름)의 값이 양의 십진 정수면 그 값, 비어 있으면 $2(기본값). 그 밖은 기동 중단.
# 앞자리 0 은 받지 않는다 — 셸 산술이 8진수로 읽어(010 → 8) 백엔드와 한도가 어긋나거나 08 에서 오류가 난다.
# 6자리(999999MB)를 넘는 값도 받지 않는다 — 실수로 넣은 값이 산술에서 넘치지 않게.
nginx_env_positive_int() {
eval "nginx_env_value=\${$1:-}"
if [ -z "$nginx_env_value" ]; then
echo "$2"
return
fi
case "$nginx_env_value" in
0* | *[!0-9]*) nginx_env_fail "$1 는 0 으로 시작하지 않는 양의 정수(MB)여야 합니다 (현재: \"$nginx_env_value\")." ;;
esac
if [ "${#nginx_env_value}" -gt 6 ]; then
nginx_env_fail "$1 가 너무 큽니다 (현재: \"$nginx_env_value\", 최대 999999)."
fi
echo "$nginx_env_value"
}
# 멀티파트 경계·헤더 몫의 여유 (MB) — 백엔드 한도에 딱 맞는 파일도 nginx 에서 막히지 않게
nginx_body_margin_mb=1
# 백엔드 기본값과 같다: MAIL_ATTACHMENT_MAX_SIZE_MB=20 (modules/mail/mail.settings.ts) · UPLOAD_MAX_SIZE_MB=5
# (명령 치환 안에서 실패하면 그 서브셸만 끝나므로, 값을 먼저 받아 실패를 여기서 확인한다)
nginx_env_mail_mb="$(nginx_env_positive_int MAIL_ATTACHMENT_MAX_SIZE_MB 20)" || exit 1
nginx_env_upload_mb="$(nginx_env_positive_int UPLOAD_MAX_SIZE_MB 5)" || exit 1
NGINX_MAIL_ATTACHMENT_BODY_LIMIT="$((nginx_env_mail_mb + nginx_body_margin_mb))m"
NGINX_UPLOAD_BODY_LIMIT="$((nginx_env_upload_mb + nginx_body_margin_mb))m"
# 백엔드 주소 — compose 가 http://<PROJECT_NAME>-backend:<BACKEND_PORT> 를 준다.
# 서비스 이름(backend)은 쓰지 않는다: proxy 네트워크를 다른 프로젝트와 함께 쓰면 그쪽 backend 로 풀릴 수 있다.
if [ -z "${NGINX_BACKEND_UPSTREAM:-}" ]; then
[ -n "${PROJECT_NAME:-}" ] || nginx_env_fail "NGINX_BACKEND_UPSTREAM(예: http://<PROJECT_NAME>-backend:3000) 또는 PROJECT_NAME 이 필요합니다."
NGINX_BACKEND_UPSTREAM="http://${PROJECT_NAME}-backend:3000"
fi
# http://호스트[:포트] 만 받는다. 경로(끝의 / 포함)가 붙으면 proxy_pass 가 모든 /api 요청을 그 경로 하나로 보내고,
# 공백·; 등은 설정 문법을 깨거나 지시어를 끼워 넣을 수 있다.
nginx_env_upstream_host="${NGINX_BACKEND_UPSTREAM#http://}"
if [ "$nginx_env_upstream_host" = "$NGINX_BACKEND_UPSTREAM" ] || [ -z "$nginx_env_upstream_host" ]; then
nginx_env_fail "NGINX_BACKEND_UPSTREAM 은 http://호스트[:포트] 형식이어야 합니다 (현재: \"$NGINX_BACKEND_UPSTREAM\")."
fi
case "$nginx_env_upstream_host" in
*[!A-Za-z0-9.:_-]*) nginx_env_fail "NGINX_BACKEND_UPSTREAM 에는 경로·쿼리·공백을 넣지 않습니다 — http://호스트[:포트] 만 (현재: \"$NGINX_BACKEND_UPSTREAM\")." ;;
:* | *:*:* | *:*[!0-9]* | *:) nginx_env_fail "NGINX_BACKEND_UPSTREAM 의 호스트·포트가 올바르지 않습니다 (현재: \"$NGINX_BACKEND_UPSTREAM\")." ;;
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}"
# ------------------------------------------
# 대용량 첨부의 공개 내려받기 호스트 (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
# ------------------------------------------
# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정")
#
# server 이름 autoconfig.<MAIL_DOMAIN> · autodiscover.<MAIL_DOMAIN>, 알리는 호스트는 MAIL_HOSTNAME(엔진 인증서의 이름 — 전환 기간 mx1.). 둘 다 compose 가 루트 env 에서 준다.
# 둘 중 하나라도 비면 끈다 — server 이름이 어느 요청에도 맞지 않는 autoconfig.invalid 다. 모양이 틀리면 기동을 멈춘다(server_name · 응답 XML 에 그대로 들어간다 —
# 지시어 · XML 을 끼워 넣지 못하게 영문 소문자 · 숫자 · - · . 의 호스트 이름만). 포털 · 공개 내려받기 호스트와 겹치면(그쪽 요청을 가로챈다) 멈춘다
# ------------------------------------------
# 호스트 이름 — 소문자로 바꾼 값이 이름 꼴이면 그 값, 아니면 아무것도 내지 않고 1 (칸마다 63자 · 모두 253자 · 점 하나 이상)
nginx_env_hostname() {
nginx_env_name="$(printf '%s' "$1" | tr 'A-Z' 'a-z')"
# 빈 값 · 그 밖의 글자 · 점이나 하이픈으로 시작 · 끝 · 연이은 점 · 칸의 앞뒤 하이픈 · 점 없는 이름
case "$nginx_env_name" in
"" | *[!a-z0-9.-]* | .* | *. | *..* | -* | *- | *.-* | *-.*) return 1 ;;
*.*) ;;
*) return 1 ;;
esac
[ "${#nginx_env_name}" -le 253 ] || return 1
nginx_env_rest="$nginx_env_name"
while [ -n "$nginx_env_rest" ]; do
nginx_env_label="${nginx_env_rest%%.*}"
[ "${#nginx_env_label}" -le 63 ] || return 1
case "$nginx_env_rest" in *.*) nginx_env_rest="${nginx_env_rest#*.}" ;; *) nginx_env_rest="" ;; esac
done
printf '%s' "$nginx_env_name"
}
if [ -z "${MAIL_DOMAIN:-}" ] || [ -z "${MAIL_HOSTNAME:-}" ]; then
NGINX_AUTOCONFIG_SERVER_NAMES="autoconfig.invalid"
NGINX_MAIL_DOMAIN="invalid"
NGINX_MAIL_CLIENT_HOST="invalid"
echo "nginx-env.envsh: 메일 프로그램 자동 설정을 끈다 (MAIL_DOMAIN · MAIL_HOSTNAME 중 비어 있는 값이 있다)"
else
NGINX_MAIL_DOMAIN="$(nginx_env_hostname "$MAIL_DOMAIN")" \
|| nginx_env_fail "MAIL_DOMAIN 은 호스트 이름(영문 · 숫자 · - · . — 예: arasystem.it.kr)이어야 합니다 (현재: \"$MAIL_DOMAIN\")."
NGINX_MAIL_CLIENT_HOST="$(nginx_env_hostname "$MAIL_HOSTNAME")" \
|| nginx_env_fail "MAIL_HOSTNAME 은 호스트 이름(예: mx1.arasystem.it.kr)이어야 합니다 (현재: \"$MAIL_HOSTNAME\")."
# 견줄 호스트 — 공개 내려받기 호스트와 CORS_ORIGIN 의 호스트들(공유 호스트 판정과 같게 빈칸 · 끝의 / 를 뗀다 — 백엔드도 끝 / 를 받는다)
nginx_env_taken_hosts="$nginx_env_share_host"
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
nginx_env_taken_hosts="$nginx_env_taken_hosts $nginx_env_portal_host"
done <<NGINX_ENV_TAKEN_ORIGINS
$(printf '%s\n' "${CORS_ORIGIN:-}" | tr ',' '\n')
NGINX_ENV_TAKEN_ORIGINS
for nginx_env_taken in $nginx_env_taken_hosts; do
case "$nginx_env_taken" in
"autoconfig.$NGINX_MAIL_DOMAIN" | "autodiscover.$NGINX_MAIL_DOMAIN")
nginx_env_fail "자동 설정 호스트(autoconfig. · autodiscover.$NGINX_MAIL_DOMAIN)가 포털(CORS_ORIGIN) · 공개 내려받기(SHARE_PUBLIC_URL) 호스트와 겹칩니다 — 그 요청을 가로챈다." ;;
esac
done
NGINX_AUTOCONFIG_SERVER_NAMES="autoconfig.$NGINX_MAIL_DOMAIN autodiscover.$NGINX_MAIL_DOMAIN"
echo "nginx-env.envsh: 메일 프로그램 자동 설정 ${NGINX_AUTOCONFIG_SERVER_NAMES} — IMAP · SMTP 호스트 ${NGINX_MAIL_CLIENT_HOST}"
fi
export NGINX_MAIL_ATTACHMENT_BODY_LIMIT NGINX_UPLOAD_BODY_LIMIT NGINX_BACKEND_UPSTREAM NGINX_LOCAL_RESOLVERS \
NGINX_SHARE_SERVER_NAME NGINX_PORTAL_SHARE NGINX_AUTOCONFIG_SERVER_NAMES NGINX_MAIL_DOMAIN NGINX_MAIL_CLIENT_HOST