From 73babeaa2c7e5b82d70bbdec423b05e30c1d4e6d Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 2 Nov 2026 13:44:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(docker):=2013=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EA=B8=B0=EB=B0=98=20=E2=80=94=20=EB=B0=B1?= =?UTF-8?q?=EC=97=85=20=C2=B7=20=EA=B0=90=EC=8B=9C=20=C2=B7=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EB=8B=A8=EB=8B=A8=ED=9E=88=20=C2=B7=20?= =?UTF-8?q?=ED=9C=B4=EC=A7=80=ED=86=B5=2030=EC=9D=BC=20=C2=B7=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=ED=94=84=EB=A1=9C=EA=B7=B8=EB=9E=A8=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 전 직원 전환 전에 서버가 스스로 지키고 되살릴 수 있게 했다. - 관리자 단단히(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 --- .env.development.example | 6 +- .env.production.example | 6 +- .github/workflows/ci.yml | 104 +++++- .gitignore | 3 + docker-compose.dev.yml | 9 + docker-compose.remote-proxy.yml | 7 + docker-compose.yml | 45 ++- frontend/nginx-env.envsh | 59 ++- frontend/nginx.conf | 81 +++++ mail/README.md | 395 ++++++++++++++++++++- mail/scripts/setup.mjs | 324 ++++++++++++++++- ops/backup/arasystem-backup.sh | 376 ++++++++++++++++++++ ops/backup/arasystem-restore.sh | 321 +++++++++++++++++ ops/lib/common.sh | 141 ++++++++ ops/monitor/arasystem-check.sh | 246 +++++++++++++ ops/ops.env.example | 65 ++++ ops/systemd/arasystem-backup.service | 28 ++ ops/systemd/arasystem-backup.timer | 13 + ops/systemd/arasystem-check.service | 16 + ops/systemd/arasystem-check.timer | 10 + ops/systemd/arasystem-mail-recover.service | 28 ++ 21 files changed, 2249 insertions(+), 34 deletions(-) create mode 100644 ops/backup/arasystem-backup.sh create mode 100644 ops/backup/arasystem-restore.sh create mode 100644 ops/lib/common.sh create mode 100644 ops/monitor/arasystem-check.sh create mode 100644 ops/ops.env.example create mode 100644 ops/systemd/arasystem-backup.service create mode 100644 ops/systemd/arasystem-backup.timer create mode 100644 ops/systemd/arasystem-check.service create mode 100644 ops/systemd/arasystem-check.timer create mode 100644 ops/systemd/arasystem-mail-recover.service diff --git a/.env.development.example b/.env.development.example index 110a8af..48d0208 100644 --- a/.env.development.example +++ b/.env.development.example @@ -40,7 +40,11 @@ MAIL_RESOLVER_IP=172.16.73.254 # ========================================== # Database 설정 # ========================================== -DB_IMAGE=postgres:15-alpine +# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 — +# 바꾸면 다음 up 에서 db 가 다시 만들어진다(데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다) +# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 이미지로는 2주가 2026-11-04 에 찬다 +# (개발은 지금 써도 된다 — 운영에 올리는 것은 11-04 뒤, .env.production.example 의 같은 줄) +DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984 DB_USER=app_user DB_PASSWORD=change-me-in-local-env DB_NAME=project_db diff --git a/.env.production.example b/.env.production.example index be63457..6801f32 100644 --- a/.env.production.example +++ b/.env.production.example @@ -38,7 +38,11 @@ MAIL_RESOLVER_IP=172.16.73.254 # ========================================== # Database 설정 # ========================================== -DB_IMAGE=postgres:15-alpine +# 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정" — 떠 있는 태그 15-alpine 을 쓰지 않는다). 메이저(15)는 데이터 형식이라 그대로 — +# 바꾸면 다음 up 에서 db 가 다시 만들어진다(몇 초 끊김 · 데이터는 DB_DATA_DIR 그대로, 같은 메이저의 패치라 그대로 연다). +# 15.19 는 2026-08-13 배포(보안 릴리스). 이 digest 는 그 판의 2026-10-21 재빌드(alpine 패키지) — 2주 규칙으로 운영에 올리는 것은 2026-11-04 뒤. +# 올리기 전에 백업이 그날 돌았는지 본다 (mail/README.md "백업" · "13단계 전부터 운영 중인 서버") +DB_IMAGE=postgres:15.19-alpine3.24@sha256:54e0dd8e8cddcdbb9156c2486eed5a43eccb7214d7c6828097d41ce049fb3984 DB_USER=app_user DB_PASSWORD=CHANGE-ME-BEFORE-DEPLOY DB_NAME=project_db diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c957839..dc9455c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,7 +122,7 @@ jobs: run: npm run build-only infra: - name: Infra (compose · Dockerfile · nginx) + name: Infra (compose · Dockerfile · nginx · 운영 스크립트) runs-on: ubuntu-latest # 멈춘 잡이 러너를 무한 점유하지 않도록 상한을 둔다 timeout-minutes: 10 @@ -150,6 +150,22 @@ jobs: if docker compose -f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example config -q 2>/dev/null; then echo "::error::FRONTEND_PRIVATE_BIND 없이 docker-compose.remote-proxy.yml 을 받아들였습니다 (멈춰야 한다)"; exit 1 fi + # 로그 보존(13단계) — 모든 서비스에 logging 이 있어야 한다(없으면 도커 기본 — 회전 없이 디스크를 채운다). + # 서비스는 두 칸, 그 키는 네 칸 들여 쓴다(compose config 의 출력 꼴) + # 리버스 프록시가 다른 서버일 때의 조합(remote-proxy — frontend-bind-check 가 더해진다)도 센다. 게시 주소는 이 확인에서만 준다 + for args in "-f docker-compose.yml -f docker-compose.dev.yml --env-file .env.development.example --profile mail --profile redis --profile frontend --profile clamav" \ + "-f docker-compose.yml --env-file .env.production.example --profile mail --profile redis" \ + "-f docker-compose.yml -f docker-compose.remote-proxy.yml --env-file .env.production.example --profile mail --profile redis"; do + # shellcheck disable=SC2086 + services=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config --services | wc -l) + # shellcheck disable=SC2086 + logged=$(FRONTEND_PRIVATE_BIND=10.0.1.12:8080 docker compose $args config | awk '/^ logging:/ { c++ } END { print c + 0 }') + [ "$services" -eq "$logged" ] || { echo "::error::logging 이 없는 서비스가 있습니다 ($args — 서비스 $services · logging $logged)"; exit 1; } + done + # DB 이미지는 판 · digest 까지 고정한다 (docker 스킬 "이미지 버전 고정") + for envf in .env.development.example .env.production.example; do + grep -Eq '^DB_IMAGE=postgres:[0-9]+\.[0-9]+-alpine[0-9.]+@sha256:[0-9a-f]{64}$' "$envf" || { echo "::error::$envf 의 DB_IMAGE 가 판 · digest 까지 고정되지 않았습니다"; exit 1; } + done # docker-compose.remote-proxy.yml 의 frontend-bind-check — 사설 IPv4:포트만 통과해야 한다 # (포트뿐인 값 · 0.0.0.0 은 compose 가 그대로 받아 모든 주소에 연다). 프로젝트 이름을 따로 두어 다른 compose 프로젝트와 섞이지 않게 한다 @@ -182,13 +198,35 @@ jobs: 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 - # 따로 둘 때 — 공개 내려받기 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) + # 따로 둘 때 — 공개 내려받기 server(files.) · 포털은 닫는다(nginx-share-closed.conf · nginx-share-api-closed.conf). 두 꼴 모두 문법을 본다. + # 이 꼴은 메일 프로그램 자동 설정(13단계 — autoconfig. · autodiscover.)도 켠다 + docker run --rm -e PROJECT_NAME=ci -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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 -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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 + 'worker_connections 4096;' 'limit_conn download_public_total 400;' 'limit_conn download_app_total 200;' \ + 'server_name autoconfig.ci.test autodiscover.ci.test;' 'mx1.ci.test' 'mx1.ci.test993'; do printf '%s\n' "$rendered" | grep -F "$want" >/dev/null || { echo "::error::렌더링한 nginx 설정에 '$want' 가 없습니다"; exit 1; } done + # 메일 값이 없으면 자동 설정은 꺼진다 — 어느 요청에도 맞지 않는 이름 + docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null | grep -F 'server_name autoconfig.invalid;' >/dev/null \ + || { echo "::error::MAIL_DOMAIN 없이 자동 설정 server 가 꺼지지 않았습니다"; exit 1; } + # 자동 설정의 값은 server_name · 응답 XML 에 그대로 들어간다 — 호스트 이름 꼴이 아니면 · 포털 · 공개 호스트와 겹치면 기동을 멈춰야 한다 + for value in 'ci.test;return 200' 'ci..test' '-ci.test' 'ci.test.' 'citest' 'ci test' 'ci.test/x'; do + if docker run --rm -e PROJECT_NAME=ci -e "MAIL_DOMAIN=$value" -e MAIL_HOSTNAME=mx1.ci.test "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::MAIL_DOMAIN=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + for value in 'mx1.ci.test' 'mx1.ci.test:993' 'mx1'; do + if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e "MAIL_HOSTNAME=$value" "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::MAIL_HOSTNAME=$value 를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done + # 끝의 / · 빈칸 · 여러 출처도 같게 본다 (공개 호스트 판정과 같은 읽기) + for value in 'https://autoconfig.ci.test' 'https://autoconfig.ci.test/' 'https://portal.ci.test, https://AUTODISCOVER.ci.test/'; do + if docker run --rm -e PROJECT_NAME=ci -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.ci.test -e "CORS_ORIGIN=$value" "$tag" nginx -t >/dev/null 2>&1; then + echo "::error::포털(CORS_ORIGIN=$value)과 겹치는 자동 설정 호스트를 받아들였습니다 (거부해야 한다)"; exit 1 + fi + done # 12단계 — 두 server 가 모두 엔진만 부르는 /api/internal/ 을 닫고(nginx-internal-api-closed.conf) 원문 요청 대상(날 \ · # · 0x80 이상)을 400 으로 끝낸다 # (if $request_target_rejected). 두 꼴 모두 지시어가 둘씩이어야 한다 — 주석이 아니라 지시어만 센다(첫 칸이 include · if) plain=$(docker run --rm -e PROJECT_NAME=ci "$tag" nginx -T 2>/dev/null) @@ -285,7 +323,7 @@ jobs: 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" + -e SHARE_PUBLIC_URL=https://files.ci.test -e CORS_ORIGIN=https://portal.ci.test -e MAIL_DOMAIN=ci.test -e MAIL_HOSTNAME=mx1.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; } @@ -355,6 +393,59 @@ jobs: [ "$fails" -eq 0 ] EOF + # 메일 프로그램 자동 설정(13단계 — frontend/nginx.conf 의 셋째 server)을 실제 요청으로 본다 — 위 단계의 컨테이너(MAIL_DOMAIN=ci.test · MAIL_HOSTNAME=mx1.ci.test). + # 고정 응답이어야 한다 — 질의 · POST 본문에 꾸민 주소를 넣어도 응답에 되돌아오지 않고, 백엔드로 넘기지 않는다(/api 도 404) + - name: 메일 프로그램 자동 설정 시험 + run: | + name="ci-nginx-paths-${{ github.run_id }}" + docker exec -i "$name" sh -s <<'EOF' + fails=0 + # host method path [본문] → 응답 전체(머리글 + 본문, CR 없이). 본문이 있으면 XML 로 보낸다 + raw() { + { printf '%s %s HTTP/1.1\r\nHost: %s\r\n' "$2" "$3" "$1"; [ -n "$4" ] && printf 'Content-Type: text/xml\r\nContent-Length: %s\r\n' "${#4}"; printf 'Connection: close\r\n\r\n'; [ -n "$4" ] && printf '%s' "$4"; sleep 1; } \ + | timeout 10 nc 127.0.0.1 8080 | tr -d '\r' + } + ok() { echo "ok $1"; } + bad() { echo "FAIL $1"; fails=$((fails + 1)); } + has() { if printf '%s\n' "$2" | grep -F -- "$3" >/dev/null; then ok "$1 — '$3' 있음"; else bad "$1 — '$3' 없음"; fi; } + hasnt() { if printf '%s\n' "$2" | grep -F -i -- "$3" >/dev/null; then bad "$1 — '$3' 가 되돌아왔다"; else ok "$1 — '$3' 없음"; fi; } + INJ='%3Cx%3Ascript%20xmlns%3Ax%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxhtml%22%3Ealert(1)%3C%2Fx%3Ascript%3E%40ci.test' + r=$(raw autoconfig.ci.test GET "/mail/config-v1.1.xml?emailaddress=$INJ") + has "Thunderbird" "$r" "HTTP/1.1 200"; has "Thunderbird" "$r" "Content-Type: text/xml; charset=utf-8" + has "Thunderbird" "$r" "mx1.ci.test"; has "Thunderbird" "$r" "993"; has "Thunderbird" "$r" "465" + has "Thunderbird" "$r" "%EMAILADDRESS%"; hasnt "Thunderbird" "$r" "script"; hasnt "Thunderbird" "$r" "pop3" + r=$(raw autoconfig.ci.test GET /.well-known/autoconfig/mail/config-v1.1.xml) + has ".well-known" "$r" "mx1.ci.test" + BODY='<script>evil@ci.testhttp://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a' + for p in /autodiscover/autodiscover.xml /AutoDiscover/AutoDiscover.xml /Autodiscover/Autodiscover.xml; do + r=$(raw autodiscover.ci.test POST "$p" "$BODY") + has "Outlook $p" "$r" "HTTP/1.1 200"; has "Outlook $p" "$r" "mx1.ci.test993"; has "Outlook $p" "$r" "SMTP" + hasnt "Outlook $p" "$r" "evil"; hasnt "Outlook $p" "$r" "script" + done + for p in / /api/auth/me /index.html /s/x /autodiscover/autodiscover.json; do + r=$(raw autoconfig.ci.test GET "$p"); has "그 밖 $p" "$r" "HTTP/1.1 404" + done + # 포털 · 공개 호스트는 이 경로를 자동 설정으로 답하지 않는다 + r=$(raw portal.ci.test GET /mail/config-v1.1.xml); hasnt "포털" "$r" "/dev/null + docker run --rm "$tag" -x -P SCRIPTDIR -S style backup/arasystem-backup.sh backup/arasystem-restore.sh monitor/arasystem-check.sh lib/common.sh + for key in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do + grep -Eq "^${key}=change-me$" ops/ops.env.example || { echo "::error::ops/ops.env.example 의 $key 는 change-me 여야 합니다"; exit 1; } + done + if grep -Eq '^HC_(BACKUP|CHECK)_URL=.+' ops/ops.env.example; then + echo "::error::ops/ops.env.example 에 Healthchecks 주소가 들어 있습니다"; exit 1 + fi + # 대용량 첨부의 악성 검사기(clamav — 11단계) 설정 검사 — compose 와 같은 이미지 · 같은 사용자(100:101)로 clamconf. # 설정 파일은 바인드 마운트 대신 빌드 컨텍스트로 넣는다 (위 머리말). clamconf 는 모르는 · 틀린 항목을 "ERROR: Parse error" 로 알리지만 # 종료 코드는 0 이라(1.4.6 확인) 출력으로 본다. 판정에 꼭 필요한 값(한도 · 경보 · 서명 다시 읽기)이 그대로인지도 본다 — @@ -401,6 +492,7 @@ jobs: 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 + docker image rm -f "ci-shellcheck:${{ 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 diff --git a/.gitignore b/.gitignore index 602e317..0e9fd23 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,9 @@ /redis-data/ # CI 러너 등록 정보 ci/runner-data/ +# 운영 서버의 백업 · 감시 설정(13단계 — S3 키 · Healthchecks 주소 · restic 비밀번호). 서버의 /etc/arasystem/ 에 두지만 저장소 안에 잘못 만들어도 올라가지 않게 +/ops/*.env +/ops/restic-password # 업로드 파일 (로컬 저장) — 코드가 아니다 backend/uploads/ # 대용량 첨부 파일 (11단계 — FILE_STORAGE_DIR 의 기본값 ./files, 볼륨을 붙이지 않은 개발 · 호스트 실행) — 코드가 아니다 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ce04651..a8b49fb 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -1,3 +1,11 @@ +# 로그 보존 — docker-compose.yml 의 logging-default 와 같은 값 (앵커는 파일을 건너가지 않아 이 파일에만 있는 서비스 — mailpit — 에 다시 적는다) +x-logging: &logging-default + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" + services: # ---------------------------------------- # frontend — dev 에서는 기본 기동하지 않는다 (profile 로 숨김) @@ -155,6 +163,7 @@ services: # 메일은 /tmp 의 임시 DB 에 저장되므로 쓰기 권한 문제가 없다 (컨테이너를 재시작하면 비워진다) user: "65534:65534" restart: "no" + logging: *logging-default # SMTP 는 내부망(mail 컨테이너)에서만 받는다 expose: - "1025" diff --git a/docker-compose.remote-proxy.yml b/docker-compose.remote-proxy.yml index c11f66e..ba32be2 100644 --- a/docker-compose.remote-proxy.yml +++ b/docker-compose.remote-proxy.yml @@ -49,6 +49,13 @@ services: cap_drop: - ALL restart: "no" + # 로그 보존 — docker-compose.yml 의 logging-default 와 같은 값 (한 번 돌고 끝나는 컨테이너라 몇 줄뿐이다) + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" environment: - FRONTEND_PRIVATE_BIND=${FRONTEND_PRIVATE_BIND:-} entrypoint: diff --git a/docker-compose.yml b/docker-compose.yml index 9e7ba68..209a2ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,30 @@ +# ---------------------------------------- +# 컨테이너 로그 보존 (13단계 — mail/README.md "로그 보존") — 모든 서비스에 명시한다 +# +# 도커 기본(json-file · 회전 없음)이면 로그 파일이 디스크를 끝없이 채운다. 크기로 회전하고(max-size), 정한 개수(max-file)만 남기며, +# 회전한 파일은 gzip 으로 줄인다(compress — docker logs 는 그대로 읽는다). json-file 은 줄마다 JSON 껍데기(시각 · stream)가 붙어 글의 1.4배 안팎이다. +# 기본(logging-default) 10m × 5 (컨테이너마다 글 35MB 안팎) — db · redis · mail-resolver · clamav · clamav-updater · (개발) mailpit +# 길게(logging-long) 20m × 10 (글 140MB 안팎) — mail · frontend · backend +# 메일 엔진 인증 · 거절 · 배달 기록. 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통이면 하루 10MB 안팎 → 2주쯤 +# frontend nginx 접근 로그(쿼리 · 토큰 없음). 한 사람이 하루 1,500 요청이면 50명에 하루 20MB 안팎 → 1주쯤 +# backend 경고 · 오류 · 주요 일만(요청마다 남기지 않는다) — 몇 주 +# (줄 길이는 개발 엔진 151바이트 · backend 176바이트 — 2026-10-31. 직원 수 · 메일 양은 가정이다 — 운영에서 docker logs --since 24h <컨테이너> | wc -c 로 다시 잰다) +# 디스크는 모두 차도 0.75GB 안팎(회전한 파일은 압축돼 실제로는 그 몇 분의 1). 더 오래 둬야 하는 기록은 로그 수집기로 — 지금은 두지 않았다 +# 바꾼 값은 컨테이너를 다시 만들어야 걸린다(up -d 가 다시 만든다) +# ---------------------------------------- +x-logging: &logging-default + driver: json-file + options: + max-size: "10m" + max-file: "5" + compress: "true" +x-logging-long: &logging-long + driver: json-file + options: + max-size: "20m" + max-file: "10" + compress: "true" + services: # ---------------------------------------- # 1. Frontend (Nginx — 화면 정적 호스팅 + /api 프록시) — 운영의 유일한 입구 @@ -40,7 +67,12 @@ services: # 그 이름의 server 가 공개 화면(/s/)· 공개 API 만 열고 포털은 그 둘을 404 로 닫는다. 비었거나 같으면 포털이 함께 연다 (nginx-env.envsh) - SHARE_PUBLIC_URL=${SHARE_PUBLIC_URL:-} - CORS_ORIGIN=${CORS_ORIGIN:-} + # 메일 프로그램 자동 설정(13단계) — autoconfig. · autodiscover. 에 IMAP · SMTP 호스트 MAIL_HOSTNAME 을 고정 XML 로 낸다. + # 둘 중 하나라도 비면 끈다 (nginx-env.envsh · mail/README.md "메일 프로그램 자동 설정") + - MAIL_DOMAIN=${MAIL_DOMAIN:-} + - MAIL_HOSTNAME=${MAIL_HOSTNAME:-} restart: unless-stopped + logging: *logging-long # 열린 파일 수 — nginx 의 연결마다 하나(워커마다 worker_connections 4096 — frontend/Dockerfile). 데몬 기본값(배포판 · 판마다 1024 일 수 있다)에 기대지 않는다 ulimits: nofile: @@ -163,6 +195,7 @@ services: - uploads:/app/uploads - files:/app/files restart: unless-stopped + logging: *logging-long # 바깥에서는 프론트 nginx 의 /api 로만 들어온다 — edge(frontend 전용)와 app-network(db·mail)에만 붙고 proxy 에는 붙지 않는다. # clamav 는 악성 검사기와만 잇는 internal 네트워크다 (7번) networks: @@ -198,6 +231,8 @@ services: # ---------------------------------------- db: container_name: ${PROJECT_NAME}-db + # 판 · digest 까지 고정한다(루트 env 의 DB_IMAGE — docker 스킬 "이미지 버전 고정"). 메이저(15)는 데이터 형식이라 그대로 둔다 — + # 올리려면 pg_dump → 새 메이저에 복원(mail/README.md "백업"의 복원 차례). 아래 watchtower 표시는 13단계에 뺐다(판을 사람이 올린다) image: ${DB_IMAGE} expose: - "5432" @@ -208,6 +243,7 @@ services: volumes: - ${DB_DATA_DIR}:/var/lib/postgresql/data restart: always + logging: *logging-default networks: - app-network healthcheck: @@ -221,8 +257,6 @@ services: limits: cpus: '0.5' memory: 512m - labels: - - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 4. Redis — 내부 네트워크 전용 · 기본 비활성 @@ -241,6 +275,7 @@ services: volumes: - ${REDIS_DATA_DIR}:/data restart: always + logging: *logging-default networks: - app-network healthcheck: @@ -254,8 +289,6 @@ services: limits: cpus: '0.25' memory: 256m - labels: - - "com.centurylinklabs.watchtower.enable=true" # ---------------------------------------- # 5. Mail (Stalwart) — 사내 메일 엔진 · profile `mail` @@ -297,6 +330,7 @@ services: - mail-config:/etc/stalwart - mail-data:/var/lib/stalwart restart: unless-stopped + logging: *logging-long # 컨테이너의 DNS — 도커 DNS(127.0.0.11)가 도커 내부 이름(mailpit 등)은 직접 풀고 바깥 이름은 메일 전용 리졸버에 묻는다. # 엔진 자신의 DNS(x:DnsResolver)는 mail setup 이 맞춘다 — 운영은 이 리졸버에 바로(도커 DNS 를 거치지 않는다), 개발은 이 길(System) dns: @@ -348,6 +382,7 @@ services: # 이미지의 기본 설정을 통째로 바꾼다. DNSSEC 신뢰 앵커(/var/unbound/root.key)는 이미지의 것을 쓰고 컨테이너 안에서 고쳐 쓴다 - ./mail/resolver/unbound.conf:/etc/unbound/unbound.conf:ro restart: unless-stopped + logging: *logging-default networks: mail-dns: ipv4_address: ${MAIL_RESOLVER_IP:-172.16.73.254} @@ -400,6 +435,7 @@ services: - clamav-db:/var/lib/clamav:ro - ./mail/clamav/clamd.conf:/etc/clamav/clamd.conf:ro restart: unless-stopped + logging: *logging-default networks: - clamav # 서명이 볼륨에 생긴 뒤에 뜬다 — 처음 기동은 clamav-updater 가 서명을 받는 몇 분을 기다린다(없으면 clamd 가 바로 끝난다). @@ -441,6 +477,7 @@ services: - clamav-db:/var/lib/clamav - ./mail/clamav/freshclam.conf:/etc/clamav/freshclam.conf:ro restart: unless-stopped + logging: *logging-default networks: - clamav-egress healthcheck: diff --git a/frontend/nginx-env.envsh b/frontend/nginx-env.envsh index 3154c7b..e180442 100644 --- a/frontend/nginx-env.envsh +++ b/frontend/nginx-env.envsh @@ -133,5 +133,62 @@ else echo "nginx-env.envsh: 공개 내려받기 호스트 ${nginx_env_share_host} — 포털에서는 /s/ · /api/public-links/ 를 닫는다" fi +# ------------------------------------------ +# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정") +# +# server 이름 autoconfig. · autodiscover., 알리는 호스트는 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 <(화면) · 화면 자산 · /api/public-links/ 만 연다. 그 밖은 404 # 공개 호스트를 따로 두지 않으면(SHARE_PUBLIC_URL 이 비었거나 그 호스트 이름이 CORS_ORIGIN 에 있다) 포털이 둘 다 연다 — nginx-env.envsh +# 메일 프로그램 자동 설정(13단계) autoconfig. · autodiscover. — 고정 XML 만 낸다(백엔드로 넘기지 않는다). 파일 끝의 셋째 server # # ⚠️ 백엔드(Express)는 경로의 대소문자를 가리지 않는다 — /api/Public-Links/… · /api/messages/x/LARGE-FILES/… 도 처리기에 닿는다. # 그래서 /api 아래에서 경로로 무엇을 정하는 location(받기의 동시 연결 수 · 버퍼링 끔, 포털의 공개 API 닫기, 조각 본문 한도)은 @@ -525,3 +526,83 @@ server { # API 의 nginx 오류 응답 (포털과 같은 파일) include /etc/nginx/snippets/api-errors.conf; } + +# ========================================== +# 메일 프로그램 자동 설정 (13단계 — mail/README.md "메일 프로그램 자동 설정") — autoconfig. · autodiscover. +# +# 주소만 치면 메일 프로그램이 서버 · 포트를 찾아 채운다. 고정 응답이다 — 사용자가 보낸 값(질의의 emailaddress · Outlook 이 POST 한 주소)을 되돌려 쓰지 않는다 +# (엔진의 같은 경로는 그 값을 이스케이프 없이 XML 에 넣고, 열지 않은 POP3 · DAV 까지 알려서 쓰지 않는다 — 엔진 443 은 그대로 닫아 둔다). +# 알리는 것은 IMAP 993(SSL) · SMTP 465(SSL) 둘뿐 — 호스트는 MAIL_HOSTNAME(엔진 인증서의 이름), 사용자 이름은 메일 주소(%EMAILADDRESS% 는 프로그램이 채운다). +# GET /mail/config-v1.1.xml Thunderbird · Thunderbird for Android(K-9) · Evolution 등 (IETF draft-ietf-mailmaint-autoconfig 의 첫 자리) +# GET /.well-known/autoconfig/mail/config-v1.1.xml 같은 내용 (그 초안의 둘째 자리 — 원래는 도메인 자체의 주소지만 이 호스트에서도 같게) +# POST /autodiscover/autodiscover.xml 클래식 Outlook(POX) — 대소문자를 가리지 않는다. LoginName 을 주지 않는다(되돌려 쓰지 않으려고) — +# 그러면 Outlook 은 @ 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진) +# 값은 nginx-env.envsh 가 루트 env 의 MAIL_DOMAIN · MAIL_HOSTNAME 에서 만든다 — 비었으면 이 server 의 이름은 어느 요청에도 맞지 않는 autoconfig.invalid 다. +# 백엔드로 넘기는 것이 없다(/api 도 404). 외부 리버스 프록시는 이 두 호스트를 포털과 같은 8080 으로 넘기고 Host 를 그대로 둔다 — DNS · Proxy Host 는 전환 때 +# (전환 전 DNS 는 옛 서버를 가리킨다 — mail/README.md "메일 프로그램 자동 설정") +# ========================================== +server { + listen 8080; + server_name ${NGINX_AUTOCONFIG_SERVER_NAMES}; + + # 접근 로그 — 포털과 같은 형식(쿼리 없음 — 질의에 메일 주소가 실린다) + access_log /var/log/nginx/access.log main_rid; + # Outlook 이 POST 하는 요청은 1KB 안팎의 XML 이다 + client_max_body_size 64k; + + default_type text/xml; + charset utf-8; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Robots-Tag "noindex, nofollow" always; + add_header Cache-Control "no-cache" always; + + location = /mail/config-v1.1.xml { + return 200 ' + + + ${NGINX_MAIL_DOMAIN} + ${NGINX_MAIL_DOMAIN} + ${NGINX_MAIL_DOMAIN} + + ${NGINX_MAIL_CLIENT_HOST} + 993 + SSL + password-cleartext + %EMAILADDRESS% + + + ${NGINX_MAIL_CLIENT_HOST} + 465 + SSL + password-cleartext + %EMAILADDRESS% + + + +'; + } + + location = /.well-known/autoconfig/mail/config-v1.1.xml { + rewrite ^ /mail/config-v1.1.xml last; + } + + location ~* ^/autodiscover/autodiscover\.xml$ { + return 200 ' + + + + email + settings + IMAP${NGINX_MAIL_CLIENT_HOST}993offoffonon + SMTP${NGINX_MAIL_CLIENT_HOST}465offoffonon + + + +'; + } + + location / { + return 404; + } +} diff --git a/mail/README.md b/mail/README.md index dd77532..2ccceda 100644 --- a/mail/README.md +++ b/mail/README.md @@ -49,6 +49,7 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악 | 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 스크립트가 하는 일" | +| 백업 · 15분 점검 (13단계) | `ops/` — 스크립트 `backup/` · `monitor/` · `lib/`, 호스트의 systemd 유닛 `systemd/`, 설정 예시 `ops.env.example` | 운영 서버의 호스트에서 돈다(컨테이너가 아니다) — [백업](#백업) · [감시](#감시-13단계) | | 포트 | 운영 | 개발 | | --- | --- | --- | @@ -58,8 +59,8 @@ app-network(신뢰 대역)에 붙이지 않는다 ([악성 검사 (ClamAV)](#악 | 계정 | 용도 | 비밀번호 | | --- | --- | --- | -| `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다 | `MAIL_ADMIN_PASSWORD` | -| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다 | `MAIL_RECOVERY_ADMIN` | +| `@` (기본 `admin@`) | 영구 관리자 — bootstrap 이 `admin@` 으로 만들고, `MAIL_ADMIN_LOCAL_PART` 가 다르면 setup 이 곧바로 그 아이디로 바꾼다. **bootstrap 전에만 정할 수 있다** (아래 "문제 해결"). 백엔드는 이 계정의 웹메일 로그인·대리 접근을 막는다. 13단계부터 **대리 접근 권한이 없고 앱 네트워크 안에서만 로그인된다** ([관리자 계정 단단히](#관리자-계정-단단히)) | `MAIL_ADMIN_PASSWORD` | +| 복구 관리자 (`MAIL_RECOVERY_ADMIN` 의 아이디) | 최초 bootstrap·관리자 복구 전용 슈퍼유저. **값이 있는 동안은 정상 모드에서도 관리자로 로그인된다** — 운영은 env 파일에 두지 않고 일회성 override + `--recovery` 로만 쓴다. 역할 · 허용 IP 를 보지 않아 관리자가 막혔을 때 되살리는 길이다 | `MAIL_RECOVERY_ADMIN` | | `MAIL_GATEWAY_USER` | 백엔드의 대리 접근 전용 서비스 계정 — 역할 권한은 `authenticate`·`impersonate` 뿐, 신뢰 대역(`APP_NETWORK_SUBNET`)에서만 로그인 | `MAIL_GATEWAY_PASSWORD` | | `@` (기본 `mail-directory@`) | 사내 목록 계정 — 백엔드가 엔진의 계정 목록(Principal)에서 직원 이름·주소만 읽는다. 역할 권한은 `authenticate`·`jmapPrincipalGet`·`jmapPrincipalQuery` 뿐, 게이트웨이 대리 접근으로만 쓰고 메일을 받지 않는다. **복구 관리자로만 만들어진다** ([사내 목록 계정](#사내-목록-계정)) | 없음 | | `ara.kim@` 김아라 · `doyun.lee@` 이도윤 · `seojun.park@` 박서준 | **개발 전용** 테스트 직원 | `MAIL_DEV_USER_PASSWORD` | @@ -124,6 +125,9 @@ node --env-file=.env.development mail/scripts/setup.mjs **서브넷보다 넓거나 밖에 있는 대역이 들어오면 실패한다.** 이 스크립트가 만든 예전 항목은 지운다. 5. **게이트웨이** — 대리 접근 전용 역할 + 서비스 계정. 비밀번호는 env 값, 자격 증명의 허용 IP 는 신뢰 대역만 6. **잠금** — 직원(`User`) 역할에서 Principal 조회 권한 13개(`jmapPrincipal*`·`davPrincipal*`)를 뺀다(→ `InvalidateCaches`). + **관리자 단단히**(13단계) — 관리자 역할(System Administrator)의 빼는 권한에 `impersonate` 를 두고(→ `InvalidateCaches`), 영구 관리자 비밀번호의 허용 IP 를 `APP_NETWORK_SUBNET` 으로 묶는다 + (이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 — 건 뒤 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다). 리로드 뒤 관리자의 실제 권한(`/api/account`)에 `impersonate` 가 없는지 본다 + ([관리자 계정 단단히](#관리자-계정-단단히)) 공개 443(https 리스너)의 HTTP 는 `/.well-known/mta-sts.txt` 만 열고 나머지는 403 (`x:Http` 의 `allowedEndpoints`) — [보안](#보안) 메일을 받지 않는 시스템 계정(관리자 · 게이트웨이 · 사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — 받아 놓고 반송하지 않게 (`x:MtaStageRcpt` 의 `script` + 시스템 Sieve 스크립트 — [보안](#보안) "메일을 받지 않는 시스템 계정") @@ -138,6 +142,8 @@ node --env-file=.env.development mail/scripts/setup.mjs 스팸 필터의 Spamhaus 두 항목은 키(`MAIL_SPAMHAUS_DQS_KEY`)가 있고 시험을 통과하면 DQS 로 켜고 아니면 끈다(공개 미러는 쓰지 않는다 — [DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)). **엔진 웹훅**(12단계) — 인증 이벤트 셋을 백엔드의 받는 곳(`http://backend:/api/internal/mail-engine-events`)으로 `MAIL_WEBHOOK_SECRET` 로 서명해 보내는 `x:WebHook` 하나를 맞춘다(setup 표시 머리글로 찾는다 — 키가 비면 운영은 중단, 개발은 `[주의]` 뒤 있던 것을 끈다 — [엔진 웹훅](#엔진-웹훅--메일-프로그램-로그인-기록)). + **휴지통 · 스팸메일함 30일**(13단계) — `x:DataRetention` 의 `expungeTrashAfter` 30일과 정리 시각(계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 — 컨테이너 시각으로 바꿔 넣는다) + ([휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기)). 모두 9 의 리로드로 반영된다 9. **설정 리로드 → 반영 확인** — 메일 컨테이너의 네트워크 주소로 `https://…:443/jmap/session` 이 403, 내부 8080 이 200 인지 본다. 운영은 바깥에서 쓰는 주소 그대로(`MAIL_PUBLIC_URL/jmap/session`, 이 스크립트를 돌리는 호스트에서)도 403 인지 본다 — @@ -282,6 +288,10 @@ node --env-file=.env.development mail/scripts/setup.mjs - **계정 보안(12단계)** — `.env.production` 의 `MAIL_WEBHOOK_SECRET`(엔진 웹훅의 서명 키 — `openssl rand -hex 24` 로 만든 값, 사람이 지은 값은 쓰지 않는다). 운영 필수다 — 아래 2)의 `up` **전에** 넣는다(없으면 백엔드가 env 검사에서 멈춰 웹메일 전체가 서고, setup 도 멈춘다). 직원에게 미리 알린다 — 2단계 인증을 켜면 아웃룩 · 휴대폰 메일 앱은 앱 비밀번호로 다시 연결해야 한다 ([계정 보안](#계정-보안-12단계--인프라)) +- **전환 준비(13단계)** — 이미 떠 있는 서버는 [13단계 전부터 운영 중인 서버](#13단계-전부터-운영-중인-서버)의 차례대로(첫 setup 은 `--recovery`). 첫 직원을 옮기기 **전에** 백업과 15분 점검을 켠다: NCP Object Storage 버킷 · 그 버킷만 쓰는 하위 계정 키 · restic 비밀번호(비밀번호 관리자에 따로 보관), + Healthchecks.io 점검 둘 · UptimeRobot 감시 셋 ([백업](#백업) · [감시](#감시-13단계)). + `.env.production` 의 `DB_IMAGE` 를 예시와 같은 값(판 · digest 고정 — 옛 파일은 `postgres:15-alpine`)으로 바꾼다 — 다음 `up -d` 에서 db 가 다시 만들어진다(몇 초 · 데이터는 볼륨 그대로, 같은 메이저 15 라 덤프 · 복원은 필요 없다). + frontend 는 `MAIL_DOMAIN` · `MAIL_HOSTNAME` 으로 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정) 응답을 만든다 — 이미 있는 값이라 새로 넣을 것은 없다 복구 관리자(`MAIL_RECOVERY_ADMIN`)는 `.env.production` 에 적지 않는다. 최초 bootstrap(또는 관리자 비밀번호 복구) 때 셸에서만 잠깐 준다. @@ -295,6 +305,7 @@ sudo -E docker compose --env-file .env.production up -d --build # 3) setup --recovery — 호스트에서, docker 를 쓸 수 있는 사용자로 실행한다 (운영은 docker 가 없으면 아무것도 바꾸지 않고 실패한다). # 8080 을 공개하지 않으므로 mail 컨테이너의 내부 주소로 붙는다 (리눅스 호스트는 컨테이너 IP 로 바로 닿는다). +# 앱 네트워크의 주소여야 한다 — 13단계부터 관리자는 그 대역에서만 로그인된다(다른 네트워크 주소면 403 — "관리자 계정 단단히") export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" # (A) 호스트 Node (24 LTS 권장, 최소 20.6 — --env-file) — docker 그룹이면 그대로: node --env-file=.env.production mail/scripts/setup.mjs --recovery @@ -335,13 +346,16 @@ node --env-file=.env.production mail/scripts/setup.mjs 3. backend 가 healthy 가 된 뒤(`sudo docker compose --env-file .env.production ps backend`) 3)처럼 `MAIL_ADMIN_URL` 을 구해 setup(복구 관리자 없이 — 5 와 같은 형태) → `[생성] 엔진 웹훅` · `[확인] 엔진 웹훅 — … 받았다` 4. [운영 확인 (12단계)](#운영-확인-12단계) — 메일 프로그램으로 한 번 로그인해 로그인 기록에 IMAP 줄이 생기는지 본다 - 운영 setup 은 끝에 **권장 DNS 레코드**(DKIM·SPF·MX·DMARC·SRV·MTA-STS·autoconfig)를 출력한다. 그대로 붙이지 말고 아래 전환 절차 순서를 따른다. + `autoconfig` · `autodiscover` 의 권장값(엔진 — mx1)은 쓰지 않는다 — 13단계부터 메일 프로그램 자동 설정은 엔진이 아니라 포털 쪽 nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)). - **TLS 인증서 이름을 `MAIL_HOSTNAME` 하나로 정한다** (2026-10-31 운영에서 겪음). bootstrap 이 켠 도메인의 자동 인증서(ACME)는 이름을 정하지 않으면 기본 묶음 — `mta-sts` · `autoconfig` · `autodiscover` · `ua-auto-config` · MX 이름 · 호스트 이름 — 을 **한 주문**으로 요청하고, ACME 는 하나라도 실패하면 전체가 실패한다. 전환 전에는 그 이름들이 옛 서버(mailcow)를 가리키거나 DNS 에 없으므로 mx1 까지 인증서를 받지 못하고, 엔진은 993 · 465 · 25(STARTTLS)에 자체 서명 인증서(`CN=rcgen self signed cert`)를 낸다. 관리 화면 Domains › `arasystem.it.kr` › Certificate Management 의 **Additional Hostnames** 에 `mx1`(도메인은 엔진이 붙인다 — Enter 로 항목을 만든 뒤 저장)만 넣고, Management › Tasks › Scheduled 에서 **AcmeRenewal** 을 **한 번** 건다(재시작만으로는 바로 요청하지 않는다. 걸 때마다 새로 발급하니 여러 번 걸지 않는다 — Let's Encrypt 같은 인증서 주에 5번). 확인: `docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep -i acme` 에 `hostname = ["mx1.arasystem.it.kr"]` · `acme.order-completed`, 바깥에서 `openssl s_client -connect mx1.arasystem.it.kr:993` 의 발급자가 Let's Encrypt. - **전면 전환** 때 `autoconfig` · `autodiscover` · `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤 그 이름들을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다. + **전면 전환** 때 `mta-sts` 의 DNS 를 새 서버로 옮긴 뒤(MTA-STS 를 게시할 때 — 아래 전환 절차 "주의할 레코드") 그 이름을 이 목록에 더하고 AcmeRenewal 을 한 번 더 건다. + `autoconfig` · `autodiscover` 는 **넣지 않는다** — 그 이름은 서버 A 의 외부 프록시(NPM)가 인증서를 받고 이 서버의 frontend nginx 가 답한다 ([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)). + 엔진 쪽에 넣으면 그 이름이 엔진을 가리키지 않아 ACME 주문 전체가 실패하고 mx1 인증서까지 갱신되지 않는다. - 공개 443 차단은 setup 이 컨테이너 안과 `MAIL_PUBLIC_URL`(호스트에서) 두 곳에서 확인한다. 호스트에서 자기 공인 주소로 되돌아오는 경로가 막힌 환경이면 경고만 남기므로, 그때는 바깥에서 확인한다: `curl -s -o /dev/null -w '%{http_code}\n' https:///jmap/session` → `403`. @@ -373,8 +387,8 @@ openssl s_client -quiet -connect mx1.arasystem.it.kr:993 # 메일 호스트에서 sudo docker logs --since 5m "${PROJECT_NAME}-mail" 2>&1 | grep 'auth.success' | grep 'imaps' | tail -3 ``` -- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 내부 8080 에 붙는다: - `ssh -L 8180::8080 <메일 호스트>` → . setup 은 운영에서 직원 계정을 만들지 않는다. +- 실제 직원 계정은 관리 화면이나 관리 API(`x:Account/set`)로 만든다. 관리 화면은 공개 443 에서 막혀 있으므로 SSH 터널로 mail 컨테이너의 **앱 네트워크 주소**:8080 에 붙는다 + (13단계부터 관리자는 앱 네트워크 안에서만 로그인된다 — 명령은 [관리 화면 — SSH 터널](#관리-화면--ssh-터널)). setup 은 운영에서 직원 계정을 만들지 않는다. ⚠️ **공용 주소(Group 계정)는 일반 관리자로 만들 수 없다** — Group 역할에 관리자 역할에는 없는 Principal 권한 13개가 있어 `You are not authorized to grant permissions: jmapPrincipalCreate, …` 로 거절된다(2026-10-29 확인, 직원 계정 · 그룹에 구성원 넣기는 관리자로 된다). 공용 주소를 만들 때는 복구 관리자를 쓴다 ("운영 배포" 1)·`up -d mail` 뒤 그 아이디로 관리 화면 · 관리 API → 4)·5) 로 끈다). @@ -776,6 +790,7 @@ sudo docker exec "${PROJECT_NAME}-backend" node -e "const s=require('net').conne | backend | 1g | 0.3~0.5GB | 조각 · 받기는 스트림이라 파일 크기와 상관없다 | | db · frontend · mail-resolver | 512m · 512m · 128m | 작다 | | | **합** | **6.15GB** | 2.5~3GB | 나머지 1.8GB 남짓을 커널 · dockerd · 페이지 캐시가 쓴다 | +| restic (백업 — 13단계, compose 밖) | 1g | 돌 때만 — 매일 03:30 몇 분 | 0.63GB (데이터 1.2GB — 2026-10-31 MinIO). 그때는 합이 7.15GB — 사용이 적은 새벽이라 둔다. 복원 연습은 엔진 1g · DB 512m 를 더 띄운다(한 달에 한 번 — 낮에 하지 않는다) | - **ClamAV 의 `ConcurrentDatabaseReload` 를 켜지 않는다**(기본은 켬) — 켜면 서명을 다시 읽는 동안(하루 몇 번) 두 벌(2GB 남짓)을 들어 2g 한도에서 죽는다. 한도를 올려 켜려면 합이 8GB 를 넘지 않는지 다시 본다. @@ -914,7 +929,7 @@ unset OTP_ADMIN_PW OTP_TARGET 켤 때의 주소는 계약대로 이름 `ARA SYSTEM`(띄어쓰기)이었고 엔진이 그 주소로 코드를 확인했다(직원 경로로 끄기도 됨). 끝나고 처음 상태(OTP 꺼짐 · 앱 비밀번호 0개 · 비밀번호 그대로)로 되돌렸다. - 웹메일의 "켠 때"(`enabledAt`)는 서버가 엔진에서 꺼진 것을 보면 지운다 (계약 5절 "2단계 인증"). -**2) 관리 화면** (운영은 SSH 터널 — `ssh -L 8180::8080 <메일 호스트>` → , [운영 배포](#운영-배포) 아래 "실제 직원 계정" 줄) — **직접 눌러 보지 못했다**: +**2) 관리 화면** (운영은 SSH 터널 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — **직접 눌러 보지 못했다**: 1. Management › Directory › **Accounts** 에서 그 직원을 연다. 2. Authentication 의 **Credentials** 에서 비밀번호 자격 증명(Password)을 연다. @@ -936,6 +951,161 @@ unset OTP_ADMIN_PW OTP_TARGET --- +## 메일 운영 설정 (13단계 — 인프라) + +전환 준비(13단계)에서 setup · compose · nginx 가 더 맡은 것이다. 백업 · 감시 · 로그는 따로 — [백업](#백업) · [감시](#감시-13단계) · [로그 보존](#로그-보존-13단계). + +### 13단계 전부터 운영 중인 서버 + +이미 떠 있는 운영 서버(서버 B)에 13단계를 거는 차례다. **2026-11-04 뒤에** 한다 — 고정한 DB 이미지(digest)가 그날 2주가 찬다(`.env.production.example` 의 `DB_IMAGE` 주석). +모든 컨테이너가 다시 만들어진다(로그 한도 · DB 이미지) — 메일 · 웹메일이 1~2분 끊기므로 사용이 적은 시간에 한다. 그 전에 손으로 백업을 받아 두기를 권한다([손으로 받을 때](#손으로-받을-때-타이머-없이--예전-방법)). + +```bash +cd ~/arasystem && git pull # 13단계 코드 (저장소 사용자로) +export PROJECT_NAME="$(sed -n 's/^PROJECT_NAME=//p' .env.production)" +# 1) 로그 남기기 — 컨테이너를 다시 만들면 그때까지의 로그가 지워진다 +mkdir -p ~/logs-before-13 && for c in mail backend frontend db; do sudo docker logs "${PROJECT_NAME}-$c" > ~/logs-before-13/$c.log 2>&1; done +# 2) .env.production 의 DB_IMAGE 를 예시와 같은 고정값으로 (옛 값 postgres:15-alpine) — 편집기로 그 한 줄만 +grep -n '^DB_IMAGE=' .env.production .env.production.example +# 3) 복구 관리자를 일회성으로 준 채 전부 다시 만든다 — 관리자 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 setup 이 건다(막히면 되돌릴 길) +read -rsp '임시 복구 비밀번호: ' P; echo; export MAIL_RECOVERY_ADMIN="admin:$P" +sudo -E docker compose --env-file .env.production up -d --build +# 4) mail 의 앱 네트워크 주소가 바뀌었다 — 다시 구한다 ("운영 배포" 3 과 같다) +export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" +# 5) 첫 setup 은 --recovery — 끝에 "복구 관리자 끄기"를 안내하고 종료 3 +node --env-file=.env.production mail/scripts/setup.mjs --recovery +# 6) 복구 관리자 끄기 → mail 다시 만들기 → 주소 다시 → 보통 setup (모두 [유지] 와 [확인] 관리자 줄) +unset MAIL_RECOVERY_ADMIN P; sudo docker compose --env-file .env.production up -d mail +export MAIL_ADMIN_URL="http://$(sudo docker inspect -f '{{index .NetworkSettings.Networks "'"${PROJECT_NAME}_app-network"'" "IPAddress"}}' "${PROJECT_NAME}-mail"):8080" +node --env-file=.env.production mail/scripts/setup.mjs +``` + +- 5)의 출력에 `[변경] 관리자 역할("System Administrator") — 대리 접근(impersonate) 뺌`, `[변경] 관리자 … — 허용 IP `, 휴지통 · 정리 시각 줄, + 그리고 `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP … 안에서 로그인됨` 이 있어야 한다. 6)은 모두 `[유지]` 에 같은 `[확인]` 줄. +- 복구 관리자 없이 처음 돌리면 setup 이 허용 IP 를 걸지 않고 종료 1 로 이 절을 가리킨다(아무것도 막히지 않는다 — 다시 3)부터). +- 7) **백업 · 감시 설치** — [백업](#백업) › 설치 1~4(부팅 때 엔진 되살리기 유닛 포함), 첫 백업 뒤 `restore.sh drill` 한 번, [감시](#감시-13단계)의 Healthchecks · UptimeRobot. +- 8) 바깥에서 관리자 비밀번호가 막혔는지 — 다른 네트워크의 PC 에서 `openssl s_client -quiet -connect mx1.arasystem.it.kr:993` 뒤 `a LOGIN <관리자 주소> <비밀번호>` → `NO`(또는 끊김). + 관리 화면은 이제 [SSH 터널](#관리-화면--ssh-터널)로만 들어간다. + +### 관리자 계정 단단히 + +엔진의 기본 관리자 역할(System Administrator)에는 대리 접근(`impersonate`)이 있어, 관리자 비밀번호 하나로 `<직원 주소>%<관리자 주소>` 로그인 — **모든 직원의 메일함** — 이 됐다. +setup 이 두 가지를 걸고 매번 확인한다(되돌려 두었으면 다시 건다). + +| 무엇 | setup 이 하는 일 | 효과 | +| --- | --- | --- | +| 대리 접근 빼기 | 관리자 역할의 빼는 권한(`disabledPermissions`)에 `impersonate` → `InvalidateCaches` | 관리자 비밀번호로는 어디서든 대리 접근이 403(`security.unauthorized … details = "impersonate"`). 관리 화면 · 관리 API 는 그대로 | +| 허용 IP | 영구 관리자 비밀번호 자격의 `allowedIps` = `APP_NETWORK_SUBNET` | 바깥(993 · 465 · 공개 443 — 게시 포트의 접속은 원래 주소로 보인다)에서 관리자 비밀번호 로그인이 403(`security.ip-unauthorized` "IP address not allowed."). 관리자 비밀번호가 통하는 곳은 서버 안(호스트에서 도는 setup · SSH 터널 · 앱 네트워크의 컨테이너)뿐이다 | + +- 확인 줄 — `[확인] 관리자 … — 대리 접근 권한 없음 (권한 N개에 impersonate 없음) · 허용 IP <대역> 안에서 로그인됨`(관리자 세션의 실제 권한 `GET /api/account` 로 본다). +- 역할의 빼기가 계정 쪽 권한보다 앞선다 — 관리자가 자기 계정에 `impersonate` 를 더해도(Merge — 저장은 된다) 대리 접근은 403 이고, 관리자는 스스로 되돌리지 못한다 + (역할의 빼기 지우기 · 게이트웨이 역할 받기 · `impersonate` 를 주는 새 역할 만들기가 모두 `You are not authorized to grant permissions: impersonate`). 되돌리는 길은 복구 관리자뿐이다. +- 허용 IP 는 이 실행의 관리 API 주소가 앱 네트워크 안으로 보일 때만 건다 — 루프백(개발 포트 매핑) · `mail` · 앱 네트워크 안의 IPv4(운영의 `MAIL_ADMIN_URL` — "운영 배포" 3). + **운영에서 처음 걸 때는 복구 관리자가 있어야 건다**(`--recovery` — 걸자마자 막히면 되돌릴 길이 그것뿐이다). 걸지 못하면 운영은 종료 1, 개발은 `[주의]` 다. + 건 뒤 관리자로 다시 로그인해 보고, 막혔으면 복구 관리자로 되돌린다. +- 운영자가 대역 안으로 **더 좁힌 값**(예: 관리 화면에서 `172.16.71.1` 하나)은 넓히지 않고 `[유지]` 한다. 대역 밖 항목이 하나라도 있으면(옛 대역 · `0.0.0.0/0`) 대역 하나로 바꾼다. +- **`APP_NETWORK_SUBNET` 을 바꾸면** 관리자 허용 IP 가 옛 대역에 남아 setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 → + 복구 관리자를 일회성으로 주고 `--recovery` 로 돌리면 지금 대역으로 고친다(`[변경] 관리자 … — 허용 IP <옛 대역> → <새 대역> …` — "운영 배포" 1 · 3 · 4 · 5 차례). 개발은 env 에 복구 관리자가 있어 보통 실행이 고친다. + 허용 IP 에 지금 대역 안의 항목이 있는데(좁힌 값) 403 이면 고치지 않고 멈춘다 — 이 실행의 주소가 좁힌 값 밖이다(`MAIL_ADMIN_URL` · 관리 화면에서 넓히기). +- 관리자의 **다른 자격**(앱 비밀번호 · API 키 — 엔진은 비밀번호 자격을 계정마다 하나만 받는다)의 허용 IP 가 대역 밖이거나 없으면 `[주의]` — 그 자격으로 바깥에서 관리자가 된다. + 쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지운다. +- 다른 관리자 역할 계정 — setup 은 비밀번호를 몰라 걸지 않고 `[주의]` 를 낸다. 관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 같은 대역으로 두거나, 쓰지 않으면 지운다. +- **그대로인 것** — 게이트웨이(`MAIL_GATEWAY_*`)는 여전히 모든 직원과 관리자까지 대리 접근한다(백엔드가 막는다 — [보안](#보안)). 관리자는 직원의 비밀번호를 바꿀 수 있다(그러면 직원이 로그인하지 못해 드러난다). + 복구 관리자는 역할 · 허용 IP 를 보지 않는다 — 운영은 그래서 파일에 두지 않는다. + +시험 (0.16.27 — 2026-10-31 임시 엔진 · 2026-11-02 다시, 개발 엔진): + +| 어디서 | 무엇 | 걸기 전 | 건 뒤 | +| --- | --- | --- | --- | +| 서버 안(앱 네트워크) | 관리자 세션 · 관리 API(`x:Account/query`) | 200 | 200 | +| 서버 안 | `<직원>%<관리자>` (JMAP · IMAP) | 200 · 로그인 | 403 · 실패 | +| 서버 안 | `<직원>%<게이트웨이>` (웹메일이 쓰는 길) | 200 | 200 | +| 바깥(다른 네트워크) | 관리자 본인 JMAP · IMAP | 200 · 로그인 | 403 · 실패 | +| 바깥 | 직원 본인 JMAP (대조) | 200 | 200 | +| 호스트 → 앱 네트워크 주소 (SSH 터널) | 관리자 세션 · 관리 화면 `/admin/` | 200 | 200 · 200 (엔진이 본 주소 `172.16.71.1` — 앱 네트워크의 게이트웨이) | +| 호스트 → 리졸버 네트워크(mail-dns) 주소 | 관리자 세션 | 200 | 403 (`remoteIp = 172.16.73.1` · IP address not allowed) | + +#### 관리 화면 — SSH 터널 + +관리 화면 · 관리 API(8080)는 바깥에 열지 않는다 — SSH 로 서버 B 에 붙어 mail 컨테이너의 **앱 네트워크 주소**로 터널을 연다. +호스트에서 컨테이너 주소로 가는 연결은 그 네트워크의 게이트웨이 주소(`APP_NETWORK_SUBNET` 의 `.1`)로 나가 관리자 허용 IP 안에 든다. +mail 컨테이너의 다른 주소(리졸버 네트워크 `172.16.73.x`)로 열면 403 이다. + +```bash +# 1) 서버 B 에서 — mail 컨테이너의 앱 네트워크 주소 (mail 을 다시 만들면 바뀐다 — 열 때마다 본다) +sudo docker inspect -f '{{index .NetworkSettings.Networks "arasystem_app-network" "IPAddress"}}' arasystem-mail +# 2) 노트북에서 — 서버 A 를 거쳐(-J) 서버 B 로. 로컬 포트는 127.0.0.1 에만 +ssh -N -J <서버 A> -L 127.0.0.1:8180:<1의 주소>:8080 <서버 B> +# 3) 브라우저 http://localhost:8180/admin/ — 관리자 주소 · MAIL_ADMIN_PASSWORD +``` + +- 서버 B(와 `-J` 로 거치는 서버 A)의 sshd 가 TCP 전달을 허락해야 한다(`AllowTcpForwarding` — Ubuntu 기본 yes. 꺼 두었으면 `administratively prohibited`). Windows 의 OpenSSH 도 같은 명령이다. +- 개발은 터널 없이 (포트 매핑 — 앱 네트워크의 게이트웨이 주소로 보인다). + +### 휴지통 · 스팸메일함 자동 비우기 + +setup 이 엔진의 `x:DataRetention` 을 맞춘다 — 휴지통 · 스팸메일함에 **들어온 때부터 30일**(`expungeTrashAfter`)이 지난 메일을 엔진의 계정 정리가 영구 삭제한다. + +- 기준은 받은 시각이 아니라 그 메일함에 들어온 시각이다(엔진 안의 삭제 시각 칸 — 0.16.27 `email/src/message/delete.rs`). 받은편지함에도 함께 든 메일은 통째로 지워진다. + - **옮겨 온 메일(imapsync)도 옮긴 때부터 센다** — 엔진은 휴지통 · 스팸메일함에 든 순간의 시각(`now()`)을 그 칸에 쓴다(0.16.27 `email/src/message/index/mod.rs` — 내부 날짜가 아니다). + 시험(2026-11-02 임시 엔진, 기간 70초): 내부 날짜 2025-01-01 로 휴지통 · 스팸메일함에 IMAP APPEND → 바로 정리해도 남고, 70초 뒤 정리에 지워졌다(받은편지함의 같은 날짜 메일은 그대로). + 그래서 이전 다음 날 30일 넘은 옛 휴지통 메일이 한꺼번에 지워지지 않는다 — 이전한 날부터 30일 뒤에 지워진다(직원에게 알릴 거리). + - 자동 비우기는 **엔진의 휴지통 · 스팸메일함(특수 메일함 — "Deleted Items" · "Junk Mail") 둘에만** 걸린다. 이름이 같은 다른 폴더(imapsync 가 "Trash" 를 따로 만든 경우)는 지워지지 않는다 — + [imapsync](#imapsync) 의 메일함 맞추기를 본다. +- 엔진 기본값도 30일이지만 기대지 않고 맞추고, 관리 화면에서 바꾼 값은 되돌린다(`[변경]`). 휴지통에서 지운 메일도 [백업](#백업)의 보존 기간(최대 6개월)까지는 백업에 남는다. +- **정리 시각을 새벽으로 옮긴다** — 엔진의 정리 작업 셋은 메일 컨테이너의 현지 시각(UTC — compose 가 TZ 를 주지 않는다)으로 돌아 기본값(00:00 · 02:00 · 04:00)이 한국 시각 09:00 · 11:00 · 13:00 이었다. + +| 작업 | 엔진 칸 | 한국 시각 | 컨테이너(UTC)에 넣는 값 | +| --- | --- | --- | --- | +| 계정 정리 — 휴지통 · 스팸 30일 | `expungeSchedule` | 03:00 | 18:00 | +| (백업 — 메일을 몇 초 멈춘다) | — | 03:30 | — | +| 데이터 정리 | `dataCleanupSchedule` | 04:00 | 19:00 | +| 원문(blob) 정리 | `blobCleanupSchedule` | 04:30 | 19:30 | + +- setup 은 컨테이너의 UTC 차이(`date +%z`)로 바꿔 넣는다. 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 엔진이 다음 시각을 기동 때와 실행 직후에만 정한다. +- 확인 — setup 요약의 `휴지통 들어온 지 30일 · 정리 KST 03:00 · 04:00 · 04:30`, 다음 날 엔진 로그의 그 시각(UTC) 작업 줄. + +### 메일 프로그램 자동 설정 + +메일 프로그램(Thunderbird · 클래식 Outlook 등)에 주소만 넣으면 서버 · 포트가 채워지게, **포털의 frontend nginx 가** `autoconfig.` · `autodiscover.` 에 고정 응답을 낸다(B안). +엔진의 같은 경로는 쓰지 않는다 — 요청에 실린 주소를 이스케이프 없이 XML 에 되돌려 넣고, 열지 않은 POP3 · DAV 까지 알린다. 엔진의 공개 443 은 그대로 닫아 둔다. + +| 경로 (그 두 호스트에서만) | 누가 묻나 | 응답 | +| --- | --- | --- | +| `GET /mail/config-v1.1.xml` · `GET /.well-known/autoconfig/mail/config-v1.1.xml` | Thunderbird · Thunderbird for Android(K-9) · Evolution 등 | IMAP `MAIL_HOSTNAME`:993 SSL · SMTP :465 SSL, 사용자 이름 `%EMAILADDRESS%`(프로그램이 채운다) | +| `POST /autodiscover/autodiscover.xml` (대소문자 무관) | 클래식 Outlook (POX) | 같은 IMAP · SMTP. `LoginName` 은 주지 않는다(요청 값을 되돌려 쓰지 않으려고) — 그러면 Outlook 은 `@` 앞부분으로 로그인하는데 엔진은 그것도 받는다(IMAP · SMTP · 앱 비밀번호 — 2026-10-31 임시 엔진) | +| 그 밖 | — | 404 (`/api` 도 — 백엔드로 넘기지 않는다) | + +- 요청 값(질의의 `emailaddress` · POST 본문)은 읽지 않는다 — 응답은 늘 같다. 접근 로그는 쿼리 없이(주소가 질의에 실린다), 본문 한도 64k. +- 값은 루트 env 의 `MAIL_DOMAIN` · `MAIL_HOSTNAME`(compose 가 frontend 에 넘긴다 — `frontend/nginx-env.envsh` 가 검사). 하나라도 비면 이 server 의 이름은 어느 요청에도 맞지 않는 `autoconfig.invalid` 다. + 모양이 틀리거나(영문 · 숫자 · `-` · `.` 의 점 있는 이름만 — 소문자로 바꿔 쓴다), `autoconfig.` · `autodiscover.` 이 공개 내려받기 호스트나 `CORS_ORIGIN` 의 호스트와 같으면 frontend 가 뜨지 않는다(fail-fast — `docker logs arasystem-frontend` 의 `nginx-env.envsh:` 줄). +- CI 의 infra 잡이 렌더링(server 이름 · 호스트 이름)과 실제 요청(주소를 꾸민 질의 · POST 가 응답에 들어가지 않는지, 그 밖 경로 404, 포털 · 공개 호스트에서는 열리지 않는지)을 본다. + +**전면 전환 때 할 일** — ⚠️ **지금은 DNS 를 바꾸지 않는다.** 두 이름은 아직 mailcow 를 가리키고 mailcow 직원이 쓰고 있다(전환 체크리스트의 일): + +1. **미리 확인** (DNS 를 바꾸기 전 — 서버 A 에서 서버 B 의 사설 주소로): `curl -s -H 'Host: autoconfig.arasystem.it.kr' http://<서버 B 사설 IP>:8080/mail/config-v1.1.xml | grep -E 'hostname|port'` → `mx1.arasystem.it.kr` · 993 · 465. +2. **NPM** (서버 A): Proxy Host 둘 — `autoconfig.arasystem.it.kr` · `autodiscover.arasystem.it.kr` → `http://<서버 B 사설 IP>:8080`(포털과 같은 곳, Host 는 그대로 — NPM 기본). + 접근 로그는 끈다(Thunderbird 는 질의에 메일 주소를 싣는다). 인증서는 3 다음에. +3. **DNS** (가비아): `autoconfig` · `autodiscover` A → 서버 A 의 공인 IP(지금 mailcow 를 가리키는 레코드를 바꾼다). TTL 은 다른 전환 레코드와 함께 미리 낮춘다. + 전파된 뒤 NPM 의 두 Proxy Host 에서 SSL 탭 › Let's Encrypt 인증서(HTTP 검증 — 그래서 DNS 가 먼저다) · Force SSL. + 이 이름들의 인증서는 NPM 이 갖는다 — 엔진의 인증서 이름(Additional Hostnames)에는 넣지 않는다("운영 배포"의 TLS 인증서 줄). +4. **SRV** (RFC 6186 · RFC 8314 — 지원하는 프로그램이 쓴다): + + | 이름 | 값 | 뜻 | + | --- | --- | --- | + | `_imaps._tcp.arasystem.it.kr` | `SRV 0 1 993 mx1.arasystem.it.kr.` | IMAP — 처음부터 TLS | + | `_submissions._tcp.arasystem.it.kr` | `SRV 0 1 465 mx1.arasystem.it.kr.` | 보내기 — 처음부터 TLS (465) | + | `_submission._tcp` · `_imap._tcp` · `_pop3._tcp` · `_pop3s._tcp` | `SRV 0 0 0 .` | 열지 않음 — `_submission` 은 587(STARTTLS)용이라 465 를 적으면 프로그램이 465 에 STARTTLS 를 시도해 실패한다 | + + 가비아가 대상 `.` 을 받지 않으면 "열지 않음" 줄은 빼도 된다(없으면 프로그램이 다음 방법으로 넘어간다). +5. **확인** — 바깥에서 `curl -s https://autoconfig.arasystem.it.kr/mail/config-v1.1.xml | grep hostname`, + `curl -s -X POST -H 'Content-Type: text/xml' --data '' https://autodiscover.arasystem.it.kr/autodiscover/autodiscover.xml | grep Server`, + `dig +short SRV _imaps._tcp.arasystem.it.kr`. Thunderbird · 클래식 Outlook 으로 새 계정을 한 번씩 만들어 본다(2단계 인증을 켠 직원은 앱 비밀번호). + 클래식 Outlook 은 도메인 자체(`https://arasystem.it.kr/autodiscover/autodiscover.xml` — 회사 홈페이지)를 먼저 묻는다 — 거기서 오래 붙잡히거나 인증서 경고가 나지 않는지도 본다. + 새 Outlook · 휴대폰 기본 메일 앱은 자기 방식(마이크로소프트 · 제조사의 자동 감지)을 써서 이 응답을 쓰지 않을 수 있다 — 그때는 직원이 서버를 손으로 넣는다(값은 웹메일 설정 › 앱 비밀번호의 메일 프로그램 설정 표). + +--- + ## 파일럿 공존 (mailcow 와 함께 — 한 사람 먼저) 전면 전환 전에 **한 사람(시범 사용자)만** 같은 `@arasystem.it.kr` 주소로 새 서버(`mx1.arasystem.it.kr`)를 쓰고, 나머지 직원은 mailcow 에 그대로 둔다. @@ -1293,7 +1463,8 @@ mailcow 를 거친 메일은 SPF · DMARC 결과가 없고(`Authentication-Resul - **DMARC** — setup 이 출력하는 권장값은 `p=reject; rua=mailto:postmaster@…` 다. 전환 중에는 **지금의 `p=none`·`rua=dmarc@` 를 유지**하고, 리포트로 두 서버 모두 통과하는 것을 본 뒤 `quarantine` → `reject` 로 올린다. - **MTA-STS** — 정책이 MX 와 어긋나면 MTA-STS 를 지키는 발신자가 배달에 실패한다. **전환이 끝난 뒤** 게시한다. -- **SRV·autoconfig·autodiscover** — 메일 클라이언트(IMAP) 자동 설정용. 웹메일만 쓰면 필요 없다. +- **SRV·autoconfig·autodiscover** — 메일 프로그램(IMAP) 자동 설정용. setup 이 출력하는 권장값(엔진 · mx1)이 아니라 [메일 프로그램 자동 설정](#메일-프로그램-자동-설정)의 "전면 전환 때 할 일"대로 — autoconfig · autodiscover 는 서버 A(NPM) 로, SRV 는 `_imaps` · `_submissions` → mx1. + 전환 전에는 그대로 둔다(mailcow 직원이 쓰고 있다). MX 를 바꾸는 날 함께 바꾼다. - **DKIM 자동 교체** — 도메인 설정은 `dkimManagement: Automatic`(`rotateAfter` 90일 · `retireAfter` 7일 · `deleteAfter` 30일)이지만, **0.16.27 은 엔진이 DNS 를 직접 고칠 때(`dnsManagement: Automatic` + DKIM 게시)만 교체한다.** DNS 를 손으로(가비아) 관리하는 지금 설정(`dnsManagement: Manual`)에서는 활성 키에 교체 일정이 생기지 않아(`x:DkimSignature` 의 `nextTransitionAt` 가 비어 있다) bootstrap 때 만든 두 키를 계속 쓴다 @@ -1315,6 +1486,9 @@ imapsync \ - 원본(mailcow)은 **Dovecot 마스터 계정**으로 직원 비밀번호 없이 읽는다. mailcow 의 마스터 계정 설정 방법과 로그인 형식(Dovecot 기본 구분자는 `*`)은 **mailcow 문서를 확인해야 한다** (여기서는 검증하지 않았다). +- **휴지통 · 스팸메일함은 엔진의 특수 메일함으로 맞춘다** — 엔진의 이름은 "Deleted Items" · "Junk Mail"(IMAP SPECIAL-USE `\Trash` · `\Junk`)이고 mailcow 는 "Trash" · "Junk" 다. + `--automap` 이 특수 용도로 짝짓지만, 한 사람으로 먼저 돌려 새 엔진에 "Trash" · "Junk" 폴더가 따로 생기지 않았는지 본다 — 생겼으면 `--f1f2 'Trash=Deleted Items' --f1f2 'Junk=Junk Mail'` 로 다시. + 따로 생긴 폴더는 자동 비우기(30일 — [휴지통 · 스팸메일함 자동 비우기](#휴지통--스팸메일함-자동-비우기))가 걸리지 않는다. 옮긴 휴지통 메일의 30일은 옮긴 날부터 센다(같은 절 — 시험함) - 대상(Stalwart)은 계정을 만들 때 정한 초기 비밀번호로 로그인한다. 게이트웨이 대리 접근(`%`)은 HTTP 에서만 검증했고 IMAP 에서는 확인하지 않았다. 게이트웨이는 신뢰 대역에서만 로그인되므로 외부에서 쓸 수도 없다. - 직원 비밀번호는 정책 결정이 필요하다 — 새 초기 비밀번호를 배포하고 첫 로그인 때 바꾸게 하거나, @@ -1324,15 +1498,119 @@ imapsync \ ## 백업 -| 볼륨 | 내용 | -| --- | --- | -| `_mail-config` | `config.json` (작다). **없으면 bootstrap 모드로 뜬다** | -| `_mail-data` | RocksDB — 메일·계정·설정 전체 | -| `_files` | 대용량 첨부 파일(11단계) — **백업하지 않는다**(보낸 날부터 30일짜리 전송물 · 계약 4절). 기록(이름 · 크기 · 링크)은 DB 백업에 든다 — 복원한 뒤 바이트가 없는 파일은 받는 쪽에 "찾을 수 없음"이다. ⚠️ `docker compose down -v` 가 지운다 | -| `_clamav-db` | 악성 검사 서명 — 백업하지 않는다. 지우면 `clamav-updater` 가 다시 받는다(1분 안팎) | +13단계부터 서버 B 의 호스트가 **매일 03:30(KST)** 혼자 백업한다 — `ops/backup/arasystem-backup.sh` 를 systemd 타이머가 돌리고, +암호화한 restic 저장소를 **NCP Object Storage**(서버 밖)에 둔다. 결과는 Healthchecks.io 로 알린다([감시](#감시-13단계)). -RocksDB 는 실행 중 파일 복사로는 일관성이 보장되지 않으므로 **잠시 멈추고** 받는다. -멈춘 동안 외부 서버는 배달을 재시도(보통 수일)하므로 짧은 중지로 메일이 유실되지 않는다. +| 무엇 | 어디서 | 비고 | +| --- | --- | --- | +| 메일 엔진 `_mail-config` · `_mail-data` | 볼륨 (읽기 전용으로 붙인다) | RocksDB — 메일 · 계정 · 설정 · DKIM 키 · 인증서 전체. `config.json` 이 없으면 bootstrap 모드로 뜬다 | +| DB | `pg_dump -Fc` (멈추지 않는다) | 로그인 세션 · 서명 · 설정 · 대용량 첨부 기록 등. 덤프를 `pg_restore -l` 로 읽어 본 뒤 올린다 | +| 업로드 `_uploads` | 볼륨 | 서명 그림(`mail-signatures/`) · `/api/files` | +| env 파일 | `.env.production` · `backend/.env.production` | 비밀이 든 파일 — 저장소가 restic 비밀번호로 암호화된다. 새 서버를 되살릴 때 필요하다 | +| 받지 않는 것 | — | 대용량 첨부 `_files`(30일짜리 전송물 — 기록은 DB 에 있어 복원하면 바이트가 없는 파일은 "찾을 수 없음"), 악성 검사 서명 `_clamav-db`(다시 받는다), 리졸버 · frontend(상태 없음), 로그 | + +**차례** — RocksDB 는 켜 둔 채 파일을 복사하면 일관되지 않아 잠시 멈춘다. 멈추는 시간을 줄이려고 두 번 올린다: + +0. 사전 확인 — 메일 엔진 · DB 가 healthy 인지. 부팅 직후 타이머가 놓친 백업을 바로 할 때(`Persistent=true`)는 엔진이 아직 starting 이라 + healthy 를 `BACKUP_HEALTHY_WAIT`(기본 300초)까지 기다린 뒤 판단한다(멈췄거나 없으면 기다리지 않는다) +1. DB 덤프 · env 파일 복사(작업 폴더 `/var/lib/arasystem-ops` — 0700. 어떻게 끝나도 지운다) +2. **warm** — 엔진을 켜 둔 채 올린다(바뀐 것 대부분이 여기서 올라간다. 이 스냅숏은 복원에 쓰지 않는다 — 복원 명령은 id 를 줘도 final 만 받는다). + 도는 엔진이 그사이 지운 파일을 읽지 못하면 restic 이 종료 3(일부를 읽지 못함)으로 끝나는데, warm 에서만 받아들이고 한 줄 남긴다 +3. 엔진을 멈추고(`docker stop`) **final** — 같은 경로를 다시. RocksDB 의 파일은 한 번 쓰면 바뀌지 않아 그사이 바뀐 것만 올라간다. + final 은 종료 3 도 실패다. **시간 상한**(`BACKUP_FINAL_TIMEOUT`, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게. + 상한은 `timeout --foreground` 로 건다 — 손으로 돌릴 때 Ctrl-C 가 restic 에 바로 닿아 정리로 간다(그룹이 다르면 상한까지 기다렸다 — 시험함) +4. 엔진을 띄우고 `/healthz/ready` 까지 기다린다(3분). 스크립트가 어떻게 끝나도(오류 · 시간 초과 · 중지) 멈춘 엔진을 다시 띄운다 — 멈추는 중에 끊겼으면 멈춤이 끝난 뒤에 띄운다 + (docker 가 stop 을 처리하는 동안 부른 start 는 아무 일도 하지 않고, 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 — 시험함). 그래도 남으면 systemd `ExecStopPost` 와 + 부팅 때의 `arasystem-mail-recover.service`(백업보다 먼저 — 도커가 늦으면 30초마다 10번까지 다시)가 표시 파일(`mail-stopped-by-backup`)을 보고 띄운다 — + 엔진이 떠 있는 것을 본 뒤에만 표시를 지운다. 이것(`ensure-mail-running`)도 백업 잠금을 잡는다 — 백업이 도는 동안 손으로 부르면 아무것도 하지 않고 그만둔다 + (final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다) + 끊겼을 때의 정리 차례: 비밀 사본 지우기 → 엔진 띄우기 → 상태 파일 · Healthchecks `/fail` → 그 뒤 엔진 준비 기다리기(systemd 가 시간 상한으로 끊어도 `/fail` 이 빠지지 않게) +5. 보존 — final 은 **매일 7 · 매주 4 · 매달 6**, warm 은 마지막 1. 일요일(KST)에 `prune` 과 `check`(데이터 5% 를 내려받아 본다) +6. 상태 파일(15분 점검이 읽는다) · Healthchecks(시작 · 성공 + 요약 한 줄 · 실패 + 까닭 — ping 주소는 curl 의 설정 글로 표준 입력에 준다, 명령줄에 남지 않게) +7. 같은 잠금(`backup.lock`)을 복원 연습 · `ensure-mail-running` 도 잡는다 — 함께 돌지 않는다(백업 쪽은 건너뛰고 Healthchecks 에 `/fail "건너뜀 …"` 한 줄, 연습 쪽은 멈추고, ensure 는 그만둔다) + +영향 (임시 엔진 + 로컬 MinIO 로 잰 값 — 2026-10-31): + +| 항목 | 값 | +| --- | --- | +| 메일 엔진 멈춤 | 8~11초 (데이터 0.1~1.3GB). 그동안 웹메일은 잠깐 `MAIL_001`, IMAP 은 다시 붙고, 들어오는 메일은 보낸 서버가 다시 보낸다 | +| 시간 | warm 28초(1.2GB, 처음) — NCP 로는 서버의 바깥 대역폭에 달렸다. 다음 날부터는 바뀐 만큼만 | +| 메모리 · CPU | restic 컨테이너 — 메모리 한도 1g(잰 최대 0.63GB — [메모리 예산](#메모리-예산)) · CPU 1 · CPU 몫 512(다른 컨테이너의 절반). systemd 의 `Nice=10` · IO 낮은 우선은 스크립트 · docker CLI 에만 걸린다(컨테이너는 dockerd 아래라 물려받지 않는다) | +| 디스크 | 복사본을 만들지 않는다(볼륨을 직접 읽는다) — DB 덤프(압축) · env 만 작업 폴더에. restic 캐시 볼륨 `-backup-cache`(잰 값 144KB — 저장소가 크면 는다) | +| 되살릴 수 있는 시점 | 마지막 03:30 (그 뒤 하루치는 잃을 수 있다) | + +### 설치 (서버 B — 한 번) + +```bash +# 1) 스크립트 · 유닛 — 저장소의 ops/ 를 /opt 로 복사한다 (저장소를 고쳐도 복사본은 그대로 — ops/ 가 바뀌면 이 단계를 다시) +sudo install -d -m 0755 /opt/arasystem-ops +sudo cp -r ops/lib ops/backup ops/monitor /opt/arasystem-ops/ && sudo chown -R root:root /opt/arasystem-ops && sudo chmod -R go-w /opt/arasystem-ops +sudo install -m 0644 ops/systemd/arasystem-*.service ops/systemd/arasystem-*.timer /etc/systemd/system/ && sudo systemctl daemon-reload + +# 2) 설정 · 비밀 — 저장소 밖, root 만 (값은 ops/ops.env.example 의 설명대로) +sudo install -d -m 0700 -o root -g root /etc/arasystem +sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env && sudoedit /etc/arasystem/ops.env +sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' +# ⚠️ restic 비밀번호와 NCP 키를 회사의 비밀번호 관리자에도 둔다 — 서버를 잃으면 이것 없이는 백업을 풀 수 없다 + +# 3) 저장소를 만들고 첫 백업을 손으로 (메일 엔진이 몇 초 멈춘다 — 사용이 적은 시간에) +sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh init +sudo systemctl start arasystem-backup.service; journalctl -u arasystem-backup -n 30 --no-pager + +# 4) 타이머 켜기 — 백업 03:30 · 15분 점검, 그리고 부팅 때 백업이 멈춘 엔진 되살리기(표시 파일이 있을 때만 — 평소에는 아무 일도 하지 않는다) +sudo systemctl enable --now arasystem-backup.timer arasystem-check.timer +sudo systemctl enable arasystem-mail-recover.service +systemctl list-timers 'arasystem-*' +``` + +- **NCP Object Storage** — 콘솔에서 버킷을 만든다(공개 안 함 · 수명 주기 삭제 규칙 없음 — 지우기는 restic 이 한다). API 키는 이 버킷만 쓰는 서브 계정으로. + `RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/<버킷>/arasystem`, `RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path'` — + 지역 `kr-standard`(서명 V4) · 경로 방식(NCP 는 경로 · 가상 호스트 방식 둘 다 받는다 — [NCP API 가이드 Object Storage](https://api.ncloud-docs.com/docs/storage-objectstorage)). + ⚠️ **NCP 로는 아직 돌려 보지 않았다**(MinIO 로만) — 3)의 `init` · 첫 백업 · 아래 `drill` 로 처음 확인한다. +- 설정 파일 `/etc/arasystem/ops.env` 는 셸로 실행하지 않고 줄마다 읽는다. `ARASYSTEM_DIR`(저장소 폴더)의 `.env.production` 에서 `PROJECT_NAME` 등을 읽는다 — 값을 따로 복사해 두지 않는다. + `PROJECT_NAME` 은 ops.env 에도 적어 두기를 권한다(새 서버 복원의 첫 단계에는 `.env.production` 이 없다 — 아래 "서버를 통째로 잃었을 때"). 둘 다 있고 다르면 스크립트가 멈춘다. +- 백업은 root 의 systemd 가 돈다(`ProtectSystem=full` · `ProtectHome=read-only` · `PrivateTmp` · 최대 6시간 · 멈출 때 엔진을 되살릴 시간 5분). 로그는 `journalctl -u arasystem-backup`. + docker 는 `Wants` 로만 묶는다 — `Requires` 면 docker 를 다시 시작할 때 백업도 함께 멈춘다. +- 바깥 연결 — 서버 B 에서 NCP Object Storage(443)로 나갈 수 있어야 한다. restic 이미지는 처음 한 번 받는다(`docker pull` — Docker Hub). + +### 복원 + +`ops/backup/arasystem-restore.sh` (root — 설정은 백업과 같다): + +| 명령 | 하는 일 | +| --- | --- | +| `snapshots` | final 스냅숏 목록 (`.env.production` 없이 돈다) | +| `check [5%]` | 저장소 검사 — 데이터를 그만큼 내려받아 본다 (`.env.production` 없이 돈다) | +| `drill [스냅숏\|latest] [--keep]` | **복원 연습** — 운영을 건드리지 않는다. 임시 볼륨(`-drill_*`)에 풀고 같은 판의 엔진 · DB 를 **네트워크 없이** 띄워 계정 수 · 계정마다 메일 수 · DB 표마다 행 수 · 업로드 수 · env 지문을 지금 운영과 견준 뒤 지운다. 백업과 같은 잠금을 잡는다 | +| `drill-clean` | 남은 연습 자원을 지운다 | +| `files <스냅숏> <폴더>` | DB 덤프 · env 파일만 폴더로 꺼낸다 (`.env.production` 없이 돈다 — 새 서버의 첫 단계) | +| `volumes <스냅숏>` | 메일 엔진 · 업로드 볼륨을 되살린다 — 볼륨이 없거나 비었고 쓰는 컨테이너가 멈춰 있을 때만(compose 의 이름표를 붙여 만든다). 볼륨을 들여다보지 못하면 비었다고 보지 않고 멈춘다 | +| `db <덤프>` | 빈 DB 에 덤프를 넣는다(표준 입력으로) — 표가 있으면 멈춘다 | + +- 스냅숏은 **final 만** 받는다 — id 를 직접 줘도 태그를 보고 warm 이면 멈춘다(켜 둔 엔진에서 받은 것이라 RocksDB 가 일관되지 않을 수 있다). +- 보조 컨테이너는 모두 권한을 버리고(`--cap-drop ALL` · `no-new-privileges`) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기, 복원(restic)은 쓰기 · 소유자 되살리기, + 연습 DB 는 postgres 시작 스크립트가 쓰는 것, 연습 엔진은 실행 파일의 낮은 포트 권한(`NET_BIND_SERVICE` — 없으면 exec 가 거절된다), 연습의 복구 관리자 비밀번호는 `-e 이름` 으로만. +- **연습은 한 달에 한 번**(그리고 백업 설정을 바꾼 뒤) — `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh drill`. 출력의 계정 · 메일 수가 운영과 같고 DB 표의 행 수가 백업 시각 이후 바뀐 만큼만 다른지 본다. + 연습 엔진은 운영의 호스트 이름 · 인증서 · 발송 대기열 · 웹훅을 안고 뜨므로 네트워크 없이 띄운다(대기열의 메일이 다시 나가지 않게). 디스크는 데이터만큼 잠시 더 쓴다. +- 시험 (2026-10-31 · 11-02, MinIO — 11-02 는 서버와 같은 Ubuntu 24.04 · bash · flock · timeout 컨테이너에서): 백업 → 연습 · `files` · `volumes` → 되살린 엔진의 계정 · 메일함 · 메일 수 지문이 원본과 같았다. + `volumes` 는 1.2GB 를 14초(로컬). 멈추는 도중 · final 도중에 SIGTERM · 시간 상한이 와도 엔진이 다시 떴다. 새 서버 차례(아래 1~5)를 빈 클론에서 끝까지 돌렸다(DB 행 수 · 엔진 지문 같음). + +**서버를 통째로 잃었을 때** (새 서버 — 차례가 중요하다: backend 가 먼저 뜨면 빈 DB 에 마이그레이션을 돌려 덤프를 넣을 수 없다): + +1. 새 서버를 [운영 배포](#운영-배포)의 사전 조건까지 준비하고 저장소를 같은 경로에 클론한다. 위 설치 1) · 2)(restic 비밀번호 · 키는 비밀번호 관리자에서 — 새로 만들지 않는다). + **`/etc/arasystem/ops.env` 에 `PROJECT_NAME` 을 옛 서버의 값으로 적는다** — 이 단계에는 `.env.production` 이 없고, 백업 스냅숏의 host 이름이 그 값이다(비우면 "PROJECT_NAME 을 알 수 없습니다"로 멈춘다) +2. `sudo bash /opt/arasystem-ops/backup/arasystem-restore.sh files latest /root/restore`(없거나 빈 폴더) → `/root/restore/backup/env/.env.production` · `…/backup/env/backend__.env.production` 을 + 저장소의 `.env.production` · `backend/.env.production` 으로(0600 — 저장소 사용자 소유) +3. `… restore.sh volumes latest` — 메일 엔진 · 업로드 볼륨 (여기부터는 `.env.production` 의 `PROJECT_NAME` 과 ops.env 의 값이 같아야 한다 — 다르면 멈춘다) +4. `sudo docker compose --env-file .env.production up -d db` → healthy 뒤 `… restore.sh db /root/restore/backup/db/db.dump`. 다 끝나면 `/root/restore` 를 지운다(비밀이 들어 있다) +5. `sudo docker compose --env-file .env.production up -d --build` — 메일 엔진은 되살린 설정으로 정상 모드로 뜬다(bootstrap 아님) +6. setup — "운영 배포" 5)와 같은 형태(`--recovery` 없이). 서버 IP 가 바뀌었으면 PTR · SPF · `mx1` A · NPM 의 전달 주소 · `TRUST_PROXY` · `FRONTEND_PRIVATE_BIND` 를 함께 고친다. + `APP_NETWORK_SUBNET` 을 바꿨다면 관리자 허용 IP 가 옛 대역이라 403 으로 멈춘다 — 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히)) +7. 웹메일 로그인 · 메일 수 · `sudo systemctl start arasystem-check.service` · 백업 타이머 · 부팅 유닛(설치 4) + +- 멈춘 동안 들어오던 메일은 보낸 서버가 며칠 다시 보낸다 — 되살리는 데 걸린 시간만큼 늦게 도착하고 잃지 않는다. 백업 뒤 ~ 사고 사이에 받은 메일 · 바꾼 설정은 없다. + +### 손으로 받을 때 (타이머 없이 — 예전 방법) ```bash sudo docker compose --env-file .env.production stop mail @@ -1341,8 +1619,80 @@ sudo docker run --rm -v "${PROJECT_NAME}_mail-config:/config:ro" -v "${PROJECT_N sudo docker compose --env-file .env.production start mail ``` -- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /` , 소유자 UID 2000 유지) 컨테이너를 띄운다. 복원 연습을 주기적으로 한다. -- 백업 파일은 다른 장소에도 둔다. 메일 전체가 들어 있으므로 접근을 제한한다. +- 복원은 빈 볼륨에 같은 경로로 풀고(`tar xzf … -C /`, 소유자 UID 2000 유지) 컨테이너를 띄운다. 백업 파일에는 메일 전체가 들어 있다 — 접근을 제한하고 서버 밖에도 둔다. + +--- + +## 감시 (13단계) + +두 겹이다 — **바깥**(UptimeRobot: 서버 · 포트가 바깥에서 보이는지)과 **안**(15분마다 도는 점검 스크립트 → Healthchecks.io). Healthchecks 는 "점검이 아예 돌지 않음"도 알린다(신호가 끊기면). + +**알림 받는 곳은 우리 메일 서버가 아닌 주소로** 둔다(운영자 둘 이상의 Gmail 등) — 메일 서버가 서면 회사 주소로는 알림이 오지 않는다. + +### Healthchecks.io (무료) — 체크 둘 + +| 체크 | 일정 | 유예 | 넣는 곳 | +| --- | --- | --- | --- | +| `arasystem-backup` | Cron `30 3 * * *` · 시간대 `Asia/Seoul` | 3시간 | `/etc/arasystem/ops.env` 의 `HC_BACKUP_URL` — 시작 · 성공(요약) · 실패(까닭) | +| `arasystem-check` | 15분마다 (Simple — Period 15분) | 20분 | `HC_CHECK_URL` — 매번 정상(요약 한 줄) 또는 실패(문제 목록) | + +- ping 주소를 아는 사람은 거짓 "정상"을 보낼 수 있다 — `ops.env`(0600) 밖에 적어 두지 않는다. 비워 두면 알리지 않고 로그 · 상태 파일만 남긴다. + +### UptimeRobot (무료 — 5분 간격) — 감시 셋 + +| 종류 | 대상 | 보는 것 | +| --- | --- | --- | +| HTTP(s) | `https://portal.arasystem.it.kr/` | 포털(서버 A 의 NPM → 서버 B frontend)이 200 | +| Port | `mx1.arasystem.it.kr` : 993 | IMAP — 서버 B 가 바깥에서 보이는지 | +| Port | `mx1.arasystem.it.kr` : 25 | 메일 받기 | + +### 15분 점검 (`ops/monitor/arasystem-check.sh`) + +| 항목 | 문제로 보는 것 (값 — `ops.env`) | 어떻게 | +| --- | --- | --- | +| 디스크 | `/` 와 도커 데이터 폴더 사용률 85% 이상 (`CHECK_DISKS` · `CHECK_DISK_MAX_PERCENT`) | `df` | +| 컨테이너 | frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 healthy 가 아님 | `docker inspect` (clamav-updater 가 unhealthy 면 "서명 갱신이 이틀 넘게 멈춤") | +| 백엔드 | `/api/health` 가 200 이 아님 — 상태 코드와 실패한 구성 요소 이름만 | backend 컨테이너 안에서 | +| 메일 대기열 | 50통 이상 또는 가장 오래된 것이 24시간 이상 (`CHECK_QUEUE_MAX` · `CHECK_QUEUE_MAX_AGE_HOURS`) | backend 컨테이너의 node → 엔진 `x:QueuedMessage` (관리자로) | +| 인증서 | 믿을 수 없는 체인 · 이름 불일치 · 남은 날 14일 미만 (`CHECK_CERTS` · `CHECK_CERT_MIN_DAYS`) — 기본은 `MAIL_HOSTNAME`:993 · 포털 · 공개 내려받기 호스트 :443 | `openssl s_client -verify_hostname` | +| 백업 | 마지막 성공에서 26시간 이상 · 백업 타이머가 꺼짐 (`CHECK_BACKUP_MAX_AGE_HOURS`) | 백업의 상태 파일 · `systemctl` | +| 백업 표시 | 표시 파일(`mail-stopped-by-backup`)이 남았는데 백업 서비스가 돌지 않고 15분보다 오래됨 — 끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 | 표시 파일 · `systemctl show arasystem-backup.service` | +| Spamhaus 키 | 키가 있는데 시험 항목(`127.0.0.2` · `127.0.1.2`)이 오지 않음 — 체험 기간 끝 등 | 메일 컨테이너의 perl 로 리졸버에 묻는다(키는 표준 입력으로) | + +- 백업이 메일 엔진을 멈춘 동안은 메일 쪽 항목을 건너뛴다 — 표시 파일만 믿지 않고, 백업 서비스가 지금 돌거나 표시가 15분 안에 생긴 것일 때만이다(남은 표시가 점검을 끝없이 조용하게 하지 않게). 문제가 있으면 종료 1 — `journalctl -u arasystem-check -n 20 --no-pager`. +- **대기열 확인의 비밀** — 관리자 비밀번호는 따로 두지 않는다: 점검이 돌 때마다 `.env.production` 의 `MAIL_ADMIN_PASSWORD` 를 읽어 `docker exec -e`(환경 변수 — 명령줄에 남지 않는다)로 backend 컨테이너의 node 에 넘긴다. + 그 node 는 앱 네트워크에서 엔진에 붙으므로 관리자 허용 IP 안이다. 읽기만 한다(대기열 조회). +- 결과 예 (개발에서): `정상 — 디스크 / 72% · 컨테이너 5개 정상 · 백엔드 health 정상 · 대기열 0통 · 인증서 … 88일 · 백업 0시간 전 · Spamhaus 키 없음`. + +### 알림이 오면 + +| 알림 | 먼저 볼 것 | +| --- | --- | +| 백업 실패 · 26시간 넘음 | `journalctl -u arasystem-backup -n 50` — NCP 키 · 버킷 · 바깥 연결, 메일 엔진이 healthy 였는지. 다시: `sudo systemctl start arasystem-backup.service` | +| 백업 표시가 남아 있음 | `sudo docker compose --env-file .env.production ps mail` — 멈춰 있으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(띄우고, 떠 있는 것을 본 뒤 표시를 지운다). 백업이 돌고 있으면 `[건너뜀]` 으로 그만둔다 — 그 백업이 끝나며 스스로 띄우니 끝난 뒤 다시 본다(`systemctl status arasystem-backup`). 그 뒤 `journalctl -u arasystem-backup` 로 왜 끊겼는지 | +| 디스크 | `sudo docker system df -v` · 도커 데이터 폴더(`docker info -f '{{.DockerRootDir}}'` — 서버 B 는 `/data/docker`). 대용량 첨부(`_files`)는 백엔드가 보관량 · 여유를 본다([대용량 첨부](#대용량-첨부-11단계--인프라)) · 로그는 [로그 보존](#로그-보존-13단계) | +| 컨테이너 unhealthy | `sudo docker compose --env-file .env.production ps` · 그 컨테이너 로그. 리졸버면 [리졸버 장애 때](#리졸버-장애-때) | +| 대기열 쌓임 | 엔진 로그의 배달 실패(바깥 25 막힘 · 받는 쪽 거절 · DNS) — 관리 화면 › Queue (SSH 터널) | +| 인증서 | mx1 은 엔진의 ACME("운영 배포"의 TLS 인증서 줄), 포털 · 공개 호스트는 NPM 의 인증서 | +| Spamhaus 키 | 체험 기간 · 구독 — 끝났으면 키를 비우고 setup ([DNS 리졸버와 스팸 차단 목록](#dns-리졸버와-스팸-차단-목록-dnsbl)) | +| UptimeRobot 만 | 서버 A(NPM) · 서버 B 가 살아 있는지, NCP ACG · 공인 IP | + +--- + +## 로그 보존 (13단계) + +모든 compose 서비스에 도커 로그 한도를 적었다(`json-file` · 넘으면 돌려 쓰고 지난 파일은 압축) — 전에는 도커 기본값(한도 없음)이라 로그가 디스크를 채울 수 있었다. + +| 서비스 | 한도 | 대략 남는 기간 (직원 50명 추정) | +| --- | --- | --- | +| frontend · backend · mail | 20MB × 10개 (`x-logging-long` — 글로 140MB 안팎) | mail(인증 · 거절 · 배달) 하루 10MB 안팎 → 2주쯤, frontend(접근 로그) 하루 20MB 안팎 → 1주쯤, backend(경고 · 오류 · 주요 일만) 몇 주 | +| db · redis · mail-resolver · clamav · clamav-updater (개발 mailpit · remote-proxy 의 frontend-bind-check) | 10MB × 5개 (`x-logging-default`) | 평소 거의 쓰지 않는다 | + +- 추정의 가정은 직원 50명 · 하루 받는 메일 3,000통 · 보내는 메일 750통 · 한 사람 하루 1,500 요청(줄 길이는 개발에서 잰 값 — `docker-compose.yml` 머리말). 운영에서 `sudo docker logs --since 24h arasystem-mail 2>&1 | wc -c` 로 다시 잰다. +- 한도는 압축 전 크기다(json-file 은 줄마다 시각 · stream 껍데기가 붙어 글의 1.4배 안팎) — 디스크에는 모두 차도 0.75GB 안팎이고, 지난 파일은 gzip 이라 실제로는 그 몇 분의 1이다. `docker logs --since …` 는 지난 파일까지 읽는다. +- **처음 적용할 때 컨테이너가 다시 만들어진다**(다음 `up -d`) — 그때까지의 로그는 컨테이너와 함께 지워진다. 남길 로그가 있으면 먼저 `sudo docker logs arasystem-mail > mail-$(date +%F).log 2>&1`. +- 더 오래 남기려면 숫자를 늘리지 말고 바깥 수집기(Loki · CloudWatch 등 — 로깅 규칙 "운영 확장")로 보낸다. 같은 호스트의 다른 프로젝트(도커 데몬 설정)에는 영향이 없다. +- CI 의 infra 잡이 모든 서비스에 `logging` 이 있는지 센다 — 새 서비스를 더하면 앵커를 붙인다. --- @@ -1374,8 +1724,8 @@ sudo docker compose --env-file .env.production start mail - ⚠️ **루프백(127.0.0.1)에서 온 요청은 정책을 건너뛴다** — 메일 컨테이너 안에서 `https://127.0.0.1` 로 시험하면 200 이 나온다. 확인은 컨테이너의 네트워크 주소로 한다 (setup 이 그렇게 확인한다). 이미지 healthcheck 는 https 실패 시 http 8080 으로 대체되어 영향이 없다. - ACME(TLS-ALPN-01)는 TLS 단계에서 끝나 HTTP 경로 제한과 무관하다 (운영 인증서 발급으로는 아직 확인하지 않았다). - - 관리 화면은 SSH 터널로 내부 8080 에 붙는다: `ssh -L 8180::8080 <메일 호스트>` → . - 직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 필요해지면 백엔드 API 로 만든다. + - 관리 화면은 SSH 터널로 mail 컨테이너의 앱 네트워크 주소:8080 에 붙는다 — [관리 화면 — SSH 터널](#관리-화면--ssh-터널). + 직원 셀프서비스(`/account/` — 비밀번호 변경 등)도 공개 443 에서는 막혀 있다. 웹메일 설정 화면(비밀번호 · 2단계 · 앱 비밀번호)이 그 일을 한다. - **직원은 계정 목록을 볼 수 없다.** 기본 `User` 역할에는 `Principal/get` 으로 관리자·게이트웨이를 포함한 전체 계정을 보는 권한 (`jmapPrincipal*`·`davPrincipal*` 13개)이 있어서, setup 이 뺀다 (역할 권한은 캐시되므로 `InvalidateCaches` 로 반영). - 2026-10-26 확인: 테스트 직원의 `Principal/get` → forbidden. 백엔드를 거친 웹메일(폴더·목록·읽기·첨부 올리기/내려받기·발송·중복 발송 방지)은 그대로 동작. @@ -1397,6 +1747,7 @@ sudo docker compose --env-file .env.production start mail 그때는 그 식에 이 계정들일 때 `arasystem-system-recipients` 를 고르는 갈래를 합친다. - 확인 (2026-10-29, 0.16.27): 바꾸기 전 세 주소 모두 RCPT 250 → 배달 때 거절 → 보낸 사람(개발은 Mailpit)에게 반송. 바꾼 뒤 대소문자 · +태그 꼴까지 550, 직원 · 직원 +태그 · 이름이 `admin` 으로 시작하는 직원 · 공용 주소(Group, +태그 포함)는 250 이고 저장됐다. setup 을 이어서 돌리면 `[유지]`, 관리자를 더 만들거나 지우면 `[변경]`. +- **관리자 비밀번호는 대리 접근을 못 하고 서버 안에서만 통한다**(13단계 — setup 이 건다) — [관리자 계정 단단히](#관리자-계정-단단히). 관리 화면은 SSH 터널로만 ([관리 화면 — SSH 터널](#관리-화면--ssh-터널)). - **복구 관리자는 값이 있는 동안 정상 모드에서도 관리자로 로그인된다** (검증함). 운영은 `.env.production` 에 두지 않고 일회성 override + `--recovery` 로만 쓴다 — 파일에 있거나, 끝난 뒤 mail 컨테이너에 남아 있으면 setup 이 실패한다 ("운영 배포" 4·5). - **포트는 IPv4(`0.0.0.0`)로만 공개한다.** 주소 없이 쓰면 IPv6 로도 열리는데, IPv6 접속은 docker-proxy 가 중계해 컨테이너에는 @@ -1714,6 +2065,12 @@ sudo docker logs --tail 3 "${PROJECT_NAME}-frontend" | clamav 의 디스크(쓰기 계층)가 줄지 않는다 · `docker system df` 의 clamav 컨테이너가 크다 | 검사 중에 clamd 가 죽으며 남긴 임시 파일이다(`/tmp/clamav-.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 의 것이 쌓인다) | +| setup 이 `관리자(…) 로그인이 HTTP 403 입니다` 로 멈춘다 (13단계부터) | 관리자 허용 IP(앱 네트워크) 밖에서 붙었다 — 운영은 `MAIL_ADMIN_URL` 을 mail 컨테이너의 **앱 네트워크** 주소로("운영 배포" 3 — 다른 네트워크 주소면 403). `APP_NETWORK_SUBNET` 을 바꾼 뒤라면 복구 관리자 + `--recovery` 가 고친다([관리자 계정 단단히](#관리자-계정-단단히)). 엔진이 본 주소는 `docker logs` 의 `security.ip-unauthorized` 줄 | +| 관리 화면 로그인이 안 된다 — SSH 터널로 (13단계부터) | 터널 대상이 mail 컨테이너의 앱 네트워크 주소인지([관리 화면 — SSH 터널](#관리-화면--ssh-터널)) — mail 을 다시 만들면 주소가 바뀐다. 바깥(993 · 공개 443)에서는 관리자 비밀번호가 통하지 않는 것이 정상이다 | +| 백업이 `메일 엔진(…)이 healthy 가 아니라 백업하지 않았습니다` · `restic 저장소에 닿지 못했습니다` | 앞은 엔진부터 본다(`ps` · 로그) — 백업은 멈춘 엔진을 건드리지 않는다. 뒤는 NCP 키 · 버킷 · `RESTIC_OPTIONS` · 바깥 443, 처음이면 `arasystem-backup.sh init`([백업](#백업)) | +| 백업이 `final 이 시간 상한(…)을 넘어 멈췄습니다` 로 실패 | 바깥 대역폭 · NCP 상태를 본다 — 엔진은 다시 떠 있다. 끊긴 restic 의 저장소 잠금은 30분 뒤 오래된 잠금이 된다 — 30분 안에 손으로 다시 돌리면 보존 정리(forget)가 잠금에 걸릴 수 있다(30분 뒤 다시, 다음 날 백업은 그대로). 평소보다 바뀐 양이 크면 `BACKUP_FINAL_TIMEOUT` 을 늘린다 | +| 백업 뒤 메일 엔진이 멈춰 있다 | 정상이면 생기지 않는다(스크립트의 EXIT 처리 — 멈추는 중이었으면 멈춤이 끝난 뒤 띄운다 — 와 systemd `ExecStopPost` · 부팅 유닛이 다시 띄운다). 먼저 백업이 아직 도는지 본다(`systemctl status arasystem-backup` — 돌고 있으면 끝나며 띄운다). 끝났으면 `sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running`(백업 잠금을 잡는다 — 백업이 돌면 `[건너뜀]`) → 그래도면 `sudo docker compose --env-file .env.production up -d mail` 과 `journalctl -u arasystem-backup` | +| frontend 가 뜨지 않는다 — `nginx-env.envsh: MAIL_DOMAIN …` · `… autoconfig …` | `MAIL_DOMAIN` · `MAIL_HOSTNAME` 의 모양(영문 · 숫자 · `-` · `.` 의 점 있는 이름 — 끝 점 · 밑줄 없이), `autoconfig.` · `autodiscover.` 이 `CORS_ORIGIN` · `SHARE_PUBLIC_URL` 의 호스트와 겹치지 않는지([메일 프로그램 자동 설정](#메일-프로그램-자동-설정)) | ### 리졸버 장애 때 diff --git a/mail/scripts/setup.mjs b/mail/scripts/setup.mjs index cbebeaa..22f4239 100644 --- a/mail/scripts/setup.mjs +++ b/mail/scripts/setup.mjs @@ -36,6 +36,8 @@ * (기본 보안 설정은 IP 당 인증 실패가 하루 100회면 그 IP 를 차단한다) * 5. 게이트웨이 대리 접근 전용 역할 + 서비스 계정 (신뢰 대역 = 고정한 앱 네트워크 대역 안에서만 로그인 가능) * 6. 잠금 직원(User) 역할에서 Principal 조회 권한을 뺀다 — 직원이 관리자·게이트웨이를 포함한 계정 목록을 보지 못하게 + * 관리자 역할에서 대리 접근(impersonate)을 빼고 관리자 비밀번호의 허용 IP 를 앱 네트워크로 묶는다(13단계 — 관리자 비밀번호 하나로 + * 바깥 993 에서 <직원>%<관리자> 로 아무 메일함에나 들어가지 못하게 · ADMIN_DISABLED_PERMISSION 머리말). 리로드 뒤 관리자의 실제 권한으로 확인한다 * 공개 443(https 리스너)의 JMAP·관리 API·웹 포털을 막는다 (MTA-STS 정책 파일만 연다) * 메일을 받지 않는 시스템 계정(관리자·게이트웨이·사내 목록)으로 오는 메일은 RCPT 에서 없는 주소처럼 550 — * 받아 놓고 보낸 사람에게 반송(backscatter)하지 않게 (리로드 뒤 메일 컨테이너 안에서 RCPT 로 확인한다) @@ -49,6 +51,8 @@ * — 로그인한 직원의 발송(웹메일)에는 걸지 않고, 바깥에서 들어오는 메일에는 그대로 (mail/README.md "엔진 한도") * 계정마다 임시 업로드 크기 합계(1시간 동안 올린 양)를 첨부 합계 한도의 20배 이상으로 올린다 (설정 리로드 뒤 반영) * — 더 크게 둔 값은 내리지 않고, 개수 · 기간 · 한 번 크기는 그대로 + * 휴지통 · 스팸메일함 자동 비우기 30일(들어온 때부터)과 엔진 정리 시각 — 계정 정리 KST 03:00 · 데이터 04:00 · 원문 04:30 + * (13단계 — 백업 03:30 의 앞뒤, 설정 리로드 뒤 반영 · TRASH_RETENTION_MS 머리말) * 엔진 DNS(x:DnsResolver)를 메일 전용 리졸버로 — 운영은 리졸버에 바로(Custom), 개발은 도커 DNS 를 거쳐(System — Mailpit 이름 때문). * Spamhaus 두 항목은 키가 있고 시험을 통과하면 DQS 로 켜고, 아니면 끈다 (설정 리로드 뒤 반영 · mail/README.md "DNS 리졸버와 스팸 차단 목록") * 엔진 웹훅(x:WebHook 하나 — 12단계): 인증 이벤트 셋을 백엔드의 받는 곳으로, MAIL_WEBHOOK_SECRET 로 서명해 보낸다 (설정 리로드 뒤 반영). @@ -128,6 +132,47 @@ const HTTP_ENDPOINT_POLICY = { const USER_ROLE_DESCRIPTION = 'User'; /** 기본 관리자 역할 — 계정의 roles {"@type":"Admin"} 이 가리키는 기본 제공 역할 (emailReceive 가 없다 — 메일을 받지 못한다) */ const ADMIN_ROLE_DESCRIPTION = 'System Administrator'; +/** + * 관리자 계정 단단히 (13단계 — mail/README.md "관리자 계정 단단히"). 둘을 맞춘다: + * 1. 관리자 역할(ADMIN_ROLE_DESCRIPTION)의 빼는 권한(disabledPermissions)에 impersonate — 그대로면 관리자 비밀번호 하나로 바깥 993 IMAP · JMAP 에서 + * <직원>%<관리자> 로 아무 직원 메일함에나 들어가고(엔진 로그에는 관리자 로그인만 남는다), 관리 API 로 accountId=<직원> 메일함도 바로 연다. + * 빼면 둘 다 막히고(대리 접근 403 · IMAP 끊김 · 남의 메일함 forbidden) 관리 API(계정 · 설정)는 그대로다. + * 엔진은 역할을 바꿀 때 "바꾼 뒤의 권한 전체"를 바꾸는 쪽이 가졌는지만 본다(0.16.27 jmap/src/registry/mapping/principal.rs 의 validate_role) — + * 빼는 것은 관리자로 되고, 되돌리기(빼는 권한 지우기 · impersonate 를 주는 역할 · 계정에 게이트웨이 역할)는 관리자로 안 된다(복구 관리자만). + * 켜는 권한(enabledPermissions)에서 지우지 않고 빼는 권한에 두는 까닭 — 엔진은 역할 · 계정 권한의 켜는 것끼리 · 빼는 것끼리 합친 뒤 뺀다 + * (common/src/auth/permissions.rs 의 union · finalize). 계정에 Merge 로 impersonate 를 켜도 이 역할을 가진 계정은 여전히 못 한다(2026-10-31 임시 엔진). + * 게이트웨이(자기 역할의 impersonate — 백엔드)와 복구 관리자(역할을 쓰지 않는 슈퍼유저)의 대리 접근은 그대로다 + * 2. 영구 관리자(@도메인) 비밀번호 자격의 허용 IP = 앱 네트워크(APP_NETWORK_SUBNET) — 바깥(게시 포트 993 · 465 · 443 은 원래 주소 그대로 보인다)에서 + * 관리자 비밀번호로 로그인하지 못하게. 이 스크립트(호스트 → 컨테이너 주소 · 개발 포트 매핑)와 SSH 터널(호스트의 sshd → 컨테이너 주소)은 + * 앱 네트워크의 게이트웨이 주소(<서브넷>.1)로 보이고, backend 컨테이너의 node 로 하는 관리 API 는 그 컨테이너 주소다 — 둘 다 대역 안이다. + * 밖이면 엔진은 401 이 아니라 403(IMAP 은 끊김) — security.ip-unauthorized "IP address not allowed." 로 남는다 (2026-10-31 임시 엔진 · 개발 엔진). + * MAIL_TRUSTED_NETWORKS(게이트웨이 대역 — 더 좁힐 수 있다)가 아니라 앱 네트워크 전체인 까닭 — 좁히면 호스트(<서브넷>.1)가 빠져 이 스크립트가 막힐 수 있다. + * 운영자가 대역 안으로 더 좁힌 값(/32 등)은 그대로 둔다. 대역 밖 항목이 있으면(없음 · 옛 대역 · 0.0.0.0/0) 대역 하나로 바꾼다. + * 둘 다 관리자로 된다. 다만 운영에서 허용 IP 를 처음 걸 때는 복구 관리자가 있어야 건다(--recovery) — 걸자마자 막히면 되돌리는 길이 + * 복구 관리자(허용 IP · 역할을 보지 않는다 — mail/README.md "운영 배포" 1 · 4 · 5)뿐이다. 걸지 못하면 운영은 종료 1, 개발은 [주의]. + * APP_NETWORK_SUBNET 을 바꿔 관리자 로그인이 403 이면 복구 관리자로 지금 대역으로 고친다(ensureAdmin · resetAdminAllowedIps) + */ +const ADMIN_DISABLED_PERMISSION = 'impersonate'; +/** 관리자 허용 IP 를 걸어도 되는 이 실행의 관리 API 주소 — 루프백(개발 포트 매핑) · compose 이름 mail(앱 네트워크 안의 컨테이너) · 앱 네트워크 안의 IPv4 */ +const LOOPBACK_HOSTS = ['127.0.0.1', 'localhost', '[::1]']; +/** + * 휴지통 · 스팸메일함 자동 비우기 (13단계 — mail/README.md "휴지통 · 스팸메일함 자동 비우기") — 엔진의 x:DataRetention 싱글톤. + * expungeTrashAfter — 휴지통 · 스팸메일함에 **들어온 때부터** 이만큼 지나면 다음 계정 정리에서 메일째 영구 삭제(받은 시각이 아니다 — + * 0.16.27 email/src/message/delete.rs 가 내부 칸 DeletedAt 으로 고른다. 받은편지함에도 함께 든 메일은 통째로 지워진다). 엔진 기본값이 30일이지만 기대지 않고 맞춘다. + * 정리 시각 셋 — 엔진 기본(00:00 · 02:00 · 04:00)은 메일 컨테이너의 현지 시각(chrono Local — utils/src/cron.rs)이고 컨테이너에 TZ 가 없어 UTC 다 → KST 09:00 · 11:00 · 13:00(일하는 시간). + * 새벽으로 옮긴다: 계정 정리(휴지통 · 스팸 30일) 03:00 → 백업 03:30(ops/backup — 엔진을 몇 초 멈춘다) → 데이터 정리 04:00 → 원문 정리 04:30 (KST). + * setup 은 컨테이너의 UTC 차이(date +%z)로 바꿔 넣는다(UTC 컨테이너면 18:00 · 19:00 · 19:30). + * 바뀐 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 다시 뜬 뒤)부터다 — 스케줄러가 다음 시각을 기동 때와 실행 직후에만 정한다 + * (services/src/task_manager/scheduler.rs — 2026-10-31 임시 엔진: 저장 · 리로드 · 재시작 뒤 task-scheduled due 17:59:59Z · 18:59:59Z · 19:29:59Z) + */ +const TRASH_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +const KST_OFFSET_MINUTES = 9 * 60; +const RETENTION_SCHEDULES_KST = [ + { field: 'expungeSchedule', label: '계정 정리(휴지통 · 스팸 30일)', hour: 3, minute: 0 }, + { field: 'dataCleanupSchedule', label: '데이터 정리', hour: 4, minute: 0 }, + { field: 'blobCleanupSchedule', label: '원문(blob) 정리', hour: 4, minute: 30 }, +]; +const MINUTES_PER_DAY = 24 * 60; /** 직원 역할에서 뺄 권한 — Principal 조회(JMAP·DAV). 백엔드는 Principal 메서드를 쓰지 않는다 */ const PRINCIPAL_PERMISSION_PATTERN = /^(jmapPrincipal|davPrincipal)/; /** @@ -1380,6 +1425,19 @@ function statusInsideMail(cfg, url) { return result.status === 0 && Number.isInteger(code) ? code : null; } +/** + * 메일 컨테이너의 현지 시각이 UTC 와 몇 분 다른가 (이미지의 date +%z — 엔진의 정리 시각이 이 시각으로 돈다 · RETENTION_SCHEDULES_KST). + * docker 를 쓸 수 없거나 읽지 못하면 null + */ +function mailUtcOffsetMinutes(cfg) { + if (!cfg.docker) return null; + const result = runDocker(['exec', cfg.container, 'date', '+%z']); + const match = /^([+-])(\d{2})(\d{2})$/.exec(result.stdout?.trim() ?? ''); + if (result.status !== 0 || !match) return null; + const minutes = Number(match[2]) * 60 + Number(match[3]); + return match[1] === '-' ? -minutes : minutes; +} + /** * 메일 컨테이너 안에서 이름을 IPv4 로 풀어 본다 (이미지의 getent — 엔진이 쓰는 것과 같은 리졸버 설정). * IPv4 주소면 그대로. 돌려주는 값: 주소 배열(못 풀면 빈 배열), docker 로 물을 수 없으면 null @@ -1444,7 +1502,7 @@ async function waitForHttp(cfg, timeoutMs) { throw new SetupError(`메일 엔진이 응답하지 않습니다: ${cfg.adminUrl} (${lastError})`, { hint: unreachableHint(cfg) }); } -/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치(또는 허용 IP 밖) */ +/** 세션 조회 HTTP 상태 — 200 로그인 성공 · 401 자격 증명 불일치 · 402 비밀번호는 맞고 2단계 인증이 켜짐 · 403 허용 IP 밖(security.ip-unauthorized) */ async function loginStatus(cfg, user, password) { const res = await request(cfg, '/jmap/session', { headers: { authorization: basicAuth(user, password) } }); await res.body?.cancel(); @@ -1790,6 +1848,8 @@ async function detectRenamedAdmin(cfg) { async function ensureAdmin(cfg) { /** 이번 실행에서 bootstrap 뒤 재시작을 했다 — 엔진의 관리자를 아직 아무도 쓰지 않았다 */ let restarted = false; + /** 이번 실행에서 복구 관리자로 관리자 허용 IP 를 지금 대역으로 고쳤다 — 한 번만 (고친 뒤에도 403 이면 다른 원인이다) */ + let allowedIpsReset = false; for (let step = 0; step < MAX_ADMIN_STEPS; step += 1) { const admin = await openClient(cfg, cfg.adminAddress, cfg.adminPassword); if (admin?.accountId) { @@ -1831,6 +1891,23 @@ async function ensureAdmin(cfg) { continue; } + // 관리자 로그인이 403 — 비밀번호가 틀린 것(401)이 아니라 허용 IP(13단계 — 앱 네트워크) 밖이거나 로그인 권한이 없다. + // 비밀번호를 다시 맞춰도 풀리지 않는다 (ADMIN_DISABLED_PERMISSION 머리말 2). 복구 관리자가 있고 이 실행의 주소가 지금 대역 안이면 + // 허용 IP 가 옛 대역(APP_NETWORK_SUBNET 을 바꾼 뒤 등)인 경우라 지금 대역으로 고쳐 잇는다 — 한 번만, 그래도 403 이면 멈춘다 + if (!restarted && (await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword)) === 403) { + if (recovery?.accountId && !allowedIpsReset && adminUrlInsideAppNetwork(cfg) && (await resetAdminAllowedIps(recovery, cfg))) { + allowedIpsReset = true; + continue; + } + throw new SetupError(`관리자(${cfg.adminAddress}) 로그인이 HTTP 403 입니다 — 비밀번호가 아니라 접속 주소가 관리자 허용 IP(앱 네트워크 ${cfg.appNetworkSubnet}) 밖이다.`, { + hint: + `이 실행의 관리 API 주소는 ${cfg.adminUrl} — 운영은 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080)로 둔다 (mail/README.md "운영 배포" 3). ` + + `엔진이 본 주소는 docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)에 있다. ` + + 'APP_NETWORK_SUBNET 을 바꿨다면 복구 관리자를 일회성으로 주고 --recovery 로 실행하면 허용 IP 를 지금 대역으로 고친다(mail/README.md "관리자 계정 단단히"). ' + + '관리자의 로그인 권한(authenticate)을 뺐다면 복구 관리자로 되돌린다.', + }); + } + // 정상 모드인데 MAIL_ADMIN_PASSWORD 로는 로그인되지 않는다 → env 값으로 맞춘다 const saved = readState(); const temporary = saved ? await openClient(cfg, saved.username, saved.secret) : null; @@ -2345,6 +2422,193 @@ async function ensureUserRoleWithoutPrincipals(client) { log(`[변경] 직원 역할 — Principal 조회 권한 ${granted.length}개 제거 (${granted.join(', ')})`); } +/** + * 관리자 역할에서 대리 접근(impersonate)을 뺀다 — 빼는 권한(disabledPermissions)에 둔다 (ADMIN_DISABLED_PERMISSION 머리말). + * 관리자로 된다. 역할 권한은 캐시되므로 InvalidateCaches 로 반영한다(직원 역할과 같다). 돌려주는 값: 맞췄으면(이미 맞았으면) true + */ +async function ensureAdminRoleWithoutImpersonate(client) { + const roles = await listAll(client, 'Role', ['description', 'enabledPermissions', 'disabledPermissions']); + const role = roles.find((item) => item.description === ADMIN_ROLE_DESCRIPTION); + const label = `관리자 역할("${ADMIN_ROLE_DESCRIPTION}") — 대리 접근(${ADMIN_DISABLED_PERMISSION}) 뺌 (<직원>%<관리자> 로그인 · 남의 메일함 직접 열기 막힘, 관리 API 는 그대로)`; + if (!role) { + problems.push(`관리자 역할("${ADMIN_ROLE_DESCRIPTION}")을 찾지 못해 대리 접근(${ADMIN_DISABLED_PERMISSION})을 빼지 못했습니다 — 관리 화면 › 역할을 확인하세요 (mail/README.md "관리자 계정 단단히").`); + return false; + } + if (role.disabledPermissions?.[ADMIN_DISABLED_PERMISSION] === true) { + log(`[유지] ${label}`); + return true; + } + await setObjects(client, 'Role', { update: { [role.id]: { [`disabledPermissions/${ADMIN_DISABLED_PERMISSION}`]: true } } }); + await setObjects(client, 'Action', { create: { invalidate: { '@type': 'InvalidateCaches' } } }); + log(`[변경] ${label}`); + return true; +} + +/** + * 이 실행이 관리 API 에 붙는 주소가 앱 네트워크 안으로 보이는가 — 관리자 허용 IP 를 걸기 전에 본다(밖이면 걸자마자 이 실행이 막힌다). + * 루프백(개발 — 포트 매핑은 앱 네트워크의 게이트웨이 주소로 보인다) · compose 이름 mail(앱 네트워크에 붙은 컨테이너) · 앱 네트워크 안의 IPv4(운영 — 호스트에서 + * 컨테이너 주소로 붙으면 그 네트워크의 게이트웨이 주소로 나간다)면 true. 그 밖(다른 네트워크의 주소 · 바깥 이름)은 false + */ +function adminUrlInsideAppNetwork(cfg) { + let host; + try { + host = new URL(cfg.adminUrl).hostname.toLowerCase(); + } catch { + return false; + } + if (LOOPBACK_HOSTS.includes(host) || host === new URL(INTERNAL_ADMIN_URL).hostname) return true; + return parseIpv4(host) !== null && parseCidr(cfg.appNetworkSubnet) !== null && cidrContains(cfg.appNetworkSubnet, host); +} + +/** 허용 IP 가 모두 그 대역 안인가 — 비었으면 false(어디서나 된다), IPv6 · 읽지 못하는 항목은 밖으로 본다 */ +function allowedIpsWithin(allowedIps, subnet) { + const entries = trueKeys(allowedIps); + return entries.length > 0 && entries.every((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, subnet)); +} + +/** + * 영구 관리자 비밀번호 자격의 허용 IP 를 앱 네트워크로 (ADMIN_DISABLED_PERMISSION 머리말 2). + * 이미 모두 대역 안이면 그대로 둔다 — 운영자가 더 좁힌 값(/32 등)을 넓히지 않는다. + * 걸기 전에 이 실행의 주소가 대역 안으로 보이는지 보고(adminUrlInsideAppNetwork), 운영에서 처음 걸 때는 복구 관리자가 있어야 건다 — + * 걸자마자 이 실행이 막히면 되돌릴 길이 복구 관리자뿐이다. 걸지 못하면 운영은 실패(problems — 종료 1), 개발은 [주의]. + * 건 뒤에는 관리자로 다시 로그인해 보고, 막혔으면(403) 복구 관리자로 되돌린다. + * 돌려주는 값: { ready — 관리자가 허용 IP 안에서 로그인된다, ips — 지금의 허용 IP(좁힌 값이면 그 값) } + */ +/** ensureAdminAllowedIps 가 허용 IP 를 걸지 못했을 때 */ +const NOT_READY = Object.freeze({ ready: false, ips: '' }); + +async function ensureAdminAllowedIps(client, cfg) { + const accounts = await listAll(client, 'Account', ['name', 'emailAddress', 'credentials', 'roles']); + const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (!admin) { + problems.push(`관리자 ${cfg.adminAddress} 를 찾지 못해 허용 IP 를 걸지 못했습니다.`); + return NOT_READY; + } + const key = passwordKey(admin); + const current = admin.credentials[key].allowedIps ?? {}; + const label = `관리자 ${cfg.adminAddress} — 허용 IP ${cfg.appNetworkSubnet} (앱 네트워크 — 바깥 993 · 465 · 443 에서는 관리자 비밀번호로 로그인되지 않는다)`; + if (allowedIpsWithin(current, cfg.appNetworkSubnet)) { + if (sameTrueSet(current, [cfg.appNetworkSubnet])) log(`[유지] ${label}`); + else log(`[유지] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(current).join(', ')} (앱 네트워크 ${cfg.appNetworkSubnet} 안으로 좁힌 값 — 넓히지 않는다)`); + return { ready: true, ips: trueKeys(current).join(', ') }; + } + const notApplied = (message) => { + if (cfg.isProduction) problems.push(message); + else warn(message); + return NOT_READY; + }; + if (!adminUrlInsideAppNetwork(cfg)) { + return notApplied( + `관리자 허용 IP 를 걸지 않았습니다 — 이 실행의 관리 API 주소(${cfg.adminUrl})가 앱 네트워크(${cfg.appNetworkSubnet}) 안으로 보이는지 알 수 없어, 걸면 이 실행이 막힐 수 있다. ` + + `MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소(http://<그 주소>:8080 — mail/README.md "운영 배포" 3)로 두고 다시 실행하세요.`, + ); + } + const recovery = await openRecoveryClient(cfg); + if (cfg.isProduction && !recovery) { + return notApplied( + `관리자 허용 IP 를 걸지 않았습니다 — 운영에서 처음 걸 때는 복구 관리자가 있어야 한다(걸자마자 이 실행이 막히면 되돌릴 길이 그것뿐이다). ` + + '복구 관리자를 일회성으로 주고 --recovery 로 다시 실행하세요 (mail/README.md "13단계 전부터 운영 중인 서버").', + ); + } + await setObjects(client, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } }); + log(`[변경] ${label}${trueKeys(current).length > 0 ? ` (전 ${trueKeys(current).join(', ')})` : ''}`); + const status = await loginStatus(cfg, cfg.adminAddress, cfg.adminPassword); + if (status >= 200 && status < 300) return { ready: true, ips: cfg.appNetworkSubnet }; + // 걸자마자 이 실행이 막혔다 — 엔진이 이 실행의 주소를 대역 밖으로 본다 (403 · security.ip-unauthorized) + const previous = current; + if (recovery) { + await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: previous } } }); + problems.push( + `관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 라 복구 관리자로 되돌렸습니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다. ` + + `docker logs ${cfg.container} 의 security.ip-unauthorized 줄의 remoteIp 를 보고 MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두세요.`, + ); + return NOT_READY; + } + throw new SetupError(`관리자 허용 IP 를 건 뒤 이 실행의 관리자 로그인이 HTTP ${status} 입니다 — 엔진이 이 실행의 주소를 앱 네트워크(${cfg.appNetworkSubnet}) 밖으로 본다.`, { + hint: + `docker logs ${cfg.container} 의 security.ip-unauthorized 줄(remoteIp)을 본다. 되돌리기 — 복구 관리자를 일회성으로 주고(운영: mail/README.md "운영 배포" 1 · 2) ` + + '복구 관리자로 관리 화면 › 계정 › 관리자 › Credentials 의 Allowed IPs 를 비운 뒤, MAIL_ADMIN_URL 을 mail 컨테이너의 앱 네트워크 주소로 두고 다시 실행한다.', + }); +} + +/** + * 관리자 로그인이 403 일 때(ensureAdmin) 복구 관리자로 관리자 비밀번호의 허용 IP 를 지금 대역으로 고친다 — APP_NETWORK_SUBNET 을 바꾸면 + * 허용 IP 가 옛 대역에 남아 관리자가 막힌다. 지금 대역 안의 항목이 하나라도 있으면(그대로이거나 운영자가 좁힌 값) 고치지 않고 false — + * 403 은 다른 원인이다(이 실행의 주소가 좁힌 값 밖 등 — 부른 쪽이 멈춘다) + */ +async function resetAdminAllowedIps(recovery, cfg) { + const accounts = await listAll(recovery, 'Account', ['emailAddress', 'credentials']); + const admin = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (!admin) return false; + const key = passwordKey(admin); + const previous = admin.credentials?.[key]?.allowedIps ?? {}; + if (trueKeys(previous).some((cidr) => parseCidr(cidr) !== null && cidrWithin(cidr, cfg.appNetworkSubnet))) return false; + await setObjects(recovery, 'Account', { update: { [admin.id]: { [`credentials/${key}/allowedIps`]: toTrueSet([cfg.appNetworkSubnet]) } } }); + log( + `[변경] 관리자 ${cfg.adminAddress} — 허용 IP ${trueKeys(previous).join(', ') || '없음'} → ${cfg.appNetworkSubnet} ` + + '(관리자 로그인이 403 이었다 — 앱 네트워크 대역이 바뀐 것으로 보고 복구 관리자로 고쳤다)', + ); + return true; +} + +/** + * 관리자 단단히가 걸렸는지 — 관리자 세션의 실제 권한(GET /api/account 의 permissions — 역할 · 계정 권한을 합친 결과)에 impersonate 가 없고, + * 관리자 로그인이 이 실행(앱 네트워크 안)에서 된다. 그 밖에 [주의] 둘 (setup 은 그 비밀을 모른다 — 걸지 않는다): + * - 관리자의 다른 자격(두 번째 비밀번호 · 앱 비밀번호 · API 키)의 허용 IP 가 앱 네트워크 밖이거나 없다 — 그 자격으로 바깥에서 관리자가 된다 + * - 다른 관리자 역할 계정의 비밀번호에 앱 네트워크 안의 허용 IP 가 없다 + */ +async function checkAdminHardening(admin, cfg, ipState) { + let permissions = null; + for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) { + const res = await request(cfg, '/api/account', { headers: { authorization: admin.auth } }); + let body = null; + if (res.ok) body = await res.json().catch(() => null); + else await res.body?.cancel(); + permissions = Array.isArray(body?.permissions) ? body.permissions : null; + if (permissions && !permissions.includes(ADMIN_DISABLED_PERMISSION)) break; + await sleep(POLL_INTERVAL_MS / 2); + } + if (!permissions) { + problems.push(`관리자 ${cfg.adminAddress} 의 권한(GET /api/account)을 읽지 못해 대리 접근이 빠졌는지 확인하지 못했습니다.`); + } else if (permissions.includes(ADMIN_DISABLED_PERMISSION)) { + problems.push( + `관리자 ${cfg.adminAddress} 에 아직 대리 접근(${ADMIN_DISABLED_PERMISSION}) 권한이 있습니다 — 관리자 비밀번호로 <직원>%<관리자> 로그인이 된다. ` + + '관리 화면 › 역할에서 System Administrator 의 Disabled permissions 를 확인하세요.', + ); + } else { + const ips = ipState?.ready ? ` · 허용 IP ${ipState.ips} 안에서 로그인됨` : ''; + log(`[확인] 관리자 ${cfg.adminAddress} — 대리 접근 권한 없음 (권한 ${permissions.length}개에 ${ADMIN_DISABLED_PERMISSION} 없음)${ips}`); + } + const accounts = await listAll(admin, 'Account', ['emailAddress', 'roles', 'credentials']); + const self = accounts.find((account) => account.emailAddress?.toLowerCase() === cfg.adminAddress); + if (self) { + const primary = passwordKey(self); + const loose = Object.entries(self.credentials ?? {}) + .filter(([key, credential]) => key !== primary && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet)) + .map(([key, credential]) => `${credential['@type'] ?? '?'} ${key}`); + if (loose.length > 0) { + warn( + `관리자 ${cfg.adminAddress} 의 다른 자격(${loose.join(', ')})은 허용 IP 가 앱 네트워크(${cfg.appNetworkSubnet}) 밖이거나 없어 바깥(993 · 465)에서도 관리자로 로그인된다. ` + + `쓰지 않으면 관리 화면 › 계정 › 관리자 › Credentials 에서 지우고, 쓴다면 Allowed IPs 를 ${cfg.appNetworkSubnet} 안으로 두세요 (mail/README.md "관리자 계정 단단히").`, + ); + } + } + const others = accounts + .filter((account) => isAdminAccount(account) && account.emailAddress?.toLowerCase() !== cfg.adminAddress) + .filter((account) => + Object.values(account.credentials ?? {}).some( + (credential) => credential['@type'] === 'Password' && !allowedIpsWithin(credential.allowedIps, cfg.appNetworkSubnet), + ), + ) + .map((account) => account.emailAddress?.toLowerCase()); + if (others.length > 0) { + warn( + `관리자 역할 계정 ${others.join(', ')} 의 비밀번호에 허용 IP 가 없어 바깥(993 · 465)에서도 로그인된다 — 대리 접근은 역할에서 빠졌지만 관리 권한은 그대로다. ` + + `관리 화면 › 계정 › 그 계정 › Credentials 의 Allowed IPs 를 ${cfg.appNetworkSubnet} 로 두거나 쓰지 않는 계정이면 지우세요 (mail/README.md "관리자 계정 단단히").`, + ); + } +} + // ============================================================ // 사내 목록 계정 — 계정 목록(Principal) 읽기 전용 (만들기 · 권한 주기는 복구 관리자로만) // ============================================================ @@ -2879,6 +3143,54 @@ async function ensureUploadQuota(client, cfg) { log(`[변경] ${label} ${formatCount(cfg.uploadQuotaFloor)}바이트 (${floor}) · 전 ${formatCount(quota)} · ${untouched}`); } +/** 시 · 분 → "HH:MM" */ +function formatClock(hour, minute) { + return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`; +} + +/** + * 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각 (TRASH_RETENTION_MS · RETENTION_SCHEDULES_KST 머리말) — 저장만 한다(main 의 리로드로 반영). + * 정리 시각은 메일 컨테이너의 현지 시각으로 넣는다 — 컨테이너의 UTC 차이를 읽지 못하면 UTC 로 본다(compose 는 mail 에 TZ 를 주지 않는다). + * 관리 화면에서 바꾼 값은 되돌린다. 돌려주는 값: 요약 글 + */ +async function ensureDataRetention(client, cfg) { + const fields = ['expungeTrashAfter', ...RETENTION_SCHEDULES_KST.map((item) => item.field)]; + const current = (await call(client, 'x:DataRetention/get', { ids: ['singleton'], properties: fields })).list[0] ?? {}; + const read = mailUtcOffsetMinutes(cfg); + const offset = read ?? 0; + const patch = {}; + const lines = []; + const days = TRASH_RETENTION_MS / 86_400_000; + const trashSame = current.expungeTrashAfter === TRASH_RETENTION_MS; + if (!trashSame) patch.expungeTrashAfter = TRASH_RETENTION_MS; + lines.push( + `${trashSame ? '[유지]' : '[변경]'} 휴지통 · 스팸메일함 자동 비우기 — 들어온 지 ${days}일이 지나면 영구 삭제 (x:DataRetention 의 expungeTrashAfter` + + `${trashSame ? '' : ` · 전 ${current.expungeTrashAfter === null ? '꺼짐(null)' : formatDuration(current.expungeTrashAfter)}`})`, + ); + for (const item of RETENTION_SCHEDULES_KST) { + const local = (((item.hour * 60 + item.minute - KST_OFFSET_MINUTES + offset) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY; + const desired = { '@type': 'Daily', hour: Math.floor(local / 60), minute: local % 60 }; + const same = stableJson(current[item.field]) === stableJson(desired); + if (!same) patch[item.field] = desired; + const was = current[item.field]?.['@type'] === 'Daily' ? formatClock(current[item.field].hour, current[item.field].minute) : stableJson(current[item.field]); + lines.push( + `${same ? '[유지]' : '[변경]'} 엔진 정리 시각 — ${item.label} 매일 KST ${formatClock(item.hour, item.minute)} ` + + `(컨테이너 시각 ${formatClock(desired.hour, desired.minute)}${same ? '' : ` · 전 ${was}`})`, + ); + } + if (Object.keys(patch).length > 0) await setObjects(client, 'DataRetention', { update: { singleton: patch } }); + for (const line of lines) log(line); + if (read === null) { + log(`[참고] 메일 컨테이너의 시간대를 읽지 못해 UTC 로 보고 정리 시각을 넣었습니다 (docker 를 쓸 수 없거나 date 가 없음).`); + } else if (read !== 0) { + log(`[참고] 메일 컨테이너의 현지 시각이 UTC${read > 0 ? '+' : '-'}${formatClock(Math.floor(Math.abs(read) / 60), Math.abs(read) % 60)} 라 그 시각으로 바꿔 넣었습니다.`); + } + if (Object.keys(patch).some((field) => field !== 'expungeTrashAfter')) { + log('[참고] 바뀐 정리 시각은 엔진이 그 작업을 한 번 더 한 뒤(또는 mail 컨테이너가 다시 뜬 뒤 — 밤 백업이 다시 띄운다)부터 걸린다.'); + } + return `들어온 지 ${days}일 · 정리 KST ${RETENTION_SCHEDULES_KST.map((item) => formatClock(item.hour, item.minute)).join(' · ')}`; +} + /** 엔진 DNS(x:DnsResolver)를 사람이 읽는 글로 — @type 과 Custom 의 서버 */ function describeEngineDns(value) { const servers = Object.values(value?.servers ?? {}).map((server) => `${server.address}:${server.port}/${server.protocol}`); @@ -4939,6 +5251,9 @@ async function main() { // 잠금 — 직원의 계정 목록 조회, 공개 443 의 JMAP·관리 API await ensureUserRoleWithoutPrincipals(admin); + // 관리자 단단히 (13단계) — 관리자 역할의 대리 접근(impersonate) 빼기 · 관리자 비밀번호의 허용 IP = 앱 네트워크 (관리자로 된다) + await ensureAdminRoleWithoutImpersonate(admin); + const adminIps = await ensureAdminAllowedIps(admin, cfg); await ensureHttpEndpointPolicy(admin); // 사내 목록 계정 — 계정 목록을 읽는 것은 이 전용 계정뿐이다 (만들기 · 권한 주기는 복구 관리자로만, 없으면 [주의]) const directory = await ensureDirectoryAccount(admin, cfg, domain); @@ -4959,6 +5274,8 @@ async function main() { // 시범 공존 A 의 나머지 — 기존 서버 IP 의 MAIL FROM · 검사 예외 · 연결 한도 예외 · 자동 차단 예외 (꺼져 있으면 setup 이 넣은 것만 뺀다) await ensurePilotInboundTrust(admin, pilot); await ensureUploadQuota(admin, cfg); + // 휴지통 · 스팸메일함 30일 자동 비우기와 엔진 정리 시각(새벽 KST — 13단계, 아래 리로드로 반영) + const retention = await ensureDataRetention(admin, cfg); // 엔진 DNS — 메일 전용 리졸버(운영은 바로 · 개발은 도커 DNS 를 거쳐), Spamhaus 두 항목은 키가 있으면 DQS 로 · 없으면 끈다 (아래 리로드로 반영) const engineDnsChanged = await ensureEngineDns(admin, cfg); const spamhausState = await ensureSpamhausDnsbl(admin, cfg, dnsCheck.spamhaus); @@ -4983,6 +5300,7 @@ async function main() { if (pilot) await checkPilotSenderGuard(cfg); checkEngineDnssec(cfg, engineDnsChanged); await checkEngineWebhook(admin, cfg, webhook); + await checkAdminHardening(admin, cfg, adminIps); const gatewayReachesAdmin = await checkGatewayAdminReach(admin, cfg); const devUsers = []; @@ -5020,7 +5338,9 @@ async function main() { const adminScreen = cfg.isProduction ? '공개 443 에서는 막혀 있다 — SSH 터널로 내부 8080 (mail/README.md "운영 배포")' : `${cfg.publicUrl ?? cfg.adminUrl}/admin/`; - log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 관리 화면 ${adminScreen}`); + const adminIpsSummary = adminIps.ready ? `허용 IP ${adminIps.ips}` : '허용 IP 걸지 않음(위 [주의] · [실패])'; + log(` 관리자 ${cfg.adminAddress} (MAIL_ADMIN_PASSWORD) — 대리 접근 없음 · ${adminIpsSummary} — 관리 화면 ${adminScreen}`); + log(` 휴지통 ${retention} (휴지통 · 스팸메일함 — 엔진이 지운다)`); log(` 게이트웨이 ${cfg.gatewayAddress} (MAIL_GATEWAY_PASSWORD) — 대리 접근 로그인: <직원 주소>%${cfg.gatewayAddress}`); log(` 앱 네트워크 ${cfg.networkName} ${cfg.appNetworkSubnet} — 신뢰 대역 ${cfg.trustedNetworks.join(', ')}`); log( diff --git a/ops/backup/arasystem-backup.sh b/ops/backup/arasystem-backup.sh new file mode 100644 index 0000000..c904ef9 --- /dev/null +++ b/ops/backup/arasystem-backup.sh @@ -0,0 +1,376 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 백업 — 메일 엔진 볼륨 · DB · 업로드 · env 를 restic 저장소(NCP Object Storage)로 (13단계 — mail/README.md "백업") +# +# arasystem-backup.sh 매일 백업 (systemd 타이머 arasystem-backup.timer — 03:30 KST) +# arasystem-backup.sh init 저장소를 처음 만든다 (한 번) +# arasystem-backup.sh ensure-mail-running +# 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다 — systemd 의 ExecStopPost(스크립트가 죽어도)와 +# 부팅 때(arasystem-mail-recover.service — 멈춘 채 서버가 다시 켜져도). 떠 있는 것을 본 뒤에만 표시를 지운다 +# +# 흐름 — 메일 엔진(Stalwart · RocksDB)은 켜 둔 채 파일을 복사하면 일관되지 않는다(공식 문서 — 실행 중 백업 없음): +# 1. DB 를 pg_dump(-Fc, 멈추지 않는다) → pg_restore -l 로 읽히는지 본다. env 파일 둘을 작업 폴더에 복사한다 +# 2. warm — 엔진을 켜 둔 채 restic 으로 한 번 올린다(크게 올릴 것은 여기서 다 올린다 — 이 스냅숏은 복원에 쓰지 않는다). +# 엔진이 도는 동안 RocksDB 가 지운 파일은 읽지 못한다 — restic 의 종료 3(일부를 읽지 못함)은 warm 에서만 받아들인다 +# 3. 엔진을 멈추고(docker stop) final — 같은 경로를 다시 올린다. RocksDB 의 SST · blob 은 한 번 쓰면 바뀌지 않아 바뀐 것만 올라간다. +# 시간 상한(BACKUP_FINAL_TIMEOUT, 기본 15분)을 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다 — 메일이 오래 멈춰 있지 않게 +# 4. 엔진을 다시 띄우고 /healthz/ready 까지 기다린다 → 멈춘 시간을 남긴다 +# 5. 보존(forget) — final 은 매일 7 · 매주 4 · 매달 6, warm 은 마지막 1. 일요일(KST)에 prune 과 check(데이터 5%) +# 6. 상태 파일(15분 점검이 읽는다)과 Healthchecks 에 알린다(성공 · /fail + 까닭 한 줄) +# 받지 않는 것: 대용량 첨부 볼륨(_files — 30일짜리 전송물), 악성 검사 서명(_clamav-db), 리졸버 · frontend(상태 없음), 로그 +# +# 설정 /etc/arasystem/ops.env (예시 ops/ops.env.example) · restic 비밀번호 /etc/arasystem/restic-password. root 로 돈다(docker · 볼륨을 읽는다). +# restic 은 고정한 이미지로 띄운다 — 판을 올릴 때는 docker 스킬 "이미지 버전 고정"의 표와 함께 +# ========================================== +set -Eeuo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" + +# restic 0.19.1(2026-07-05) — manifest list digest 까지 고정한다 (docker 스킬 "이미지 버전 고정") +RESTIC_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510} + +STEP="시작" +# 메일 엔진을 어디까지 다뤘나 — 비었음(건드리지 않음) · stopping(docker stop 을 불렀다) · starting(docker start 를 불렀다). on_exit 가 이것으로 되살린다 +MAIL_PHASE="" +# restic 한 번의 시간 상한 (timeout 의 길이 — 비면 없음). final 에서만 건다 +RESTIC_TIMEOUT="" +SEQ=0 + +# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …] +# root 로 돌되 권한(capability)은 파일 읽기(DAC_READ_SEARCH)만 남긴다 — 메일(UID 2000) · 업로드(UID 1000) 파일을 읽어야 한다. 볼륨은 읽기 전용으로 붙인다. +# 비밀은 -e 이름만 넘긴다(값이 명령줄 · docker 이벤트에 남지 않게). 캐시는 이름 있는 볼륨(-backup-cache)에 둔다. +# CPU 1 · 메모리 1g · CPU 몫 512(다른 컨테이너의 절반 — 켜 둔 메일 엔진을 앞세운다). 스크립트의 Nice · IO 우선(systemd)은 dockerd 아래의 컨테이너가 물려받지 않는다. +# RESTIC_TIMEOUT 이 있으면 timeout 으로 감싼다 — 넘으면 docker 가 restic 에 SIGTERM 을 넘기고, 그래도 남은 컨테이너는 지운다(종료 124). +# --foreground — timeout 이 따로 프로세스 그룹을 만들지 않게. 손으로 돌릴 때 Ctrl-C 가 docker CLI 에 닿아(restic 에 넘긴다) 바로 멈추고 정리로 간다 +# (그룹이 다르면 Ctrl-C 가 상한까지 기다린다). systemd 는 그룹과 상관없이 cgroup 전체에 보낸다 +restic_run() { + local dargs=() + while [ "$1" != "--" ]; do dargs+=("$1"); shift; done + shift + local net=() limit=() rc=0 name="${PROJECT_NAME}-backup-restic-$$-$((SEQ += 1))" + [ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK") + [ -n "$RESTIC_TIMEOUT" ] && limit=(timeout --foreground -k 30 "$RESTIC_TIMEOUT") + # shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다 (-o s3.region=… -o s3.bucket-lookup=…) + "${limit[@]}" docker run --rm --name "$name" \ + --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges \ + --memory 1g --cpus 1 --cpu-shares 512 "${net[@]}" \ + -e RESTIC_REPOSITORY -e RESTIC_PASSWORD_FILE=/run/secrets/restic-password -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ + -v "$RESTIC_PASSWORD_FILE:/run/secrets/restic-password:ro" -v "${PROJECT_NAME}-backup-cache:/root/.cache/restic" \ + "${dargs[@]}" "$RESTIC_IMAGE" ${RESTIC_OPTIONS:-} "$@" || rc=$? + if [ "$rc" = 124 ] || [ "$rc" = 137 ]; then + # docker CLI 가 끊겨도 데몬이 만들던 컨테이너는 뒤늦게 뜰 수 있다 — 몇 초 동안 보이는 대로 지운다 + local _ + for _ in 1 2 3 4 5; do + docker rm -f "$name" >/dev/null 2>&1 || true + sleep 1 + done + return 124 + fi + return "$rc" +} + +write_status() { # write_status <글> + local now + now=$(date +%s) + { + if [ "$1" = ok ]; then + echo "last_success_epoch=$now" + elif [ -f "$WORK_DIR/status" ]; then + grep '^last_success_epoch=' "$WORK_DIR/status" || true + fi + echo "last_run_epoch=$now" + echo "last_result=$1" + echo "last_message=$2" + } > "$WORK_DIR/status.tmp" + mv -f "$WORK_DIR/status.tmp" "$WORK_DIR/status" + chmod 0644 "$WORK_DIR/status" +} + +mail_ready() { # 엔진의 /healthz/ready (이미지의 curl) + docker exec "$MAIL_CONTAINER" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1 +} + +wait_mail_ready() { # MAIL_READY_TIMEOUT 초까지 — 준비되면 0 + local waited=0 + until mail_ready; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt "$MAIL_READY_TIMEOUT" ] || return 1 + done +} + +# 엔진을 다시 띄운다 — 떠 있는 것을 본 뒤에만 표시를 지운다(15분 점검 · ensure-mail-running 이 표시를 본다) +start_mail() { + STEP="메일 엔진 다시 띄우기" + MAIL_PHASE=starting + docker start "$MAIL_CONTAINER" >/dev/null + container_running "$MAIL_CONTAINER" || die "docker start 뒤에도 메일 엔진($MAIL_CONTAINER)이 떠 있지 않습니다" + rm -f "$MARKER" + MAIL_PHASE="" + wait_mail_ready || die "메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)" +} + +# 끊긴 백업이 멈춘 엔진을 다시 띄운다 (on_exit) — 띄웠으면 0. 준비(/healthz/ready)는 여기서 기다리지 않는다(on_exit 가 알린 뒤에 기다린다). +# 멈추는 중이었으면 멈춤이 끝나기를 기다린 뒤에 띄운다 — docker 가 stop 을 처리하는 동안 start 를 부르면 아무 일도 하지 않고(아직 떠 있다), +# 그 뒤 stop 이 끝나 엔진이 멈춘 채 남는다 +restart_stopped_mail() { + log "[복구] 메일 엔진을 다시 띄운다 (끊긴 단계: $MAIL_PHASE)" + if [ "$MAIL_PHASE" = stopping ]; then + docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null 2>&1 || true + fi + if ! docker start "$MAIL_CONTAINER" >/dev/null 2>&1 || ! container_running "$MAIL_CONTAINER"; then + log "[실패] 메일 엔진을 다시 띄우지 못했습니다 — 표시($MARKER)를 둔다. docker start $MAIL_CONTAINER" + return 1 + fi + rm -f "$MARKER" + MAIL_PHASE="" +} + +# 정리 차례 — systemd 가 시간 상한(TimeoutStopSec)으로 끊어도 중요한 것부터 끝나게: +# 1. 비밀이 든 사본(env · DB 덤프)을 지운다 2. 멈춘 엔진을 띄운다(준비는 기다리지 않는다) 3. 실패를 알린다(상태 파일 · Healthchecks /fail) +# 4. 그 뒤에 엔진 준비를 기다린다 — 오래 걸려도 /fail 은 이미 나갔다 +on_exit() { + local rc=$? + # 정리 중에는 다시 끊기지 않게 (systemd 는 SIGTERM 한 번 뒤 TimeoutStopSec 까지 기다린다) + trap '' TERM INT + rm -rf "${WORK_DIR:?}/env" "${WORK_DIR:?}/db" + local restarted=0 mail_note="" + if [ -n "$MAIL_PHASE" ]; then + if restart_stopped_mail; then + restarted=1 + mail_note=" · 메일 엔진은 다시 띄웠다" + else + mail_note=" · ⚠️ 메일 엔진을 다시 띄우지 못했다(ensure-mail-running)" + fi + fi + if [ "$rc" -ne 0 ] && [ "${MODE:-run}" = run ]; then + write_status fail "$STEP" || true + hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 실패 — ${STEP} (종료 코드 ${rc}${mail_note} · journalctl -u arasystem-backup 로 본다)" + fi + if [ "$restarted" = 1 ]; then + if wait_mail_ready; then log "[복구] 메일 엔진 준비됨"; else log "[주의] 메일 엔진이 ${MAIL_READY_TIMEOUT}초 안에 준비되지 않았습니다 (docker logs $MAIL_CONTAINER)"; fi + fi +} + +# 부팅 직후(타이머의 Persistent — 꺼져 있던 동안 놓친 백업을 켜지자마자 한다)에는 엔진 · DB 가 아직 starting 이다 — +# healthy 를 BACKUP_HEALTHY_WAIT 초(기본 300)까지 기다린 뒤 판단한다. 멈췄거나 없으면(exited · missing) 기다리지 않는다 +wait_healthy() { # wait_healthy <컨테이너> — healthy 면 0 + local waited=0 state + while :; do + state=$(container_state "$1") + case $state in + healthy) return 0 ;; + starting | unhealthy | running) [ "$waited" -lt "$BACKUP_HEALTHY_WAIT" ] || return 1 ;; + *) return 1 ;; + esac + [ "$waited" -gt 0 ] || log "[대기] $1 이(가) $state — healthy 를 ${BACKUP_HEALTHY_WAIT}초까지 기다린다" + sleep 5 + waited=$((waited + 5)) + done +} + +# restic backup --json 의 요약 줄에서 값 하나 +summary_field() { sed -n "s/.*\"message_type\":\"summary\".*\"$1\":\"\{0,1\}\([^\",}]*\).*/\1/p" <<< "$2" | tail -n 1; } + +human_bytes() { + awk -v b="${1:-0}" 'BEGIN { split("B KB MB GB TB", u); i = 1; while (b >= 1024 && i < 5) { b /= 1024; i++ } printf (i == 1 ? "%d%s" : "%.1f%s"), b, u[i] }' +} + +prepare() { + load_config + WORK_DIR=$(ops_work_dir) + MARKER="$WORK_DIR/mail-stopped-by-backup" + # 메일 엔진이 SIGTERM 뒤 RocksDB 를 닫고 끝낼 시간 — 넘으면 docker 가 죽인다(그래도 WAL 로 일관되게 다시 뜬다) + MAIL_STOP_TIMEOUT=${MAIL_STOP_TIMEOUT:-60} + # 다시 띄운 엔진이 /healthz/ready 를 줄 때까지 기다리는 시간(초) + MAIL_READY_TIMEOUT=${MAIL_READY_TIMEOUT:-180} + # final(엔진 멈춤) 한 번의 시간 상한 — timeout 의 길이(숫자 + s · m · h) + BACKUP_FINAL_TIMEOUT=${BACKUP_FINAL_TIMEOUT:-15m} + [[ $BACKUP_FINAL_TIMEOUT =~ ^[1-9][0-9]*[smh]?$ ]] || die "$OPS_CONFIG 의 BACKUP_FINAL_TIMEOUT 은 15m 처럼 적는다 (숫자 + s · m · h)" + # 사전 확인에서 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 따라잡는 백업 + BACKUP_HEALTHY_WAIT=${BACKUP_HEALTHY_WAIT:-300} + [[ $BACKUP_HEALTHY_WAIT =~ ^[0-9]+$ ]] || die "$OPS_CONFIG 의 BACKUP_HEALTHY_WAIT 은 초(숫자)로 적는다" + require_set RESTIC_REPOSITORY + case $RESTIC_REPOSITORY in s3:*) require_set AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY ;; esac + RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE:-/etc/arasystem/restic-password} + [ -s "$RESTIC_PASSWORD_FILE" ] || die "restic 비밀번호 파일 $RESTIC_PASSWORD_FILE 이 없거나 비어 있습니다 (ops/ops.env.example 머리말)" + export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + MAIL_CONTAINER=${PROJECT_NAME}-mail + DB_CONTAINER=${PROJECT_NAME}-db + BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME} + BACKUP_ENV_FILES=${BACKUP_ENV_FILES:-"$APP_ENV_FILE $ARASYSTEM_DIR/backend/.env.production"} + mkdir -p "$WORK_DIR" + chmod 0700 "$WORK_DIR" + command -v docker >/dev/null || die "docker 가 없습니다" +} + +cmd_init() { + prepare + log "restic 저장소를 만든다 — ${RESTIC_REPOSITORY%%\?*}" + restic_run -- init + log "[완료] 저장소를 만들었습니다. restic 비밀번호($RESTIC_PASSWORD_FILE)를 서버 밖(비밀번호 관리자)에도 둔다 — 잃으면 백업을 풀 수 없다" +} + +# 표시 파일이 남아 있으면 그 메일 엔진이 떠 있게 한다 — 설정을 다 읽지 않는다(설정이 깨졌어도 돈다). +# 백업과 같은 잠금을 잡는다 — 백업이 돌고 있으면(손으로 불렀을 때) 아무것도 하지 않고 그만둔다: 그 백업이 끝나며 엔진을 띄운다. +# final 도중에 엔진을 띄우면 엔진이 쓰는 RocksDB 를 읽은 스냅숏이 final 로 남아 복원의 latest 가 된다. +# (ExecStopPost 때는 백업 본체가 끝나 잠금이 풀려 있다 · 부팅 유닛은 백업보다 먼저 돈다 — Before=) +# 떠 있는 것을 본 뒤에만 표시를 지운다. 도커에 닿지 못하면 표시를 둔 채 실패한다(15분 점검이 남은 표시를 알린다 · 부팅 유닛은 다시 한다). +# 방금 멈추기 시작한 엔진(표시가 MAIL_STOP_TIMEOUT 보다 새것)은 멈춤이 끝날 때까지 기다린 뒤에 본다 — 아직 떠 보여도 곧 멈춘다 +cmd_ensure_mail_running() { + WORK_DIR=$(ops_work_dir) + local marker="$WORK_DIR/mail-stopped-by-backup" + [ -f "$marker" ] || exit 0 + if ! take_backup_lock "$WORK_DIR"; then + log "[건너뜀] 백업이나 복원 연습이 돌고 있다(잠금) — 백업이면 끝나며 엔진을 스스로 다시 띄운다. 끝난 뒤에도 표시가 남아 있으면 다시 부른다(journalctl -u arasystem-backup)" + exit 0 + fi + # 잠금을 잡는 사이 백업이 끝나 표시를 지웠을 수 있다 + [ -f "$marker" ] || exit 0 + local container stop_timeout age + container=$(cat "$marker") + [[ $container =~ ^[a-z0-9][a-z0-9_-]*-mail$ ]] || die "표시 파일($marker)의 내용이 메일 컨테이너 이름이 아닙니다 — 확인하고 지운다" + stop_timeout=${MAIL_STOP_TIMEOUT:-$(env_get "$OPS_CONFIG" MAIL_STOP_TIMEOUT || true)} + stop_timeout=${stop_timeout:-60} + [[ $stop_timeout =~ ^[0-9]+$ ]] || stop_timeout=60 + age=$(( $(date +%s) - $(stat -c %Y "$marker") )) + [ "$age" -ge 0 ] || age=0 + if [ "$age" -lt $((stop_timeout + 15)) ]; then + log "[대기] 표시가 ${age}초 전 것이다 — 멈춤이 끝날 때까지 $((stop_timeout + 15 - age))초 기다린 뒤 본다" + sleep $((stop_timeout + 15 - age)) + fi + local state=0 + container_running "$container" || state=$? + case $state in + 0) ;; + 1) + log "[복구] 백업이 멈춘 메일 엔진($container)이 남아 있어 다시 띄운다" + docker start "$container" >/dev/null || die "docker start $container 에 실패했습니다 — 표시($marker)를 둔다" + container_running "$container" || die "docker start 뒤에도 $container 가 떠 있지 않습니다 — 표시($marker)를 둔다" + ;; + *) die "도커에 닿지 못했거나 $container 가 없습니다 — 표시($marker)를 둔다" ;; + esac + rm -f "$marker" + log "[확인] $container 가 떠 있다 — 표시를 지웠다" +} + +cmd_run() { + prepare + if ! take_backup_lock "$WORK_DIR"; then + log "[건너뜀] 다른 백업 · 복원 연습이 돌고 있습니다" + hc_ping "${HC_BACKUP_URL:-}" /fail "arasystem-backup 건너뜀 — 다른 백업 · 복원 연습이 돌고 있다(잠금 $WORK_DIR/backup.lock)" + exit 0 + fi + trap on_exit EXIT + # systemd 가 멈추거나(시간 초과 · stop) Ctrl-C 여도 EXIT 정리(멈춘 메일 엔진 다시 띄우기)를 탄다 + trap 'exit 143' TERM + trap 'exit 130' INT + local started rc + started=$(date +%s) + hc_ping "${HC_BACKUP_URL:-}" /start + + STEP="사전 확인" + local volume + for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do + docker volume inspect "$volume" >/dev/null 2>&1 || die "볼륨 $volume 이 없습니다 (PROJECT_NAME 확인)" + done + wait_healthy "$MAIL_CONTAINER" || die "메일 엔진($MAIL_CONTAINER)이 healthy 가 아니라 백업하지 않았습니다 ($(container_state "$MAIL_CONTAINER"))" + wait_healthy "$DB_CONTAINER" || die "DB($DB_CONTAINER)가 healthy 가 아닙니다 ($(container_state "$DB_CONTAINER"))" + STEP="restic 저장소 확인" + restic_run -- cat config >/dev/null || die "restic 저장소에 닿지 못했습니다 — 주소 · 키 · 비밀번호, 처음이면 arasystem-backup init" + # 죽은 restic 이 남긴 오래된 잠금만 지운다 (살아 있는 잠금은 두고) + restic_run -- unlock >/dev/null 2>&1 || true + + STEP="DB 덤프" + rm -rf "${WORK_DIR:?}/db" "${WORK_DIR:?}/env" + mkdir -p "$WORK_DIR/db" "$WORK_DIR/env" + # shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다(비밀번호 없이 소켓으로) + docker exec "$DB_CONTAINER" sh -c 'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$WORK_DIR/db/db.dump.tmp" + docker exec -i "$DB_CONTAINER" pg_restore -l < "$WORK_DIR/db/db.dump.tmp" > /dev/null || die "DB 덤프를 pg_restore 가 읽지 못했습니다" + mv -f "$WORK_DIR/db/db.dump.tmp" "$WORK_DIR/db/db.dump" + local dump_bytes + dump_bytes=$(wc -c < "$WORK_DIR/db/db.dump") + STEP="env 파일 복사" + # 저장소 기준 경로의 / 를 __ 로 — .env.production · backend__.env.production (복원 때 제자리를 안다) + local file name + for file in $BACKUP_ENV_FILES; do + [ -r "$file" ] || die "env 파일 $file 을 읽을 수 없습니다" + name=${file#"$ARASYSTEM_DIR"/} + cp -f "$file" "$WORK_DIR/env/${name//\//__}" + done + + local mounts=( + -v "${PROJECT_NAME}_mail-config:/backup/mail-config:ro" -v "${PROJECT_NAME}_mail-data:/backup/mail-data:ro" + -v "${PROJECT_NAME}_uploads:/backup/uploads:ro" -v "$WORK_DIR/db:/backup/db:ro" -v "$WORK_DIR/env:/backup/env:ro" + ) + local paths=(/backup/mail-config /backup/mail-data /backup/uploads /backup/db /backup/env) + + STEP="warm(켜 둔 채 미리 올리기)" + log "[1/4] warm — 메일 엔진을 켜 둔 채 올린다" + rc=0 + restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag warm --quiet "${paths[@]}" || rc=$? + case $rc in + 0) ;; + # 켜 둔 RocksDB 가 그사이 지운 파일(압축으로 사라진 SST 등)은 읽지 못한다 — warm 은 미리 올리기라 괜찮다. final 은 멈춘 엔진에서 다시 다 읽는다 + 3) log "[참고] warm — 도는 엔진이 그사이 지운 파일을 읽지 못했다(restic 종료 3). final 이 멈춘 엔진에서 다시 받는다" ;; + *) die "warm 의 restic 이 종료 $rc 로 끝났습니다" ;; + esac + + STEP="final(메일 엔진 멈춤)" + log "[2/4] final — 메일 엔진을 멈추고 바뀐 것만 올린다 (상한 $BACKUP_FINAL_TIMEOUT)" + local stopped_at down out="" + stopped_at=$(date +%s) + printf '%s\n' "$MAIL_CONTAINER" > "$MARKER" + MAIL_PHASE=stopping + docker stop -t "$MAIL_STOP_TIMEOUT" "$MAIL_CONTAINER" >/dev/null + rc=0 + RESTIC_TIMEOUT=$BACKUP_FINAL_TIMEOUT + out=$(restic_run "${mounts[@]}" -- backup --host "$BACKUP_HOST" --tag final --json "${paths[@]}") || rc=$? + RESTIC_TIMEOUT="" + case $rc in + 0) ;; + 124) die "final 이 시간 상한($BACKUP_FINAL_TIMEOUT)을 넘어 멈췄습니다 — 메일 엔진을 다시 띄운다 (바깥 대역폭 · NCP · BACKUP_FINAL_TIMEOUT)" ;; + *) die "final 의 restic 이 종료 $rc 로 끝났습니다 (3 이면 멈춘 엔진의 파일을 읽지 못한 것 — 받지 않는다)" ;; + esac + start_mail + down=$(( $(date +%s) - stopped_at )) + local snapshot added + snapshot=$(summary_field snapshot_id "$out") + added=$(summary_field data_added "$out") + [ -n "$snapshot" ] || die "final 스냅숏 id 를 읽지 못했습니다" + log "[3/4] 메일 엔진 다시 준비됨 — 멈춘 시간 ${down}초 · final 스냅숏 ${snapshot:0:8} · 새로 올린 양 $(human_bytes "$added")" + + STEP="보존 정리(forget)" + log "[4/4] 보존 — 매일 ${BACKUP_KEEP_DAILY:-7} · 매주 ${BACKUP_KEEP_WEEKLY:-4} · 매달 ${BACKUP_KEEP_MONTHLY:-6}" + restic_run -- forget --host "$BACKUP_HOST" --tag final --keep-daily "${BACKUP_KEEP_DAILY:-7}" --keep-weekly "${BACKUP_KEEP_WEEKLY:-4}" --keep-monthly "${BACKUP_KEEP_MONTHLY:-6}" --quiet + restic_run -- forget --host "$BACKUP_HOST" --tag warm --keep-last 1 --quiet + # 요일은 한국 시각으로 — 서버의 시간대가 UTC 여도 03:30 KST(전날 18:30 UTC)가 토요일로 읽히지 않게 + if [ "$(TZ=Asia/Seoul date +%u)" = "${BACKUP_PRUNE_WEEKDAY:-7}" ] || [ "${BACKUP_FORCE_PRUNE:-0}" = 1 ]; then + STEP="prune · check" + log "[주간] prune · check(데이터 ${BACKUP_CHECK_SUBSET:-5%})" + restic_run -- prune --quiet + restic_run -- check --read-data-subset="${BACKUP_CHECK_SUBSET:-5%}" --quiet + fi + + local summary + summary="arasystem-backup 성공 — 스냅숏 ${snapshot:0:8} · 메일 엔진 멈춤 ${down}초 · 새로 올림 $(human_bytes "$added") · DB 덤프 $(human_bytes "$dump_bytes") · 전체 $(( $(date +%s) - started ))초" + write_status ok "$summary" + log "[완료] $summary" + hc_ping "${HC_BACKUP_URL:-}" "" "$summary" +} + +main() { + MODE=${1:-run} + case $MODE in + run) cmd_run ;; + init) cmd_init ;; + ensure-mail-running) cmd_ensure_mail_running ;; + *) echo "사용법: $0 [run|init|ensure-mail-running]" >&2; exit 2 ;; + esac +} + +# source 하면(시험) 함수만 읽는다 +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi diff --git a/ops/backup/arasystem-restore.sh b/ops/backup/arasystem-restore.sh new file mode 100644 index 0000000..24ac836 --- /dev/null +++ b/ops/backup/arasystem-restore.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 복원 · 복원 연습 (13단계 — mail/README.md "백업" · "복원") +# +# arasystem-restore.sh snapshots final 스냅숏 목록 (복원에 쓰는 것 — warm 은 쓰지 않는다) +# arasystem-restore.sh check [5%|100%] 저장소 검사 — 데이터를 그만큼 내려받아 본다 (기본 5%) +# arasystem-restore.sh drill [스냅숏|latest] [--keep] +# 복원 연습 — 운영을 건드리지 않는다. 임시 볼륨에 풀고, 네트워크 없이(--network none) +# 같은 판의 메일 엔진 · DB 를 띄워 계정 · 메일 수 · DB 행 수 · 업로드 · env 를 지금 운영과 견준 뒤 지운다(--keep 이면 남긴다). +# 백업과 같은 잠금을 잡는다 — 함께 돌지 않는다 +# arasystem-restore.sh drill-clean 남은 연습 자원(-drill*)을 지운다 +# arasystem-restore.sh files <스냅숏> <폴더> DB 덤프 · env 파일만 폴더로 꺼낸다 (새 서버 — env 를 제자리에 둘 때) +# arasystem-restore.sh volumes <스냅숏> 메일 엔진 · 업로드 볼륨을 되살린다 (새 서버 — 볼륨이 없거나 비었고, 쓰는 컨테이너가 멈춰 있을 때만) +# arasystem-restore.sh db <덤프 파일> 빈 DB 에 덤프를 넣는다 (db 만 띄우고 backend 를 띄우기 전에 — 표가 있으면 멈춘다) +# +# 설정은 백업과 같다 (/etc/arasystem/ops.env · restic-password). root 로 돈다. +# snapshots · check · files 는 저장소의 .env.production 없이 돈다 — 새 서버 복원의 첫 단계가 그 파일을 꺼낸다. 그때 PROJECT_NAME(스냅숏의 host 이름)은 +# ops.env 의 값이다(옛 서버의 값을 적는다). volumes · db · drill 은 .env.production 이 있어야 한다(볼륨 · 컨테이너 이름이 그 PROJECT_NAME 이다) +# 스냅숏은 final 만 받는다 — id 를 직접 줘도 태그를 본다(warm 은 켜 둔 엔진에서 받아 RocksDB 가 일관되지 않을 수 있다) +# ⚠️ 연습의 메일 엔진은 운영 설정(호스트 이름 · 인증서 · 발송 대기열 · 웹훅)을 그대로 안고 뜬다 — 그래서 네트워크 없이(루프백뿐) 띄운다 +# (대기열의 메일을 다시 보내거나 인증서를 새로 받으러 나가지 않게). 포트도 열지 않는다. 세는 쪽(백엔드 이미지의 node)은 그 엔진의 네트워크 이름공간을 함께 쓴다 +# (--network container:…). 도커의 주소 풀이 바닥나도(all predefined address pools have been fully subnetted) 연습이 멈추지 않는다 +# 보조 컨테이너는 모두 권한을 버리고(--cap-drop ALL · no-new-privileges) 꼭 필요한 것만 더한다 — 볼륨 들여다보기는 파일 읽기(DAC_READ_SEARCH)만, +# 복원(restic)은 쓰기 · 소유자 되살리기, 연습 DB 는 postgres 의 시작 스크립트가 쓰는 것(소유자 바꾸기 · 사용자 바꾸기) +# ========================================== +set -Eeuo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" + +RESTIC_IMAGE=${RESTIC_IMAGE:-restic/restic:0.19.1@sha256:136600b6ff6843d61d355f7f71f460a166429f35de6fd11b568fece3c9a4d510} +SEQ=0 +# 볼륨을 읽기만 하는 보조 컨테이너 (restic 이미지의 sh) +PROBE=(--rm --network none --cap-drop ALL --cap-add DAC_READ_SEARCH --security-opt no-new-privileges) + +prepare() { # prepare [optional] — optional 이면 .env.production 이 없어도 된다 (snapshots · check · files) + load_config "${1:-required}" + require_set RESTIC_REPOSITORY + case $RESTIC_REPOSITORY in s3:*) require_set AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY ;; esac + RESTIC_PASSWORD_FILE=${RESTIC_PASSWORD_FILE:-/etc/arasystem/restic-password} + [ -s "$RESTIC_PASSWORD_FILE" ] || die "restic 비밀번호 파일 $RESTIC_PASSWORD_FILE 이 없거나 비어 있습니다" + export RESTIC_REPOSITORY AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY + BACKUP_HOST=${BACKUP_HOST:-$PROJECT_NAME} + DRILL=${PROJECT_NAME}-drill + WORK_DIR=$(ops_work_dir) +} + +# restic 을 컨테이너로 — restic_run [docker 인자 …] -- [restic 인자 …]. 복원은 소유자(메일 UID 2000 · 업로드 UID 1000)를 되살려야 해 +# 파일 쓰기 · 소유자 바꾸기 권한을 더한다(백업은 읽기만) +restic_run() { + local dargs=() + while [ "$1" != "--" ]; do dargs+=("$1"); shift; done + shift + local net=() + [ -n "${BACKUP_DOCKER_NETWORK:-}" ] && net=(--network "$BACKUP_DOCKER_NETWORK") + # shellcheck disable=SC2086 # RESTIC_OPTIONS 는 낱말로 나눠 넘긴다 + docker run --rm --name "${PROJECT_NAME}-restore-restic-$$-$((SEQ += 1))" \ + --cap-drop ALL --cap-add DAC_READ_SEARCH --cap-add DAC_OVERRIDE --cap-add CHOWN --cap-add FOWNER --security-opt no-new-privileges \ + --memory 1g "${net[@]}" \ + -e RESTIC_REPOSITORY -e RESTIC_PASSWORD_FILE=/run/secrets/restic-password -e AWS_ACCESS_KEY_ID -e AWS_SECRET_ACCESS_KEY \ + -v "$RESTIC_PASSWORD_FILE:/run/secrets/restic-password:ro" -v "${PROJECT_NAME}-backup-cache:/root/.cache/restic" \ + "${dargs[@]}" "$RESTIC_IMAGE" ${RESTIC_OPTIONS:-} "$@" +} + +# 스냅숏 이름 → 64자 id (latest 는 이 서버의 마지막 final). id 를 직접 주면 final 인지 본다 +resolve_snapshot() { + local want=${1:-latest} json id + if [ "$want" = latest ]; then + json=$(restic_run -- snapshots --json --host "$BACKUP_HOST" --tag final --latest 1) + else + [[ $want =~ ^[0-9a-f]{8,64}$ ]] || die "스냅숏은 id(8자 이상) 또는 latest 입니다" + json=$(restic_run -- snapshots --json "$want") + fi + id=$(grep -o '"id":"[0-9a-f]\{64\}"' <<< "$json" | tail -n 1 | cut -d'"' -f4) + [ -n "$id" ] || die "스냅숏을 찾지 못했습니다 ($want · host $BACKUP_HOST · 태그 final — 새 서버면 ops.env 의 PROJECT_NAME 이 옛 서버의 값인지 본다)" + grep -q '"tags":\[[^]]*"final"' <<< "$json" || + die "스냅숏 ${id:0:8} 은 final 이 아닙니다(warm 등 — 켜 둔 엔진에서 받은 것) — 복원에는 final 만 쓴다 ($0 snapshots)" + printf '%s' "$id" +} + +# 볼륨이 없거나 비었으면 0 · 무엇이 있으면 1. 들여다보지 못하면(도커 · 이미지 오류) 멈춘다 — 실패를 "비었다"로 보고 덮어쓰지 않게 +volume_empty() { + docker volume inspect "$1" >/dev/null 2>&1 || return 0 + local out rc=0 + out=$(docker run "${PROBE[@]}" -v "$1:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v') || rc=$? + [ "$rc" = 0 ] || die "볼륨 $1 을 들여다보지 못했습니다 (docker 종료 $rc) — 비었는지 모르므로 멈춘다" + [ -z "$out" ] +} + +volume_in_use() { # 그 볼륨을 붙인 컨테이너가 떠 있으면 이름 + docker ps --filter "volume=$1" --format '{{.Names}}' | head -n 1 +} + +cmd_snapshots() { + prepare optional + restic_run -- snapshots --host "$BACKUP_HOST" --tag final +} + +cmd_check() { + prepare optional + local subset=${1:-5%} + [[ $subset =~ ^[0-9]{1,3}%$ ]] || die "check 의 범위는 5% 처럼 적는다" + log "저장소 검사 — 데이터 ${subset}" + restic_run -- check --read-data-subset="$subset" +} + +cmd_files() { + prepare optional + [ $# -eq 2 ] || die "사용법: $0 files <스냅숏> <폴더>" + local id dir + id=$(resolve_snapshot "$1") + dir=$2 + if [ -e "$dir" ] && [ -n "$(ls -A "$dir")" ]; then die "$dir 가 비어 있지 않습니다"; fi + mkdir -p "$dir" + chmod 0700 "$dir" + # docker -v 는 절대 경로만 받는다 (C:/… 는 Git Bash 시험용) + case $dir in /* | [A-Za-z]:/*) ;; *) dir="$PWD/$dir" ;; esac + log "스냅숏 ${id:0:8} 의 DB 덤프 · env 파일을 $dir 로" + restic_run -v "$dir:/restore" -- restore "$id" --target /restore --include /backup/db --include /backup/env + log "[완료] $dir/backup/db/db.dump · $dir/backup/env/ (backend__.env.production 은 backend/.env.production 자리) — 비밀이 들어 있다, 쓴 뒤 지운다" +} + +cmd_volumes() { + prepare + [ $# -eq 1 ] || die "사용법: $0 volumes <스냅숏>" + local id volume + id=$(resolve_snapshot "$1") + for volume in "${PROJECT_NAME}_mail-config" "${PROJECT_NAME}_mail-data" "${PROJECT_NAME}_uploads"; do + [ -z "$(volume_in_use "$volume")" ] || die "$volume 을 쓰는 컨테이너($(volume_in_use "$volume"))가 떠 있습니다 — 멈춘 뒤에" + volume_empty "$volume" || die "$volume 이 비어 있지 않습니다 — 되살리려는 서버가 맞는지 확인하고, 맞으면 그 볼륨을 직접 지운 뒤(docker volume rm $volume) 다시 실행한다" + done + # 없는 볼륨은 compose 의 이름표(프로젝트 · 볼륨 이름)를 붙여 만든다 — 그냥 만들어도 compose 가 그 볼륨을 쓰지만 + # up 마다 "already exists but was not created by Docker Compose" 경고를 낸다 (compose 2.40 — 2026-11-02 확인) + for volume in mail-config mail-data uploads; do + docker volume inspect "${PROJECT_NAME}_${volume}" >/dev/null 2>&1 || + docker volume create --label "com.docker.compose.project=${PROJECT_NAME}" --label "com.docker.compose.volume=${volume}" "${PROJECT_NAME}_${volume}" >/dev/null + done + log "스냅숏 ${id:0:8} → 볼륨 ${PROJECT_NAME}_mail-config · _mail-data · _uploads" + local started + started=$(date +%s) + restic_run -v "${PROJECT_NAME}_mail-config:/backup/mail-config" -v "${PROJECT_NAME}_mail-data:/backup/mail-data" -v "${PROJECT_NAME}_uploads:/backup/uploads" \ + -- restore "$id" --target / --include /backup/mail-config --include /backup/mail-data --include /backup/uploads + log "[완료] $(( $(date +%s) - started ))초 — 다음은 compose up (mail/README.md \"복원\")" +} + +cmd_db() { + load_config + [ $# -eq 1 ] && [ -r "$1" ] || die "사용법: $0 db <덤프 파일>" + local db=${PROJECT_NAME}-db tables + [ "$(container_state "$db")" = healthy ] || die "DB($db)가 healthy 가 아닙니다 — db 만 먼저 띄운다 (compose up -d db)" + # shellcheck disable=SC2016 # $POSTGRES_* 는 컨테이너 안에서 푼다 + tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"') + [ "$tables" = 0 ] || die "DB 에 표가 이미 $tables 개 있습니다 — 덤프는 빈 DB 에만 넣는다 (backend 가 먼저 떠 마이그레이션을 돌렸다면 db 볼륨을 새로 만들고 db 만 띄운 뒤)" + log "덤프를 $db 에 넣는다 (표준 입력으로)" + # shellcheck disable=SC2016 + docker exec -i "$db" sh -c 'exec pg_restore --no-owner --exit-on-error -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$1" + # shellcheck disable=SC2016 + tables=$(docker exec "$db" sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "select count(*) from information_schema.tables where table_schema = '\''public'\''"') + log "[완료] 표 ${tables}개 — 다음은 compose up -d (backend 는 마이그레이션이 이미 적용된 것을 보고 그대로 뜬다)" +} + +drill_resources() { + docker ps -aq --filter "name=^${DRILL}-" || true +} + +cmd_drill_clean() { + load_config + DRILL=${PROJECT_NAME}-drill + local ids + ids=$(drill_resources) + # shellcheck disable=SC2086 + [ -z "$ids" ] || docker rm -f $ids >/dev/null + local volume + for volume in mail-config mail-data uploads db env; do + docker volume rm "${DRILL}_${volume}" >/dev/null 2>&1 || true + done + log "[정리] 연습 자원(${DRILL}*)을 지웠습니다" +} + +# 표마다 행 수 — "표|행" 줄들. 표마다 count(*) 문을 만들어(첫 psql) 그대로 다시 넣는다(둘째 psql — 표준 입력) +table_counts() { # table_counts <컨테이너> <사용자> + local sql="select format('select %L || ''|'' || count(*) from %I.%I;', table_name, table_schema, table_name) from information_schema.tables where table_schema = 'public' and table_type = 'BASE TABLE' order by table_name" + docker exec "$1" psql -U "$2" -d "$3" -At -c "$sql" | docker exec -i "$1" psql -U "$2" -d "$3" -At +} + +cmd_drill() { + prepare + local want=latest keep=0 arg + for arg in "$@"; do + case $arg in --keep) keep=1 ;; *) want=$arg ;; esac + done + mkdir -p "$WORK_DIR" + chmod 0700 "$WORK_DIR" + take_backup_lock "$WORK_DIR" || die "백업이나 다른 복원 연습이 돌고 있습니다 — 끝난 뒤에 (journalctl -u arasystem-backup)" + [ -z "$(drill_resources)" ] || die "지난 연습이 남아 있습니다 — $0 drill-clean" + local mail_image db_image backend_image + mail_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-mail" 2>/dev/null || env_get "$APP_ENV_FILE" MAIL_IMAGE || true) + db_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-db" 2>/dev/null || env_get "$APP_ENV_FILE" DB_IMAGE || true) + backend_image=$(docker inspect -f '{{.Config.Image}}' "${PROJECT_NAME}-backend" 2>/dev/null || true) + [ -n "$mail_image" ] && [ -n "$db_image" ] || die "메일 · DB 이미지를 알 수 없습니다" + local id t0 t_restore t_ready + id=$(resolve_snapshot "$want") + [ "$keep" = 1 ] || trap cmd_drill_clean EXIT + log "복원 연습 — 스냅숏 ${id:0:8} · 메일 $mail_image · DB $db_image" + + # 네트워크를 만들지 않는다 — 연습 엔진은 --network none(루프백뿐 — 바깥으로 한 걸음도 나가지 않는다), 세는 쪽은 그 네트워크 이름공간을 함께 쓴다 + t0=$(date +%s) + restic_run -v "${DRILL}_mail-config:/backup/mail-config" -v "${DRILL}_mail-data:/backup/mail-data" -v "${DRILL}_uploads:/backup/uploads" \ + -v "${DRILL}_db:/backup/db" -v "${DRILL}_env:/backup/env" -- restore "$id" --target / >/dev/null + t_restore=$(( $(date +%s) - t0 )) + log "[1] 풀기 ${t_restore}초" + + # 메일 엔진 — 복구 관리자(연습마다 무작위 — 출력하지 않고, 명령줄에도 싣지 않는다 — -e 이름만)로 계정 · 메일을 센다. + # 운영 관리자 · 게이트웨이는 허용 IP(앱 네트워크)라 여기서는 쓰지 못한다. 엔진 이미지는 stalwart 사용자로 돈다 — 다만 실행 파일에 + # 낮은 포트용 파일 권한(cap_net_bind_service=ep)이 붙어 있어 그 권한이 없으면 exec 가 거절된다(operation not permitted — 2026-11-02 확인). 그것만 둔다 + local recovery + recovery=$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n') + STALWART_RECOVERY_ADMIN="drill:$recovery" docker run -d --name "${DRILL}-mail" --network none --memory 1g \ + --cap-drop ALL --cap-add NET_BIND_SERVICE --security-opt no-new-privileges \ + -e STALWART_RECOVERY_ADMIN -v "${DRILL}_mail-config:/etc/stalwart" -v "${DRILL}_mail-data:/var/lib/stalwart" "$mail_image" >/dev/null + local waited=0 + until docker exec "${DRILL}-mail" curl -fsS -m 3 -o /dev/null http://127.0.0.1:8080/healthz/ready >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt 180 ] || die "연습 메일 엔진이 180초 안에 준비되지 않았습니다 (docker logs ${DRILL}-mail)" + done + t_ready=$waited + log "[2] 메일 엔진 준비 ${t_ready}초 (RocksDB 를 열었다)" + if [ -n "$backend_image" ]; then + # 백엔드 이미지의 node(node 사용자)로 센다 (fetch) — 연습 네트워크 안에서. 직원마다 메일 수를 대리 접근(<주소>%drill)으로 + R="$recovery" docker run --rm --network "container:${DRILL}-mail" --memory 512m --cap-drop ALL --security-opt no-new-privileges \ + -e R -e H=127.0.0.1 -e CHECK="${RESTORE_CHECK_ACCOUNT:-}" --entrypoint node "$backend_image" -e ' + const base = `http://${process.env.H}:8080`, b = (u) => "Basic " + Buffer.from(u + ":" + process.env.R).toString("base64") + const call = async (auth, using, calls) => (await (await fetch(base + "/jmap/", { method: "POST", headers: { authorization: auth, "content-type": "application/json" }, body: JSON.stringify({ using, methodCalls: calls }) })).json()).methodResponses + ;(async () => { + const s = await (await fetch(base + "/jmap/session", { headers: { authorization: b("drill") } })).json() + const x = s.primaryAccounts["urn:stalwart:jmap"], X = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"] + const [[, q]] = await call(b("drill"), X, [["x:Account/query", { accountId: x, limit: 1000 }, "q"]]) + const [[, g]] = await call(b("drill"), X, [["x:Account/get", { accountId: x, ids: q.ids, properties: ["name", "emailAddress", "@type"] }, "g"]]) + const [[, d]] = await call(b("drill"), X, [["x:Domain/get", { accountId: x, ids: null, properties: ["name"] }, "d"]]) + let total = 0, withMail = 0; const rows = [] + for (const a of g.list.filter((a) => a["@type"] === "User" && a.emailAddress)) { + const r = await fetch(base + "/jmap/session", { headers: { authorization: b(a.emailAddress + "%drill") } }) + if (r.status !== 200) continue + const m = (await r.json()).primaryAccounts["urn:ietf:params:jmap:mail"]; if (!m) continue + const res = await call(b(a.emailAddress + "%drill"), ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail"], [["Email/query", { accountId: m, calculateTotal: true, limit: 1 }, "e"], ["Mailbox/get", { accountId: m, ids: null, properties: ["name", "totalEmails"] }, "b"]]) + const n = res[0][1].total ?? 0; total += n; if (n > 0) withMail += 1 + rows.push(a.emailAddress.split("@")[0] + " " + n) + if (process.env.CHECK && a.emailAddress.toLowerCase() === process.env.CHECK.toLowerCase()) console.log(" 확인 계정 " + a.emailAddress + " — " + res[1][1].list.map((x) => x.name + " " + x.totalEmails).join(" · ")) + } + console.log(" 도메인 " + d.list.map((x) => x.name).join(", ") + " · 계정 " + g.list.length + "개(직원 꼴 " + rows.length + ") · 메일 " + total + "통(메일이 있는 계정 " + withMail + ")") + console.log(" 계정마다 메일 수: " + rows.join(" · ")) + })().catch((e) => { console.log(" [실패] " + e.message); process.exitCode = 1 })' \ + || log "[주의] 연습 메일 엔진의 계정 · 메일 수를 세지 못했습니다" + else + log "[건너뜀] 백엔드 이미지가 없어 계정 · 메일 수는 세지 않았다 (엔진이 RocksDB 를 연 것까지 확인)" + fi + + # DB — 같은 이미지의 임시 postgres(네트워크 없음)에 덤프를 넣고 표마다 행 수를 지금 운영과 견준다. + # postgres 의 시작 스크립트는 root 로 데이터 폴더의 소유자를 바꾼 뒤 postgres 사용자로 내려간다 — 그 권한만 더한다 + local db_pass + db_pass=$(head -c 18 /dev/urandom | od -An -tx1 | tr -d ' \n') + POSTGRES_PASSWORD=$db_pass docker run -d --name "${DRILL}-db" --network none --memory 512m \ + --cap-drop ALL --cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER --cap-add SETUID --cap-add SETGID --security-opt no-new-privileges \ + -e POSTGRES_PASSWORD -e POSTGRES_USER=drill -e POSTGRES_DB=drill -v "${DRILL}_db:/restore:ro" "$db_image" >/dev/null + waited=0 + until docker exec "${DRILL}-db" pg_isready -U drill -d drill >/dev/null 2>&1 && docker exec "${DRILL}-db" psql -U drill -d drill -Atc 'select 1' >/dev/null 2>&1; do + sleep 1 + waited=$((waited + 1)) + [ "$waited" -lt 120 ] || die "연습 DB 가 뜨지 않았습니다 (docker logs ${DRILL}-db)" + done + docker exec "${DRILL}-db" pg_restore --no-owner --no-privileges --exit-on-error -U drill -d drill /restore/db.dump + local restored live="" + restored=$(table_counts "${DRILL}-db" drill drill) + if [ "$(container_state "${PROJECT_NAME}-db")" = healthy ]; then + live=$(table_counts "${PROJECT_NAME}-db" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_USER)" "$(docker exec "${PROJECT_NAME}-db" printenv POSTGRES_DB)" || true) + fi + log "[3] DB — 표 $(grep -c . <<< "$restored")개 (표 · 백업 · 지금)" + local table count now + while IFS='|' read -r table count; do + [ -n "$table" ] || continue + now=$(grep "^${table}|" <<< "$live" | cut -d'|' -f2 || true) + printf ' %-34s %8s %8s\n' "$table" "$count" "${now:--}" + done <<< "$restored" + + # 업로드 · env — 파일 수 · 크기, env 는 지금 파일과 같은지(sha256 — 값은 보지 않는다) + local up_restored up_live + up_restored=$(docker run "${PROBE[@]}" -v "${DRILL}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"') + up_live=$(docker run "${PROBE[@]}" -v "${PROJECT_NAME}_uploads:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'echo "$(find /v -type f | wc -l)개 $(du -sh /v | cut -f1)"' 2>/dev/null || echo -) + log "[4] 업로드 — 백업 $up_restored · 지금 $up_live" + local env_report file name + env_report="" + for file in $(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sh "$RESTIC_IMAGE" -c 'ls -A /v'); do + name=${file//__//} + if [ -r "$ARASYSTEM_DIR/$name" ] && [ "$(docker run "${PROBE[@]}" -v "${DRILL}_env:/v:ro" --entrypoint sha256sum "$RESTIC_IMAGE" "/v/$file" | cut -d' ' -f1)" = "$(sha256sum "$ARASYSTEM_DIR/$name" | cut -d' ' -f1)" ]; then + env_report+=" $name(지금과 같음)" + else + env_report+=" $name(지금과 다름 · 또는 지금 없음)" + fi + done + log "[5] env —${env_report:- 없음}" + log "[완료] 복원 연습 — 스냅숏 ${id:0:8} · 풀기 ${t_restore}초 · 메일 엔진 준비 ${t_ready}초$( [ "$keep" = 1 ] && printf ' · 남겨 둠(%s drill-clean 으로 지운다)' "$0")" +} + +main() { + case ${1:-} in + snapshots) shift; cmd_snapshots "$@" ;; + check) shift; cmd_check "$@" ;; + drill) shift; cmd_drill "$@" ;; + drill-clean) shift; cmd_drill_clean ;; + files) shift; cmd_files "$@" ;; + volumes) shift; cmd_volumes "$@" ;; + db) shift; cmd_db "$@" ;; + *) sed -n '3,14p' "$0" >&2; exit 2 ;; + esac +} + +# source 하면(시험) 함수만 읽는다 +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; fi diff --git a/ops/lib/common.sh b/ops/lib/common.sh new file mode 100644 index 0000000..2c027c1 --- /dev/null +++ b/ops/lib/common.sh @@ -0,0 +1,141 @@ +# shellcheck shell=bash +# ========================================== +# 운영 스크립트 공용 함수 (백업 · 복원 · 15분 점검이 source 한다 — 이 파일만 따로 실행하지 않는다) +# +# 설정은 /etc/arasystem/ops.env(예시 ops/ops.env.example)와 저장소의 .env.production 에서 읽는다 — 셸로 실행(source)하지 않고 줄마다 읽는다 +# (값에 $ · 따옴표 · 공백이 있어도 명령이 되지 않게). 비밀(비밀번호 · 키)은 출력 · 로그 · 알림 어디에도 싣지 않는다. +# ========================================== + +# Git Bash(Windows)에서 시험할 때만 뜻이 있다 — docker 인자의 컨테이너 경로(/backup/…)를 Windows 경로로 바꾸지 않게. 리눅스에서는 아무 일도 하지 않는다 +export MSYS_NO_PATHCONV=1 + +OPS_CONFIG=${ARASYSTEM_OPS_CONFIG:-/etc/arasystem/ops.env} + +log() { printf '%s %s\n' "$(date '+%Y-%m-%dT%H:%M:%S%z')" "$*"; } +die() { log "[실패] $*" >&2; exit 1; } + +trim() { + local value=$1 + value=${value#"${value%%[![:space:]]*}"} + value=${value%"${value##*[![:space:]]}"} + printf '%s' "$value" +} + +# KEY=값 한 줄의 값 — Node 의 --env-file 과 같게 읽는다(따옴표로 감싸지 않은 값은 # 부터 주석) +parse_env_value() { + local value + value=$(trim "$1") + case $value in + \"*\") value=${value#\"}; value=${value%\"} ;; + \'*\') value=${value#\'}; value=${value%\'} ;; + *) value=$(trim "${value%%#*}") ;; + esac + printf '%s' "$value" +} + +# env_get <파일> — 마지막 정의의 값(없으면 빈 글, 종료 코드 1). KEY 는 이 스크립트들이 정한 이름뿐이다(영문 대문자 · 숫자 · _) +env_get() { + local line + line=$(grep -E "^[[:space:]]*(export[[:space:]]+)?$2[[:space:]]*=" "$1" 2>/dev/null | tail -n 1) || true + [ -n "$line" ] || return 1 + parse_env_value "${line#*=}" +} + +# /etc/arasystem/ops.env 를 읽어 KEY 마다 환경 변수로 — 이미 셸에 있는 값은 덮어쓰지 않는다(시험에서 하나만 바꿔 돌릴 수 있게) +# load_config 저장소의 .env.production 이 있어야 한다 (백업 · 점검 · 볼륨 · DB 복원) +# load_config optional 없어도 된다 — 새 서버 복원의 첫 단계(restore.sh files · snapshots · check)는 바로 그 파일을 백업에서 꺼낸다. +# 그때 PROJECT_NAME(백업 스냅숏의 host 이름 · 볼륨 이름)은 ops.env 의 값이다 +# PROJECT_NAME 이 ops.env 와 .env.production 에 둘 다 있고 다르면 멈춘다 — 다른 이름의 볼륨을 받거나 되살리지 않게 +load_config() { + local app_env=${1:-required} + [ -r "$OPS_CONFIG" ] || die "설정 파일 $OPS_CONFIG 를 읽을 수 없습니다 — ops/ops.env.example 을 복사해 채운다 (mail/README.md \"백업\")" + local line key + while IFS= read -r line || [ -n "$line" ]; do + case $line in '' | '#'*) continue ;; esac + line=$(trim "$line") + [[ $line =~ ^(export[[:space:]]+)?([A-Z_][A-Z0-9_]*)[[:space:]]*= ]] || continue + key=${BASH_REMATCH[2]} + [ -n "${!key+x}" ] && continue + printf -v "$key" '%s' "$(parse_env_value "${line#*=}")" + export "${key?}" + done < "$OPS_CONFIG" + [ -n "${ARASYSTEM_DIR:-}" ] || die "$OPS_CONFIG 에 ARASYSTEM_DIR(저장소 폴더)이 없습니다" + APP_ENV_FILE=${APP_ENV_FILE:-$ARASYSTEM_DIR/.env.production} + local from_file="" + if [ -r "$APP_ENV_FILE" ]; then + from_file=$(env_get "$APP_ENV_FILE" PROJECT_NAME || true) + elif [ "$app_env" = optional ]; then + APP_ENV_FILE="" + else + die "$APP_ENV_FILE 을 읽을 수 없습니다 (ARASYSTEM_DIR 확인 — 새 서버 복원이면 restore.sh files 로 먼저 꺼낸다, mail/README.md \"복원\")" + fi + if [ -n "${PROJECT_NAME:-}" ] && [ -n "$from_file" ] && [ "$PROJECT_NAME" != "$from_file" ]; then + die "PROJECT_NAME 이 $OPS_CONFIG(${PROJECT_NAME})와 $APP_ENV_FILE(${from_file})에서 다릅니다 — 같게 맞춘다(볼륨 · 컨테이너 · 백업 스냅숏의 이름이 이 값이다)" + fi + PROJECT_NAME=${PROJECT_NAME:-$from_file} + [[ ${PROJECT_NAME:-} =~ ^[a-z0-9][a-z0-9_-]*$ ]] || + die "PROJECT_NAME 을 알 수 없습니다 — $OPS_CONFIG 에 적거나(새 서버 복원 — 옛 서버의 값) 저장소에 .env.production 을 둔다" + export PROJECT_NAME APP_ENV_FILE +} + +# 작업 폴더(상태 파일 · 잠금 · 표시 파일 · DB 덤프) — systemd 의 StateDirectory(/var/lib/arasystem-ops, root 만) +ops_work_dir() { + local dir=${BACKUP_WORK_DIR:-$(env_get "$OPS_CONFIG" BACKUP_WORK_DIR || true)} + printf '%s' "${dir:-/var/lib/arasystem-ops}" +} + +# 백업과 복원 연습이 함께 돌지 않게 — 작업 폴더의 같은 잠금 파일. 잡지 못하면 1 (flock 이 없는 곳 — 시험용 Git Bash — 은 잠그지 않는다) +take_backup_lock() { + command -v flock >/dev/null 2>&1 || return 0 + exec 9> "$1/backup.lock" + flock -n 9 +} + +# 예시 값(change-me)이 남은 설정을 거절한다 — 이름만 알린다(값은 출력하지 않는다) +require_set() { + local key + for key in "$@"; do + [ -n "${!key:-}" ] || die "$OPS_CONFIG 에 $key 가 비어 있습니다" + case ${!key} in *[Cc][Hh][Aa][Nn][Gg][Ee]-[Mm][Ee]* | *[Cc][Hh][Aa][Nn][Gg][Ee][Mm][Ee]*) die "$OPS_CONFIG 의 $key 가 예시 값 그대로입니다" ;; esac + done +} + +# curl 설정 글(-K)의 따옴표 값 — \ · " 는 이스케이프, 줄바꿈 · 탭은 \n · \t (curl 이 따옴표 안에서 푼다) +curl_config_quote() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + value=${value//$'\r'/} + value=${value//$'\n'/\\n} + value=${value//$'\t'/\\t} + printf '%s' "$value" +} + +# hc_ping <주소> [/fail|/start] [본문] — Healthchecks.io 에 알린다. 주소가 비면 아무것도 하지 않는다. 실패해도 스크립트를 멈추지 않는다. +# 주소 · 본문은 curl 의 설정 글로 표준 입력에 준다(-K -) — 명령줄(ps · /proc)에 ping 주소가 남지 않게 (주소를 아는 사람은 거짓 신호를 보낼 수 있다) +hc_ping() { + local url=$1 suffix=${2:-} body=${3:-} + [ -n "$url" ] || return 0 + if command -v curl >/dev/null 2>&1; then + # 응답은 셸이 버린다(-o /dev/null 은 쓰지 않는다 — 위 MSYS_NO_PATHCONV 때문에 Windows 의 curl 이 /dev/null 을 열지 못한다) + printf 'url = "%s"\ndata-raw = "%s"\n' "$(curl_config_quote "${url%/}${suffix}")" "$(curl_config_quote "${body:0:9000}")" \ + | curl -fsS -m 15 --retry 4 --retry-delay 5 -K - >/dev/null 2>&1 \ + || log "[주의] Healthchecks 에 알리지 못했습니다 (${suffix:-성공})" + else + log "[주의] curl 이 없어 Healthchecks 에 알리지 못했습니다" + fi +} + +# 컨테이너가 떠 있으면 0 · 멈춰 있으면 1 · 도커에 닿지 못하거나 컨테이너가 없으면 2 +container_running() { + local running + running=$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null) || return 2 + [ "$running" = true ] +} + +# 컨테이너 상태 한 낱말 — healthy · unhealthy · starting · running(healthcheck 없음) · exited · … · missing +container_state() { + local state + state=$(docker inspect -f '{{if .State.Running}}{{if .State.Health}}{{.State.Health.Status}}{{else}}running{{end}}{{else}}{{.State.Status}}{{end}}' "$1" 2>/dev/null) || state="" + printf '%s' "${state:-missing}" +} diff --git a/ops/monitor/arasystem-check.sh b/ops/monitor/arasystem-check.sh new file mode 100644 index 0000000..b1ded7e --- /dev/null +++ b/ops/monitor/arasystem-check.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +# ========================================== +# arasystem 15분 점검 (13단계 — mail/README.md "감시") — systemd 타이머 arasystem-check.timer +# +# 서버 안에서만 볼 수 있는 것을 보고 Healthchecks.io 에 알린다 — 정상이면 요약 한 줄, 아니면 /fail + 까닭 한 줄. +# 바깥에서 보는 것(웹 · 메일 포트가 열리나)은 UptimeRobot 이 맡는다 — 서버가 통째로 죽으면 이 스크립트도 못 돈다(Healthchecks 가 늦은 신호로 알린다). +# 1. 디스크 — / 와 도커 데이터 폴더(또는 CHECK_DISKS)의 사용률 +# 2. 컨테이너 — frontend · backend · db · mail · mail-resolver · clamav · clamav-updater 가 떠 있고 healthy +# (clamav-updater 의 unhealthy = 악성 검사 서명이 이틀 넘게 그대로 — compose 의 healthcheck) +# 3. 백엔드 /api/health — DB 까지 닿는지 (백엔드 컨테이너 안에서 — 바깥에서는 404 다) +# 4. 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간 (관리 API x:QueuedMessage — 관리자 비밀번호는 실행할 때 .env.production 에서 읽어 +# 백엔드 컨테이너의 node 로 http://mail:8080 에 묻는다(관리자 허용 IP = 앱 네트워크 안). 값은 docker exec 의 -e 이름으로만 넘기고 출력하지 않는다) +# 5. 인증서 남은 날 — mx1:993(이 서버의 127.0.0.1 로 — 공인 IP 로 되돌아오는 길이 막힌 곳이 있다) · 포털 · files:443, 믿을 수 있는 인증서인지도 +# 6. 마지막 백업 성공에서 지난 시간 (arasystem-backup 의 상태 파일) · 백업 타이머가 켜져 있는지 +# 7. Spamhaus 키(MAIL_SPAMHAUS_DQS_KEY 가 있을 때) — setup 과 같은 시험 항목이 메일 전용 리졸버로 나오는지(키가 만료되면 답이 멈춘다). +# 키는 표준 입력으로만 넘긴다(docker exec 의 명령줄 · 도커 이벤트에 남지 않게) +# 상주하지 않는다(메모리 0) — 15분마다 몇 초 돈다. 종료 코드 0 정상 · 1 문제 있음 +# ========================================== +set -Euo pipefail +umask 077 + +OPS_ROOT=$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd) +# shellcheck source=../lib/common.sh +. "$OPS_ROOT/lib/common.sh" +load_config + +WORK_DIR=$(ops_work_dir) +PROBLEMS=() +NOTES=() +problem() { PROBLEMS+=("$*"); } +note() { NOTES+=("$*"); } +P=$PROJECT_NAME + +# 백업이 메일 엔진을 잠깐 멈춘 동안(몇 초 — arasystem-backup 의 표시 파일)은 메일 쪽 확인을 건너뛴다. +# 표시만 믿지 않는다 — 백업 서비스가 지금 돌고 있거나(systemd) 표시가 15분 안에 생긴 것일 때만 "백업 중"이다. +# 그 밖의 남은 표시는 문제다(끊긴 백업이 엔진을 멈춘 채 남겼을 수 있다 — 표시만 남아 점검이 메일 쪽을 끝없이 건너뛰지 않게) +MARKER="$WORK_DIR/mail-stopped-by-backup" +MARKER_FRESH_SECONDS=900 +backup_service_running() { + command -v systemctl >/dev/null && [ -d /run/systemd/system ] || return 1 + case $(systemctl show --property=ActiveState --value arasystem-backup.service 2>/dev/null) in + activating | active | deactivating | reloading) return 0 ;; + *) return 1 ;; + esac +} +BACKUP_HOLDS_MAIL=0 +if [ -f "$MARKER" ]; then + marker_age=$(( $(date +%s) - $(stat -c %Y "$MARKER" 2>/dev/null || echo 0) )) + if backup_service_running || [ "$marker_age" -lt "$MARKER_FRESH_SECONDS" ]; then + BACKUP_HOLDS_MAIL=1 + else + problem "백업 표시가 $((marker_age / 60))분째 남아 있음(백업은 돌지 않는다 — 메일 엔진이 멈춘 채일 수 있다: sudo bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running)" + fi +fi + +# 1. 디스크 +check_disks() { + local paths=${CHECK_DISKS:-} path used seen=" " + [ -n "$paths" ] || paths="/ $(docker info -f '{{.DockerRootDir}}' 2>/dev/null || true)" + for path in $paths; do + [ -e "$path" ] || { problem "디스크 $path 없음"; continue; } + local fs + # --output 칸을 쓴다 — 마운트 경로에 빈칸이 있어도 칸이 밀리지 않게 (GNU df) + fs=$(df --output=source "$path" | tail -n 1) + case $seen in *" $fs "*) continue ;; esac + seen+="$fs " + used=$(df --output=pcent "$path" | tail -n 1 | tr -dc '0-9') + if [ "${used:-0}" -ge "${CHECK_DISK_MAX_PERCENT:-85}" ]; then problem "디스크 $path ${used}%"; else note "디스크 $path ${used}%"; fi + done +} + +# 2. 컨테이너 +check_containers() { + local service state healthy=0 + for service in ${CHECK_CONTAINERS:-frontend backend db mail mail-resolver clamav clamav-updater}; do + state=$(container_state "$P-$service") + if [ "$service" = mail ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then note "mail 은 백업 중(잠깐 멈춤)"; continue; fi + case $state in + healthy | running) healthy=$((healthy + 1)) ;; + unhealthy) + if [ "$service" = clamav-updater ]; then problem "컨테이너 $service unhealthy(악성 검사 서명이 이틀 넘게 그대로)"; else problem "컨테이너 $service unhealthy"; fi + ;; + *) problem "컨테이너 $service $state" ;; + esac + done + note "컨테이너 ${healthy}개 정상" +} + +# 3. 백엔드 /api/health — 무엇이 죽었는지(error 의 키)만 옮긴다 (본문의 오류 글은 싣지 않는다) +check_backend_health() { + local out + # shellcheck disable=SC2016 # process.env.PORT 는 컨테이너 안에서 푼다 + out=$(docker exec "$P-backend" node -e 'fetch("http://127.0.0.1:" + process.env.PORT + "/api/health", { signal: AbortSignal.timeout(10000) }).then(async (r) => { const j = await r.json().catch(() => ({})); console.log(r.status + " " + (j.status ?? "-") + " " + Object.keys(j.error ?? {}).join(",")) }).catch((e) => console.log("000 " + e.name))' 2>/dev/null || echo "000 exec") + case $out in + "200 ok"*) note "백엔드 health 정상" ;; + *) problem "백엔드 /api/health ${out}" ;; + esac +} + +# 4. 메일 대기열 — "수 가장오래(시간)" (관리자 비밀번호는 -e 이름으로만) +check_queue() { + local pw out total oldest + pw=$(env_get "$APP_ENV_FILE" MAIL_ADMIN_PASSWORD || true) + [ -n "$pw" ] || { problem "대기열 — .env.production 의 MAIL_ADMIN_PASSWORD 를 읽지 못함"; return; } + out=$(QUEUE_ADMIN_PASSWORD=$pw docker exec -i -e QUEUE_ADMIN_PASSWORD "$P-backend" node - 2>/dev/null <<'EOF' || echo "error exec" +const base = process.env.MAIL_JMAP_URL || 'http://mail:8080' +const user = `${(process.env.MAIL_ADMIN_LOCAL_PART || 'admin').trim().toLowerCase()}@${(process.env.MAIL_DOMAIN || '').trim().toLowerCase()}` +const auth = 'Basic ' + Buffer.from(`${user}:${process.env.QUEUE_ADMIN_PASSWORD}`).toString('base64') +const using = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'] +;(async () => { + const s = await fetch(`${base}/jmap/session`, { headers: { authorization: auth }, signal: AbortSignal.timeout(10000) }) + if (s.status !== 200) return console.log(`error session-${s.status}`) + const accountId = (await s.json()).primaryAccounts['urn:stalwart:jmap'] + const call = async (method, args) => { + const r = await fetch(`${base}/jmap/`, { method: 'POST', headers: { authorization: auth, 'content-type': 'application/json' }, body: JSON.stringify({ using, methodCalls: [[method, { accountId, ...args }, 'c']] }), signal: AbortSignal.timeout(15000) }) + const [[name, result]] = (await r.json()).methodResponses + if (name === 'error') throw new Error(result.type) + return result + } + const q = await call('x:QueuedMessage/query', { calculateTotal: true, limit: 200 }) + let oldest = 0 + if (q.ids.length > 0) { + const g = await call('x:QueuedMessage/get', { ids: q.ids, properties: ['createdAt'] }) + oldest = Math.max(0, ...g.list.map((m) => (Date.now() - Date.parse(m.createdAt)) / 3600000)) + } + console.log(`${q.total ?? q.ids.length} ${oldest.toFixed(1)}`) +})().catch((e) => console.log(`error ${e.message}`)) +EOF +) + read -r total oldest <<< "$out" + if ! [[ ${total:-} =~ ^[0-9]+$ ]]; then problem "대기열 — 읽지 못함(${oldest:-?})"; return; fi + if [ "$total" -gt "${CHECK_QUEUE_MAX:-50}" ]; then problem "메일 대기열 ${total}통"; fi + if awk -v a="$oldest" -v m="${CHECK_QUEUE_MAX_AGE_HOURS:-24}" 'BEGIN { exit !(a > m) }'; then problem "메일 대기열에 ${oldest}시간 머문 메일"; fi + note "대기열 ${total}통" +} + +host_of() { # 출처(https://호스트[:포트]) → 호스트 + local value=${1#*://} + value=${value%%/*} + printf '%s' "${value%%:*}" +} + +# 5. 인증서 — 대상 꼴: 이름:포트 또는 이름:포트@붙을주소 +check_certs() { + local targets=${CHECK_CERTS:-} target name port addr out end days verify + if [ -z "$targets" ]; then + local mail cors share + mail=$(env_get "$APP_ENV_FILE" MAIL_HOSTNAME || true) + cors=$(env_get "$APP_ENV_FILE" CORS_ORIGIN || true) + share=$(env_get "$APP_ENV_FILE" SHARE_PUBLIC_URL || true) + [ -n "$mail" ] && targets+=" $mail:993@127.0.0.1" + [ -n "$cors" ] && targets+=" $(host_of "${cors%%,*}"):443" + [ -n "$share" ] && targets+=" $(host_of "$share"):443" + fi + command -v openssl >/dev/null || { problem "인증서 — openssl 이 없음"; return; } + for target in $targets; do + name=${target%%:*} + port=${target#*:} + addr=$name + case $port in *@*) addr=${port#*@}; port=${port%@*} ;; esac + if [ "$addr" = 127.0.0.1 ] && [ "$BACKUP_HOLDS_MAIL" = 1 ]; then continue; fi + out=$(timeout 20 openssl s_client -connect "$addr:$port" -servername "$name" -verify_hostname "$name" /dev/null || true) + end=$(openssl x509 -noout -enddate <<< "$out" 2>/dev/null | cut -d= -f2) + verify=$(sed -n 's/^ *Verify return code: \([0-9]*\).*/\1/p' <<< "$out" | tail -n 1) + if [ -z "$end" ]; then problem "인증서 $name:$port — 연결 · 읽기 실패"; continue; fi + days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 )) + if [ "${verify:-1}" != 0 ]; then + problem "인증서 $name — 믿을 수 없음(검증 코드 ${verify:-?} · 자체 서명이면 ACME 실패)" + elif [ "$days" -lt "${CHECK_CERT_MIN_DAYS:-14}" ]; then + problem "인증서 $name ${days}일 남음" + else + note "인증서 ${name%%.*} ${days}일" + fi + done +} + +# 6. 백업 — 마지막 성공에서 지난 시간 · 타이머 +check_backup() { + local last hours + last=$(sed -n 's/^last_success_epoch=//p' "$WORK_DIR/status" 2>/dev/null || true) + if [ -z "$last" ]; then + problem "백업 — 성공한 기록 없음($WORK_DIR/status)" + else + hours=$(( ( $(date +%s) - last ) / 3600 )) + if [ "$hours" -ge "${CHECK_BACKUP_MAX_AGE_HOURS:-26}" ]; then problem "백업 — 마지막 성공 ${hours}시간 전"; else note "백업 ${hours}시간 전"; fi + fi + if command -v systemctl >/dev/null && [ -d /run/systemd/system ]; then + systemctl is-active --quiet arasystem-backup.timer || problem "백업 타이머(arasystem-backup.timer)가 꺼져 있음" + fi +} + +# 7. Spamhaus 키 — 메일 컨테이너의 perl 로 리졸버에 A 를 묻는다(이름은 표준 입력으로). 줄마다 "이름의 시험 결과 주소들" +SPAMHAUS_PROBE=$(cat <<'EOF' +use strict; use IO::Socket::INET; use IO::Select; +my @in = ; chomp @in; my $server = shift @in; +for my $name (@in) { + my $id = int(rand(65536)); my $q = pack('n6', $id, 0x0100, 1, 0, 0, 0); + $q .= pack('C', length $_) . $_ for grep { length } split /\./, $name; + $q .= pack('Cnn', 0, 1, 1); + my $s = IO::Socket::INET->new(PeerAddr => $server, PeerPort => 53, Proto => 'udp', Timeout => 10) or do { print "error\n"; next }; + send($s, $q, 0); my $r = ''; + unless (IO::Select->new($s)->can_read(10) && defined recv($s, $r, 4096, 0) && length($r) >= 12) { print "timeout\n"; next } + my ($rid, $flags, $qd, $an) = unpack('n4', $r); my $o = 12; + my $skip = sub { while ($o < length $r) { my $l = unpack('C', substr($r, $o, 1)); if (($l & 0xC0) == 0xC0) { $o += 2; return } $o += 1 + $l; return if $l == 0 } }; + for (1 .. $qd) { $skip->(); $o += 4 } + my @a; for (1 .. $an) { $skip->(); my ($t, $c, $ttl, $len) = unpack('nnNn', substr($r, $o, 10)); $o += 10; push @a, join('.', unpack('C4', substr($r, $o, 4))) if $t == 1 && $len == 4; $o += $len } + print 'rcode' . ($flags & 0x0F) . ' ' . join(',', @a) . "\n"; +} +EOF +) +check_spamhaus() { + local key resolver out zen dbl + key=$(env_get "$APP_ENV_FILE" MAIL_SPAMHAUS_DQS_KEY || true) + [ -n "$key" ] || { note "Spamhaus 키 없음(꺼 둠)"; return; } + resolver=$(env_get "$APP_ENV_FILE" MAIL_RESOLVER_IP || true) + key=$(tr '[:upper:]' '[:lower:]' <<< "$key") + # 프로그램은 명령줄로(비밀 없음), 키가 든 조회 이름은 표준 입력으로 + out=$(printf '%s\n' "${resolver:-172.16.73.254}" "2.0.0.127.$key.zen.dq.spamhaus.net" "dbltest.com.$key.dbl.dq.spamhaus.net" \ + | docker exec -i "$P-mail" perl -e "$SPAMHAUS_PROBE" 2>/dev/null || echo error) + zen=$(sed -n 1p <<< "$out") + dbl=$(sed -n 2p <<< "$out") + case "$zen|$dbl" in + *127.0.0.2*"|"*127.0.1.2*) note "Spamhaus 키 정상" ;; + *) problem "Spamhaus 키 — 시험 항목이 안 나옴(ZEN ${zen%% *} · DBL ${dbl%% *} — 키 만료면 setup 이 끄고 종료 1, mail/README.md \"DNS 리졸버와 스팸 차단 목록\")" ;; + esac +} + +check_disks +check_containers +check_backend_health +if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_queue; fi +check_certs +check_backup +if [ "$BACKUP_HOLDS_MAIL" = 0 ]; then check_spamhaus; fi + +if [ "${#PROBLEMS[@]}" -gt 0 ]; then + message="문제 ${#PROBLEMS[@]} — $(printf '%s · ' "${PROBLEMS[@]}")" + message=${message% · } + log "[문제] $message" + hc_ping "${HC_CHECK_URL:-}" /fail "$message" + exit 1 +fi +message="정상 — $(printf '%s · ' "${NOTES[@]}")" +message=${message% · } +log "[정상] $message" +hc_ping "${HC_CHECK_URL:-}" "" "$message" diff --git a/ops/ops.env.example b/ops/ops.env.example new file mode 100644 index 0000000..2c08b43 --- /dev/null +++ b/ops/ops.env.example @@ -0,0 +1,65 @@ +# ========================================== +# 운영 서버의 백업 · 감시 설정 (13단계 — mail/README.md "백업" · "감시") +# +# 이 파일은 예시다. 서버에서는 저장소 밖에 둔다 — root 만 읽게: +# sudo install -d -m 0700 -o root -g root /etc/arasystem +# sudo install -m 0600 -o root -g root ops/ops.env.example /etc/arasystem/ops.env # 그다음 값을 채운다 (sudoedit /etc/arasystem/ops.env) +# restic 저장소 비밀번호는 따로 /etc/arasystem/restic-password (0600) — 한 줄, 무작위로: +# sudo sh -c 'umask 077; openssl rand -base64 33 > /etc/arasystem/restic-password' +# ⚠️ restic 비밀번호 · S3 키는 서버 밖(회사의 비밀번호 관리자)에도 둔다 — 서버를 잃었는데 비밀번호가 서버에만 있으면 백업을 풀 수 없다. +# +# 형식: KEY=값 한 줄씩. 스크립트는 이 파일을 셸로 실행(source)하지 않고 줄마다 읽는다 — 값에 공백이 있으면 작은따옴표로 감싼다. +# ========================================== + +# 저장소 폴더 (git clone 한 곳) — 스크립트가 여기의 .env.production · backend/.env.production 을 읽는다 +# (PROJECT_NAME · 메일 도메인 · 관리자 비밀번호(대기열 확인) · Spamhaus 키 — 따로 복사해 두지 않는다) +ARASYSTEM_DIR=/home/ubuntu/arasystem +# 프로젝트 이름 — 볼륨 · 컨테이너 이름(_mail-data · -mail)과 백업 스냅숏의 host 이름이 이 값이다. +# 비우면 위 저장소의 .env.production 에서 읽는다. ⚠️ 새 서버를 되살릴 때는 적는다(옛 서버의 값 — 예: arasystem) — 그 단계에는 .env.production 이 +# 아직 없다(restore.sh files 가 백업에서 꺼낸다). 둘 다 있고 다르면 스크립트가 멈춘다 +PROJECT_NAME= +# 백업 스냅숏의 host 이름 — 비우면 PROJECT_NAME. 다른 이름으로 받던 백업을 이어 쓸 때만 적는다 +BACKUP_HOST= + +# ------------------------------------------ +# 백업 (ops/backup/arasystem-backup.sh — systemd 타이머 매일 03:30 KST) +# ------------------------------------------ +# restic 저장소 — NCP Object Storage(S3 호환). 버킷은 콘솔에서 먼저 만든다(공개 안 함). 뒤의 /arasystem 은 버킷 안의 접두어 +RESTIC_REPOSITORY=s3:https://kr.object.ncloudstorage.com/change-me-bucket/arasystem +# NCP 의 S3 옵션 — 지역 kr-standard(서명 V4 에 쓴다) · 경로 방식(https://<엔드포인트>/<버킷> — NCP 는 경로 · 가상 호스트 방식 둘 다 받는다) +# (근거: NCP API 가이드 Object Storage — https://api.ncloud-docs.com/docs/storage-objectstorage · …-listobjects) +RESTIC_OPTIONS='-o s3.region=kr-standard -o s3.bucket-lookup=path' +# NCP 서브 계정의 API 인증키 — 이 버킷만 쓰게 좁힌 계정으로 (콘솔 › Sub Account) +AWS_ACCESS_KEY_ID=change-me +AWS_SECRET_ACCESS_KEY=change-me +# 보존 — 매일 7 · 매주 4 · 매달 6 (그보다 오래된 백업은 지운다 — 지운 메일도 이 기간까지만 백업에 남는다) +BACKUP_KEEP_DAILY=7 +BACKUP_KEEP_WEEKLY=4 +BACKUP_KEEP_MONTHLY=6 +# 메일 엔진을 멈추고 올리는 final 한 번의 시간 상한(숫자 + s · m · h) — 넘으면 restic 을 멈추고 엔진을 다시 띄운 뒤 실패로 알린다. +# 평소 final 은 몇 초다(바뀐 것만 올린다). 바깥 대역폭이 아주 느리면 늘린다 +BACKUP_FINAL_TIMEOUT=15m +# 사전 확인에서 메일 엔진 · DB 가 healthy 가 되기를 기다리는 시간(초) — 부팅 직후 타이머가 놓친 백업을 바로 할 때 엔진이 아직 starting 이다 +BACKUP_HEALTHY_WAIT=300 + +# ------------------------------------------ +# 알림 — Healthchecks.io (무료). 체크 둘을 만들고 각자의 ping 주소를 넣는다 (mail/README.md "감시" — 체크 만드는 차례) +# 비우면 알리지 않는다(로그 · 상태 파일은 그대로). 주소를 아는 사람은 거짓 신호를 보낼 수 있다 — 이 파일 밖에 적어 두지 않는다 +# ------------------------------------------ +HC_BACKUP_URL= +HC_CHECK_URL= + +# ------------------------------------------ +# 15분 점검 (ops/monitor/arasystem-check.sh) — 비운 값은 기본값 +# ------------------------------------------ +# 디스크 — 볼 마운트 지점(공백으로) · 넘으면 알리는 사용률(%). 비우면 / 와 도커 데이터 폴더(docker info 의 DockerRootDir) +CHECK_DISKS= +CHECK_DISK_MAX_PERCENT=85 +# 인증서 — host:port (공백으로). 비우면 .env.production 의 MAIL_HOSTNAME:993 · CORS_ORIGIN 의 호스트:443 · SHARE_PUBLIC_URL 의 호스트:443 +CHECK_CERTS= +CHECK_CERT_MIN_DAYS=14 +# 메일 대기열 — 쌓인 수 · 가장 오래 머문 시간(시간) +CHECK_QUEUE_MAX=50 +CHECK_QUEUE_MAX_AGE_HOURS=24 +# 마지막 백업 성공에서 지난 시간(시간) +CHECK_BACKUP_MAX_AGE_HOURS=26 diff --git a/ops/systemd/arasystem-backup.service b/ops/systemd/arasystem-backup.service new file mode 100644 index 0000000..45455fe --- /dev/null +++ b/ops/systemd/arasystem-backup.service @@ -0,0 +1,28 @@ +# arasystem 백업 (13단계 — mail/README.md "백업") — arasystem-backup.timer 가 매일 03:30 KST 에 부른다 +# 설치: mail/README.md "백업" › "설치 (서버 B — 한 번)" (스크립트는 /opt/arasystem-ops 에 root 소유로 둔다 — 저장소 폴더를 바로 부르지 않는다) +[Unit] +Description=arasystem 백업 — 메일 엔진 · DB · 업로드 · env 를 restic 저장소로 +# docker 가 없으면 스크립트가 스스로 실패해 알린다 — Requires 로 묶으면 docker 를 다시 시작할 때 이 서비스도 함께 멈춘다(엔진을 멈춘 채로) +Wants=docker.service network-online.target +After=docker.service network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh +# 스크립트가 어떻게 끝나든(죽어도) 백업이 멈춘 메일 엔진이 남아 있으면 다시 띄운다 +ExecStopPost=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running +# 상태 파일 · DB 덤프 자리 (/var/lib/arasystem-ops — root 만) +StateDirectory=arasystem-ops +StateDirectoryMode=0700 +# 처음 백업은 메일 전체를 올려 오래 걸릴 수 있다 (그 뒤로는 바뀐 것만 — 분 단위). 메일 엔진이 멈추는 final 은 스크립트가 따로 묶는다(BACKUP_FINAL_TIMEOUT) +TimeoutStartSec=6h +# 멈출 때(stop · 시간 초과) 스크립트가 정리할 시간 — 사본 지우기 → 엔진 띄우기(멈춤 끝내기 60초) → /fail 알림(재시도 포함 최대 1분 반) → 준비 180초 +TimeoutStopSec=8min +# 스크립트 · docker CLI 에만 걸린다 — restic 컨테이너는 dockerd 아래라 물려받지 않는다(스크립트가 docker run --cpus 1 --cpu-shares 512 --memory 1g 로 묶는다) +Nice=10 +IOSchedulingClass=best-effort +IOSchedulingPriority=7 +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only diff --git a/ops/systemd/arasystem-backup.timer b/ops/systemd/arasystem-backup.timer new file mode 100644 index 0000000..854b4c8 --- /dev/null +++ b/ops/systemd/arasystem-backup.timer @@ -0,0 +1,13 @@ +# 매일 03:30 KST — 엔진의 휴지통 · 스팸 정리(03:00)와 데이터 · 원문 정리(04:00 · 04:30 — setup 이 맞춘다) 사이 +[Unit] +Description=arasystem 백업 — 매일 03:30 KST + +[Timer] +OnCalendar=*-*-* 03:30:00 Asia/Seoul +# 서버가 꺼져 있어 놓쳤으면 켜진 뒤 바로 한 번 (메일 엔진이 몇 초 멈춘다). 그때 엔진은 아직 starting 이다 — +# 스크립트가 healthy 를 5분(BACKUP_HEALTHY_WAIT)까지 기다리고, 부팅 유닛(arasystem-mail-recover)이 먼저 돈다 +Persistent=true +AccuracySec=1min + +[Install] +WantedBy=timers.target diff --git a/ops/systemd/arasystem-check.service b/ops/systemd/arasystem-check.service new file mode 100644 index 0000000..bb32bd7 --- /dev/null +++ b/ops/systemd/arasystem-check.service @@ -0,0 +1,16 @@ +# arasystem 15분 점검 (13단계 — mail/README.md "감시") — arasystem-check.timer 가 부른다 +[Unit] +Description=arasystem 점검 — 디스크 · 컨테이너 · 대기열 · 인증서 · 백업 · Spamhaus 키 +# docker 가 없으면 점검이 그것을 문제로 알린다 — Requires 로 묶지 않는다 +Wants=docker.service network-online.target +After=docker.service network-online.target + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/monitor/arasystem-check.sh +TimeoutStartSec=5min +Nice=10 +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only diff --git a/ops/systemd/arasystem-check.timer b/ops/systemd/arasystem-check.timer new file mode 100644 index 0000000..1988c94 --- /dev/null +++ b/ops/systemd/arasystem-check.timer @@ -0,0 +1,10 @@ +# 15분마다 — 매시 07 · 22 · 37 · 52분 (백업이 시작하는 03:30 과 겹치지 않게 비켜 둔다) +[Unit] +Description=arasystem 점검 — 15분마다 + +[Timer] +OnCalendar=*-*-* *:07/15:00 +AccuracySec=30s + +[Install] +WantedBy=timers.target diff --git a/ops/systemd/arasystem-mail-recover.service b/ops/systemd/arasystem-mail-recover.service new file mode 100644 index 0000000..7758405 --- /dev/null +++ b/ops/systemd/arasystem-mail-recover.service @@ -0,0 +1,28 @@ +# arasystem — 부팅 때 백업이 멈춘 메일 엔진을 다시 띄운다 (13단계 — mail/README.md "백업") +# 백업이 메일 엔진을 멈춘 몇 초 사이에 서버가 꺼지면 엔진은 멈춘 채 남는다 — docker stop 으로 멈춘 컨테이너는 restart: unless-stopped 여도 +# 도커가 다시 띄우지 않는다. 표시 파일(/var/lib/arasystem-ops/mail-stopped-by-backup)이 있을 때만 띄우고, 떠 있는 것을 본 뒤 표시를 지운다 +[Unit] +Description=arasystem — 백업이 멈춘 메일 엔진 다시 띄우기 (부팅 때) +Wants=docker.service +After=docker.service +# 부팅 뒤 따라잡는 백업(타이머의 Persistent)보다 먼저 — 백업이 이 엔진을 멈춘 채 받지 않게 +Before=arasystem-backup.service +# 도커가 늦게 뜨면 실패한다 — 30초마다 다시, 10분 동안 10번까지 +StartLimitIntervalSec=10min +StartLimitBurst=10 + +[Service] +Type=oneshot +ExecStart=/bin/bash /opt/arasystem-ops/backup/arasystem-backup.sh ensure-mail-running +Restart=on-failure +RestartSec=30 +StateDirectory=arasystem-ops +StateDirectoryMode=0700 +TimeoutStartSec=5min +PrivateTmp=true +NoNewPrivileges=true +ProtectSystem=full +ProtectHome=read-only + +[Install] +WantedBy=multi-user.target