Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 배포 — 서버에서 실행 계정으로 실행: sudo -u <APP_USER> env SOURCE_BUNDLE=<번들> <APP_DIR>/repo/deploy/deploy.sh
# 1. 소스 갱신(번들 또는 원격) 2. 테스트 3. 릴리스 복사 4. DB 백업·마이그레이션 5. 서비스 재시작(실패 시 이전 릴리스로 복귀)
# 사이트(서비스 이름·계정·경로·포트)는 <APP_DIR>/site.env(setup.sh 가 둠)에서 읽습니다. 없으면 arihakdang 설정으로 동작합니다(0.8.17 까지의 서버 호환).
set -euo pipefail
. "$(dirname "$0")/lib.sh"
APP="$(cd "$DEPLOY_DIR/../.." && pwd)" # <APP_DIR>/repo/deploy → <APP_DIR>
if [ -f "$APP/site.env" ]; then load_site "$APP/site.env"; else load_site "$DEPLOY_DIR/sites/arihakdang.env"; fi
APP="$APP_DIR"; REPO="$APP/repo"; RELEASES="$APP/releases"
[ -x "$NODE_BIN" ] || NODE_BIN="$(command -v node)"
MAJOR="$($NODE_BIN --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $($NODE_BIN --version)" >&2; exit 1; }
export PATH="$(dirname "$NODE_BIN"):$PATH"
STAMP="$(date +%Y%m%d-%H%M%S)"; TARGET="$RELEASES/$STAMP"
exec 9>"$APP/deploy.lock"; flock -n 9 || { echo '다른 배포가 진행 중입니다' >&2; exit 1; }
echo "▶ 소스 갱신 ($SITE: $MAIL_HOST)"; cd "$REPO"
# SOURCE_BUNDLE=<git bundle 경로> 를 주면 원격 자격 증명 없이 번들에서 main 을 받습니다(작업 PC 에서 scp 로 전달).
# 번들의 main 이 서버가 아는 origin/main 과 이어지지 않아도(릴리스 역사를 다시 쓴 경우) 받도록 강제(+) 갱신합니다. 어차피 아래에서 origin/main 으로 reset 합니다.
if [ -n "${SOURCE_BUNDLE:-}" ]; then git bundle verify "$SOURCE_BUNDLE" >/dev/null 2>&1; git fetch --quiet --force "$SOURCE_BUNDLE" +main:refs/remotes/origin/main; else git fetch --quiet origin; fi
git reset --hard --quiet origin/main; echo " $(git log --oneline -1)"
echo "▶ 테스트"; for t in gateway imap smtp config provision domains webmaker usage; do "$NODE_BIN" "test/$t.test.mjs"; done
echo "▶ 릴리스 배치"; mkdir -p "$TARGET"
for path in server web lang migrations package.json haraka; do cp -r "$REPO/$path" "$TARGET/$path"; done
git rev-parse HEAD > "$TARGET/REVISION"
DB="$APP/data/mail-gateway.sqlite"; mkdir -p "$APP/data/backups"
if [ -f "$DB" ]; then python3 - "$DB" "$APP/data/backups/pre-$STAMP.sqlite" <<'PY'
import sqlite3,sys
s=sqlite3.connect('file:'+sys.argv[1]+'?mode=ro',uri=True);b=sqlite3.connect(sys.argv[2]);s.backup(b);b.close();s.close()
PY
fi
DB_FILE="$DB" "$NODE_BIN" "$TARGET/server/db-migrate.mjs"
# 첫 배포에는 current 가 없습니다. -e 는 존재하는 경로만 돌려주므로 자기 자신을 가리키는 링크를 만들지 않습니다.
PREVIOUS="$(readlink -e "$APP/current" 2>/dev/null || true)"
rollback() { trap - ERR; if [ -n "$PREVIOUS" ] && [ -d "$PREVIOUS" ] && [ "$PREVIOUS" != "$TARGET" ]; then ln -sfn "$PREVIOUS" "$APP/current.rollback"; mv -Tf "$APP/current.rollback" "$APP/current"; sudo systemctl restart "$APP_NAME"; fi; echo "배포 실패 — 이전 릴리스로 복귀" >&2; }
trap rollback ERR
ln -sfn "$TARGET" "$APP/current.tmp"; mv -Tf "$APP/current.tmp" "$APP/current"; echo " current → $TARGET"
# sync: 릴리스 파일이 디스크에 완전히 쓰인 뒤 재시작합니다(1.0.5 — 0.9.7 배포 때 첫 시작이 빈 package.json 을 읽은 기록).
sync
echo "▶ 서비스 재시작"; sudo systemctl restart "$APP_NAME"; sleep 2
systemctl is-active --quiet "$APP_NAME" || { rollback; exit 1; }
curl -fsS "http://127.0.0.1:$PORT/api/health" >/dev/null && echo " health OK"
trap - ERR
ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +6 | xargs -r rm -rf
# 내장 Haraka(1.0.0): 관리 서비스가 등록되어 있으면 새 릴리스의 run.mjs 로 다시 띄웁니다(sudoers 에 없으면 건너뜀 — haraka-apply.sh 가 다음 실행에서 맞춤).
# 의존성 설치는 root 가 하는 haraka-apply.sh 의 몫이지만, 이미 설치된 디렉터리가 있고 package.json 이 바뀌었으면 여기서 실행 계정으로 갱신을 시도합니다(실패해도 배포는 성공).
if [ -f "$APP/haraka/package.json" ] && ! cmp -s "$REPO/haraka/package.json" "$APP/haraka/package.json"; then
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
if [ -x "$NPM_BIN" ]; then cp "$REPO/haraka/package.json" "$APP/haraka/package.json"; (cd "$APP/haraka" && HOME="$APP/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --loglevel=error) || echo " ⚠️ Haraka 의존성 갱신 실패(인터넷?) — sudo bash $REPO/deploy/haraka-apply.sh 로 다시 시도" >&2; fi
fi
if systemctl is-enabled --quiet "$APP_NAME-haraka" 2>/dev/null; then sudo -n systemctl restart "$APP_NAME-haraka" 2>/dev/null && echo " Haraka 관리 서비스 재시작" || echo " (Haraka 관리 서비스는 root 가 재시작해야 합니다: sudo systemctl restart $APP_NAME-haraka)"; fi
echo "✔ 배포 완료: $STAMP"
# 초기 설정 페이지(1.0.0): 서버가 시작하며 데이터 디렉터리에 접속 주소(토큰 포함)를 남깁니다. 설치 직후 운영 설정(Resend 키 등)을 여기서 채웁니다.
sleep 1; if [ -f "$APP/data/setup-url.txt" ]; then echo "▶ 초기 설정 페이지(토큰 포함 — 공유하지 마세요):"; sed 's/^/ /' "$APP/data/setup-url.txt"; echo " 또는 https://$MAIL_HOST/admin → 메일 서비스 설정"; fi
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# 추가 도메인의 웹 메일 호스트에 nginx vhost 와 Let's Encrypt 인증서를 준비합니다(1.0.7, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/domain-nginx.sh mail.example.com [site.env 경로]
#
# 왜 필요한가
# 도메인 자체는 전체 설정 화면(기본 도메인의 8791 포트)에서 추가하면 게이트웨이가 바로 받기 시작하지만, 브라우저가 https://mail.example.com/ 으로
# 오려면 nginx 가 그 이름(server_name)과 인증서를 알아야 합니다. 이 스크립트는 기존 사이트의 nginx 템플릿(deploy/nginx/*.in)을 그 호스트 이름으로 채워
# 같은 게이트웨이 포트(PORT)로 넘기는 vhost 를 만들고 certbot 으로 인증서를 받습니다. 게이트웨이 설정·환경 파일·데이터는 건드리지 않습니다.
#
# 하는 일
# 1) 인증서가 없으면 80 전용(ACME 챌린지) vhost 로 시작하고, DNS(호스트 → 이 서버 IP)가 맞으면 certbot certonly --webroot 로 발급합니다.
# DNS 가 아직 안 바뀌었으면 안내만 하고 끝납니다(DNS 가 바뀐 뒤 다시 실행).
# 2) 인증서가 있으면 443 vhost(mail.conf.in)로 바꾸고 nginx 를 다시 읽습니다. 갱신 뒤 nginx 재적재용 certbot 훅도 둡니다.
# 참고: 메일 프로그램 포트(IMAP/POP/SMTP)의 인증서는 기본 도메인 것 하나이므로, 추가 도메인의 사용자도 메일 프로그램에는 기본 도메인의 호스트 이름으로 접속합니다
# (웹 메일의 "메일 프로그램 설정" 화면이 그 이름을 안내합니다).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
TARGET_HOST="$(printf '%s' "${1:?웹 메일 호스트 이름(예: mail.example.com)}" | tr 'A-Z' 'a-z')"
printf '%s' "$TARGET_HOST" | grep -Eq '^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$' || { echo "호스트 이름 형식이 아닙니다: $TARGET_HOST" >&2; exit 1; }
SITE_FILE="${2:-}"
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
[ "$TARGET_HOST" != "$MAIL_HOST" ] || { echo "$TARGET_HOST 는 기본 도메인의 호스트입니다(setup.sh / nginx-apply.sh 가 관리)." >&2; exit 1; }
# 템플릿의 @MAIL_HOST@ 자리를 이 호스트로 채웁니다(나머지 변수 — 포트·로그 이름·ACME 경로 — 는 사이트 설정 그대로).
MAIL_HOST="$TARGET_HOST"; export MAIL_HOST
TARGET="/etc/nginx/sites-available/$TARGET_HOST.conf"
apply() { render "$1" "$TARGET"; chmod 644 "$TARGET"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$TARGET_HOST.conf"; nginx -t; systemctl reload nginx; }
echo "▶ 추가 도메인 호스트: $TARGET_HOST (사이트 $SITE, 게이트웨이 포트 $PORT)"
if [ ! -d "/etc/letsencrypt/live/$TARGET_HOST" ]; then
apply "$DEPLOY_DIR/nginx/mail.http-only.conf.in"
echo " 80 포트(ACME) 설정 적용. 인증서를 발급합니다…"
RESOLVED="$(getent ahostsv4 "$TARGET_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)"
MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)"
MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done
if [ -z "$MATCH" ]; then
echo " $TARGET_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE)."
echo " DNS 의 A 레코드를 이 서버로 바꾼 뒤 같은 명령을 다시 실행하세요. 그때까지 https 는 열리지 않습니다."
exit 0
fi
mkdir -p "$ACME_WEBROOT"
ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email"
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$TARGET_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG
fi
apply "$DEPLOY_DIR/nginx/mail.conf.in"
# 갱신 훅: 이 호스트의 인증서가 갱신되면 nginx 만 다시 읽습니다(메일 포트는 기본 도메인 인증서를 쓰므로 게이트웨이 재시작 불필요).
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
cat > "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh" <<HOOK
#!/bin/sh
# AriMail 추가 도메인($TARGET_HOST) 인증서 갱신 뒤 nginx 재적재
case "\${RENEWED_LINEAGE:-}" in */$TARGET_HOST*) systemctl reload nginx || true ;; esac
HOOK
chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME-$TARGET_HOST.sh"
echo "✔ https://$TARGET_HOST/ 준비 완료. 전체 설정 화면에서 이 호스트를 가진 도메인을 추가(또는 호스트 목록에 넣기)했는지 확인하세요."
+65
View File
@@ -0,0 +1,65 @@
# @ETC_DIR@/gateway.env — 소유자만 읽게 하세요 (chmod 600, chown @APP_USER@). setup.sh 가 사이트 설정(deploy/sites/@SITE@.env)으로 만든 파일입니다.
#
# 1.0.0: 이 파일에는 "배포에 꼭 필요한 값" 만 남깁니다. 운영 설정(수·발신 방식, Resend API 키·웹훅 비밀키, SMTP 중계 서버, 내장 Haraka, 발신 표시 이름,
# 탭 제목, 통계 시간대, 동기화 주기)은 데이터 디렉터리의 설정 파일(@APP_DIR@/data/config.json)에 있고, 설치 뒤 초기 설정 페이지
# (배포 스크립트가 출력하는 http(s)://<서버>:8791/?token=… 또는 https://@MAIL_HOST@/admin → 메일 서비스 설정)에서 채우면 재시작 없이 적용됩니다.
# 예전 서버에서 이 파일에 남아 있는 RESEND_API_KEY 등은 1.0.0 첫 시작 때 설정 파일로 가져가며, 그 뒤로는 설정 파일 값이 우선합니다.
#
# 게이트웨이가 다루는 기본 메일함. 계정의 메일함은 항상 "로그인 ID@(기본 메일함의 도메인)" 입니다.
MAILBOX=@MAILBOX@
PUBLIC_HOST=@MAIL_HOST@
# 운영 설정 파일 위치(비우면 DB 파일 옆의 config.json). 초기 설정 페이지 전용 포트(0 이면 끔)와 접속 허용 주소.
CONFIG_FILE=
SETUP_PORT=8791
SETUP_HOST=0.0.0.0
# 내장 Haraka 설치 디렉터리(deploy/haraka-apply.sh 가 npm 으로 설치, 서비스 @APP_NAME@-haraka). 비우면 @APP_DIR@/haraka.
HARAKA_DIR=@APP_DIR@/haraka
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본 설치 형태, 첫 관리자 admin/adminpasswd 자동 생성)
# www = www 사이트 DB(WWW_DB_FILE)로 관리자를 인증. standalone 이면 WWW_DB_FILE 은 무시됩니다.
LOGIN_MODE=@LOGIN_MODE@
# www 사이트 DB(관리자 계정 인증용, 읽기 전용). LOGIN_MODE=www 일 때만 씁니다.
WWW_DB_FILE=@WWW_DB_FILE@
# 웹메이커 연동(1.0.8, LOGIN_MODE=webmaker): 웹메이커 데이터 디렉터리(DATA_ROOT), sudo 로 읽을 계정(선택, deploy/webmaker-access.sh), 사이트 id(선택).
WEBMAKER_DIR=@WEBMAKER_DIR@
WEBMAKER_USER=@WEBMAKER_USER@
WEBMAKER_SITE=@WEBMAKER_SITE@
# 시스템 제한(0.9.0, 메일 시스템 전체의 상위 설정 — 관리자 페이지에서 바꿀 수 없고 관리자 설정은 이 값 이내로만 가능). 모두 0 이면 제한 없음.
# 크기는 바이트 또는 K/M/G 접미사(예: 500M, 2G)이며 MIME 변환 뒤의 크기(첨부는 base64 로 약 1.37배) 기준입니다.
# (systemd 환경 파일은 값 뒤의 주석을 지원하지 않으므로 주석은 따로 둡니다.)
# 허용 가능한 메일 계정 수
LIMIT_ACCOUNTS=@LIMIT_ACCOUNTS@
# 메일함 전체 최대 용량
LIMIT_TOTAL_BYTES=@LIMIT_TOTAL_BYTES@
# 개별 메일함 최대 용량
LIMIT_MAILBOX_BYTES=@LIMIT_MAILBOX_BYTES@
# 첨부 파일 한 개 최대 크기
LIMIT_ATTACHMENT_BYTES=@LIMIT_ATTACHMENT_BYTES@
# 한 메일의 받는 사람 수(참조·숨은 참조 포함, 내장 상한 50)
LIMIT_RECIPIENTS=@LIMIT_RECIPIENTS@
# 월간(1일~말일) 최대 수발신 통수
LIMIT_MONTHLY_MESSAGES=@LIMIT_MONTHLY_MESSAGES@
# 대용량 첨부파일 시스템 상한(0.9.3): 관리자 설정의 상한이며 무제한으로 둘 수 없습니다(비우거나 0 이면 기본값 1G / 30 / 20 적용).
# 파일 한 개 최대 크기(기본 1G)
LIMIT_LARGE_FILE_BYTES=@LIMIT_LARGE_FILE_BYTES@
# 보관 최대 기간(일, 기본 30)
LIMIT_LARGE_FILE_DAYS=@LIMIT_LARGE_FILE_DAYS@
# 다운로드 최대 횟수(기본 20)
LIMIT_LARGE_FILE_DOWNLOADS=@LIMIT_LARGE_FILE_DOWNLOADS@
# 대용량 파일을 둘 디렉터리(비우면 DB 파일 옆의 files/). 서비스가 쓸 수 있는 경로여야 합니다(systemd ReadWritePaths).
FILES_DIR=
# 게이트웨이 저장소(SQLite). 설정 파일·초기 설정 토큰·Haraka 상태 파일도 같은 디렉터리에 둡니다.
DB_FILE=@APP_DIR@/data/mail-gateway.sqlite
# POP3·IMAP·SMTP TLS: 인증서 사본 경로(certbot 갱신 훅이 여기에 복사). 없으면 995/993/465 를 열지 않고 평문 포트는 STARTTLS 없이 로그인 거부.
POP_TLS_CERT=@ETC_DIR@/tls/fullchain.pem
POP_TLS_KEY=@ETC_DIR@/tls/privkey.pem
POP_PORT=110
POPS_PORT=995
# IMAP: 993(TLS) / 143(STARTTLS). 인증서는 POP 와 같은 파일을 씁니다.
IMAP_PORT=143
IMAPS_PORT=993
# SMTP 제출(보내는 메일 서버): 587(STARTTLS) / 465(TLS). 로그인 비밀번호로 인증하고 위 RESEND_API_KEY 로 발송합니다.
SMTP_PORT=587
SMTPS_PORT=465
# 110/143 포트의 TLS 없는 로그인: 0.9.7 부터 관리자 페이지(POP / IMAP 서비스 → 일반 접속 로그인 허용, 기본 켬)에서 정합니다.
# 1 로 두면 관리자 설정과 무관하게 항상 허용(진단용). 0 이나 빈 값은 관리자 설정을 따릅니다(예전처럼 "항상 거부" 가 아님).
POP_ALLOW_PLAINTEXT=0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# 내장 Haraka 준비(1.0.0) — root 로 실행: sudo bash <APP_DIR>/repo/deploy/haraka-apply.sh [site.env 경로]
#
# 하는 일(환경 파일·데이터는 건드리지 않습니다)
# 1) <APP_DIR>/haraka 디렉터리(실행 계정 소유)에 haraka/package.json 을 두고 npm 으로 Haraka 와 DKIM 플러그인을 설치합니다(인터넷 필요, 몇 분).
# 2) systemd 서비스 <APP_NAME>-haraka 를 등록·시작합니다. 이 서비스(haraka/run.mjs)는 설정 파일(config.json)의 수·발신 방식이 haraka 일 때만
# Haraka 를 띄우므로, 지금 Resend 만 쓰고 있어도 켜 두어도 아무 포트를 열지 않습니다(설정 화면에서 바꾸는 순간 동작).
# setup.sh 가 새 사이트에 자동으로 부르고, 기존 사이트는 한 번 직접 실행합니다. 다시 실행해도 안전합니다(설치된 버전이 같으면 npm 이 금방 끝남).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
SITE_FILE="${1:-}"
# 사이트 파일: 인자 → <APP_DIR>/site.env(0.8.18 이후 setup.sh 가 둠) → deploy/sites/<SITE>.env(0.8.17 이전에 설치한 서버는 site.env 사본이 없음, deploy.sh 와 같은 폴백).
if [ -n "$SITE_FILE" ] && [ ! -f "$SITE_FILE" ]; then echo "사이트 설정 파일이 없어 저장소의 기본 파일을 씁니다: $SITE_FILE" >&2; SITE_FILE=""; fi
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
echo "▶ Haraka 준비: $SITE ($APP_DIR/haraka, 서비스 $APP_NAME-haraka)"
[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN" >&2; exit 1; }
NPM_BIN="$(dirname "$NODE_BIN")/npm"; [ -x "$NPM_BIN" ] || NPM_BIN="$(command -v npm || true)"
REPO_HARAKA="$APP_DIR/repo/haraka"; [ -f "$REPO_HARAKA/package.json" ] || REPO_HARAKA="$DEPLOY_DIR/../haraka"
mkdir -p "$APP_DIR/haraka"; chown "$APP_USER:$APP_USER" "$APP_DIR/haraka"
install -o "$APP_USER" -g "$APP_USER" -m 644 "$REPO_HARAKA/package.json" "$APP_DIR/haraka/package.json"
if [ -x "$NPM_BIN" ]; then
echo "▶ npm 설치 ($NPM_BIN) — 인터넷이 필요합니다"
# 실행 계정으로 설치해 node_modules 소유자를 맞춥니다. 실패해도(오프라인 등) 서비스는 등록하고 안내만 남깁니다.
if sudo -u "$APP_USER" env PATH="$(dirname "$NODE_BIN"):$PATH" HOME="$APP_DIR/haraka" "$NPM_BIN" install --omit=dev --no-audit --no-fund --no-package-lock --prefix "$APP_DIR/haraka" --loglevel=error; then
echo " 설치됨: Haraka $("$NODE_BIN" -e "console.log(require('$APP_DIR/haraka/node_modules/Haraka/package.json').version)")"
else echo " ⚠️ npm 설치 실패 — 인터넷 연결을 확인하고 다시 실행하세요: sudo bash $0 $SITE_FILE" >&2; fi
else echo " ⚠️ npm 을 찾지 못했습니다($NODE_BIN 옆에 없음). Node 배포판의 npm 을 두고 다시 실행하세요." >&2; fi
REPO_DEPLOY="$APP_DIR/repo/deploy"; [ -f "$REPO_DEPLOY/systemd/mail-haraka.service.in" ] || REPO_DEPLOY="$DEPLOY_DIR"
render "$REPO_DEPLOY/systemd/mail-haraka.service.in" "/etc/systemd/system/$APP_NAME-haraka.service"; chmod 644 "/etc/systemd/system/$APP_NAME-haraka.service"
# 실행 계정이 deploy.sh 에서 이 서비스를 재시작할 수 있게 sudoers 를 최신 규칙으로 다시 씁니다(setup.sh 와 같은 한 줄).
sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q
systemctl daemon-reload
# 첫 배포 전(current 없음)에는 시작하지 않고 등록만 합니다(deploy.sh 가 끝나면 다음 부팅·수동 시작 때 뜸). current 가 있으면 바로 (재)시작.
systemctl enable -q "$APP_NAME-haraka"
if [ -e "$APP_DIR/current/haraka/run.mjs" ]; then systemctl restart "$APP_NAME-haraka"; sleep 1; systemctl is-active --quiet "$APP_NAME-haraka" && echo " 서비스 실행 중: $APP_NAME-haraka" || echo " ⚠️ 서비스가 뜨지 않았습니다: journalctl -u $APP_NAME-haraka -n 50" >&2
else echo " (아직 배포된 릴리스가 없어 서비스는 등록만 했습니다. deploy.sh 뒤 자동으로 시작합니다)"; fi
cat <<MSG
완료. 설정 화면(초기 설정 페이지 또는 /admin → 메일 서비스 설정)에서 수신/발신 방식을 "내장 Haraka" 로 고르면 몇 초 안에 Haraka 가 뜹니다.
- 수신(MX)으로 쓰려면 방화벽에서 25 포트를 열고 DNS 의 MX 를 이 서버로 바꾸세요. 예) sudo ufw allow 25/tcp
- 발신으로 쓰려면 설정 화면에서 DKIM 키를 만들고 안내된 SPF·DKIM TXT 레코드를 DNS 에 올리세요. 서버 IP 의 역방향 DNS(PTR)도 필요합니다.
- 로그: journalctl -u $APP_NAME-haraka -f
MSG
@@ -0,0 +1,12 @@
#!/bin/sh
# certbot 갱신 훅(@MAIL_HOST@): 인증서 사본을 서비스 계정 전용 경로에 두고 nginx·게이트웨이에 반영합니다.
# certbot 의 개인키는 root 만 읽으므로 게이트웨이(@APP_USER@)가 읽을 사본이 필요합니다.
set -e
LINEAGE="${RENEWED_LINEAGE:-/etc/letsencrypt/live/@MAIL_HOST@}"
case "$LINEAGE" in */@MAIL_HOST@*) ;; *) exit 0 ;; esac
mkdir -p @ETC_DIR@/tls
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/fullchain.pem" @ETC_DIR@/tls/fullchain.pem
install -o @APP_USER@ -g @APP_USER@ -m 640 "$LINEAGE/privkey.pem" @ETC_DIR@/tls/privkey.pem
[ "${SKIP_RESTART:-0}" = 1 ] && exit 0
systemctl reload nginx || true
systemctl restart @APP_NAME@ || true
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# setup.sh / deploy.sh / tls-when-ready.sh 가 공유하는 함수(0.8.18~). 직접 실행하지 않고 source 합니다.
#
# load_site <파일> : 사이트 설정(deploy/sites/<이름>.env 또는 $APP_DIR/site.env)을 읽고 필수 값을 확인합니다.
# render <템플릿> <출력> : 템플릿의 @변수@ 자리를 사이트 설정 값으로 바꿔 씁니다(systemd·nginx·certbot 훅·환경 파일).
#
# 예전(0.8.17 까지)에는 모든 파일에 mail.arihakdang.com / arihakdang 계정 / /srv/arihakdang-mail 이 박혀 있어 다른 도메인에 설치할 수 없었습니다.
# 이제 사이트마다 변수 파일 하나로 서비스 이름·계정·경로·호스트·메일함을 정하고, 같은 템플릿으로 설정 파일을 만듭니다.
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 템플릿이 치환하는 변수 목록. 새 변수를 쓰려면 여기와 sites/*.env 에 함께 더합니다.
# 0.9.0: LOGIN_MODE(설치 옵션: standalone | www)와 시스템 제한 LIMIT_*(0 = 제한 없음, 크기는 500M/2G 같은 접미사 가능)을 더했습니다.
SITE_VARS=(SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS)
load_site() {
local file="$1"
[ -f "$file" ] || { echo "사이트 설정 파일이 없습니다: $file" >&2; return 1; }
# shellcheck disable=SC1090
set -a; . "$file"; set +a
for v in SITE APP_NAME APP_USER APP_DIR ETC_DIR NODE_BIN PORT MAIL_HOST MAILBOX; do
[ -n "${!v:-}" ] || { echo "사이트 설정에 $v 가 없습니다: $file" >&2; return 1; }
done
: "${MAIL_FROM_NAME:=$SITE}" "${SITE_TITLE:=}" "${SITE_BRAND:=}" "${WWW_DB_FILE:=}" "${WEBMAKER_DIR:=}" "${WEBMAKER_USER:=}" "${WEBMAKER_SITE:=}" "${EXTRA_RW:=}" "${ACME_WEBROOT:=/var/www/letsencrypt}" "${CF_CERT:=}" "${CF_KEY:=}" "${REPO_URL:=https://source.ibit.kr/OZ-Group/AriMail.git}"
: "${LIMIT_ACCOUNTS:=0}" "${LIMIT_TOTAL_BYTES:=0}" "${LIMIT_MAILBOX_BYTES:=0}" "${LIMIT_ATTACHMENT_BYTES:=0}" "${LIMIT_RECIPIENTS:=0}" "${LIMIT_MONTHLY_MESSAGES:=0}"
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한이 없으므로 기본값(1G / 30일 / 20회)을 둡니다.
: "${LIMIT_LARGE_FILE_BYTES:=1G}" "${LIMIT_LARGE_FILE_DAYS:=30}" "${LIMIT_LARGE_FILE_DOWNLOADS:=20}"
# 로그인 방식(0.9.0 설치 옵션). 비워 두면 WWW_DB_FILE 유무로 정합니다(0.8.x 사이트 파일 호환). standalone 이 기본 설치 형태입니다.
# standalone : www 사이트 없이 게이트웨이 계정만 씀(WWW_DB_FILE 은 비움) www : www 사이트 DB 로 관리자를 인증(WWW_DB_FILE 필수)
: "${LOGIN_MODE:=}"
if [ -z "$LOGIN_MODE" ]; then if [ -n "$WWW_DB_FILE" ]; then LOGIN_MODE=www; else LOGIN_MODE=standalone; fi; fi
# webmaker(1.0.8): 웹메이커 데이터 디렉터리(WEBMAKER_DIR)로 계정을 연동. WEBMAKER_USER(sudo 로 읽을 계정)·WEBMAKER_SITE(사이트 id)는 선택.
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE=""; WEBMAKER_DIR="" ;;
www) WEBMAKER_DIR=""; [ -n "$WWW_DB_FILE" ] || { echo "LOGIN_MODE=www 이면 WWW_DB_FILE(www 사이트 DB 경로)이 필요합니다: $file" >&2; return 1; } ;;
webmaker) WWW_DB_FILE=""; [ -n "$WEBMAKER_DIR" ] || { echo "LOGIN_MODE=webmaker 이면 WEBMAKER_DIR(웹메이커 데이터 디렉터리)이 필요합니다: $file" >&2; return 1; } ;;
*) echo "LOGIN_MODE 는 standalone, www, webmaker 중 하나여야 합니다: $LOGIN_MODE ($file)" >&2; return 1 ;;
esac
export MAIL_FROM_NAME SITE_TITLE SITE_BRAND WWW_DB_FILE WEBMAKER_DIR WEBMAKER_USER WEBMAKER_SITE EXTRA_RW ACME_WEBROOT CF_CERT CF_KEY REPO_URL LOGIN_MODE LIMIT_ACCOUNTS LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES LIMIT_LARGE_FILE_BYTES LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS
}
# sed 치환에서 특별한 뜻을 갖는 글자(구분자 | 와 &, \)를 피합니다.
_sed_escape() { local s="$1"; s="${s//\\/\\\\}"; s="${s//|/\\|}"; s="${s//&/\\&}"; printf '%s' "$s"; }
render() {
local template="$1" out="$2" expr=()
for v in "${SITE_VARS[@]}"; do expr+=(-e "s|@${v}@|$(_sed_escape "${!v:-}")|g"); done
sed "${expr[@]}" "$template" > "$out"
# 치환되지 않은 자리가 남으면 템플릿과 변수 목록이 어긋난 것이므로 바로 알립니다.
if grep -q '@[A-Z_]\{3,\}@' "$out"; then echo "템플릿에 알 수 없는 변수가 있습니다: $template → $(grep -o '@[A-Z_]\{3,\}@' "$out" | sort -u | tr '\n' ' ')" >&2; return 1; fi
}
# 실행 계정이 서비스를 재시작하고 nginx 를 다시 읽을 수 있게 하는 sudoers 규칙 한 줄.
# 1.0.0: 내장 Haraka 관리 서비스(<APP_NAME>-haraka)의 재시작도 허용합니다(deploy.sh 가 새 릴리스로 다시 띄움).
sudoers_line() { printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart %s, /usr/bin/systemctl restart %s-haraka, /usr/bin/systemctl reload nginx\n' "$APP_USER" "$APP_NAME" "$APP_NAME"; }
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
# 새 사이트 설정 파일 만들기(0.9.0 설치 옵션) — 작업 PC 에서: bash deploy/new-site.sh <사이트이름> --host mail.example.com --mailbox admin@example.com [옵션]
#
# deploy/sites/<사이트이름>.env 를 만듭니다. setup.sh 는 이 파일로 디렉터리·환경 파일·systemd·nginx 를 준비합니다.
# 로그인 방식은 --login standalone(기본: www 사이트 없이 게이트웨이 계정만 사용) 또는 --login www --www-db <www DB 경로>(www 사이트 관리자로 인증) 중 고릅니다.
# 시스템 제한(--limit-*)은 설치 시 gateway.env 에 들어가는 상위 설정이며, 0(기본)은 제한 없음입니다. 크기는 500M, 2G 처럼 적습니다.
#
# 옵션: --user <실행 계정>(기본 사이트이름) --app-name <서비스 이름>(기본 <사이트이름>-mail) --app-dir <경로>(기본 /srv/<서비스 이름>) --etc-dir <경로>(기본 /etc/<서비스 이름>)
# --node <node 경로>(기본 /opt/<서비스 이름>-node/bin/node) --port <포트>(기본 8790) --from-name <발신 표시 이름> --title <탭 제목>
# --login standalone|www --www-db <경로> --extra-rw <경로> --repo <저장소 URL> --force(있는 파일 덮어쓰기)
# --limit-accounts N --limit-total 2G --limit-mailbox 500M --limit-attachment 20M --limit-recipients N --limit-monthly N
# --large-bytes 1G --large-days 30 --large-downloads 20 (대용량 첨부파일 시스템 상한, 0.9.3 — 무제한 불가, 기본 1G/30/20)
set -euo pipefail
DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SITE="${1:-}"; [ -n "$SITE" ] && [ "${SITE#-}" = "$SITE" ] || { sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//' >&2; exit 1; }
shift
[[ "$SITE" =~ ^[a-z0-9][a-z0-9-]*$ ]] || { echo "사이트 이름은 영문 소문자·숫자·하이픈이어야 합니다: $SITE" >&2; exit 1; }
MAIL_HOST=""; MAILBOX=""; APP_USER="$SITE"; APP_NAME="$SITE-mail"; APP_DIR=""; ETC_DIR=""; NODE_BIN=""; PORT=8790; MAIL_FROM_NAME=""; SITE_TITLE=""
LOGIN_MODE=standalone; WWW_DB_FILE=""; EXTRA_RW=""; REPO_URL="https://source.ibit.kr/OZ-Group/AriMail.git"; FORCE=0
LIMIT_ACCOUNTS=0; LIMIT_TOTAL_BYTES=0; LIMIT_MAILBOX_BYTES=0; LIMIT_ATTACHMENT_BYTES=0; LIMIT_RECIPIENTS=0; LIMIT_MONTHLY_MESSAGES=0
LIMIT_LARGE_FILE_BYTES=1G; LIMIT_LARGE_FILE_DAYS=30; LIMIT_LARGE_FILE_DOWNLOADS=20
while [ $# -gt 0 ]; do
case "$1" in
--host) MAIL_HOST="$2"; shift 2 ;; --mailbox) MAILBOX="$2"; shift 2 ;; --user) APP_USER="$2"; shift 2 ;; --app-name) APP_NAME="$2"; shift 2 ;;
--app-dir) APP_DIR="$2"; shift 2 ;; --etc-dir) ETC_DIR="$2"; shift 2 ;; --node) NODE_BIN="$2"; shift 2 ;; --port) PORT="$2"; shift 2 ;;
--from-name) MAIL_FROM_NAME="$2"; shift 2 ;; --title) SITE_TITLE="$2"; shift 2 ;; --login) LOGIN_MODE="$2"; shift 2 ;; --www-db) WWW_DB_FILE="$2"; shift 2 ;;
--extra-rw) EXTRA_RW="$2"; shift 2 ;; --repo) REPO_URL="$2"; shift 2 ;; --force) FORCE=1; shift ;;
--limit-accounts) LIMIT_ACCOUNTS="$2"; shift 2 ;; --limit-total) LIMIT_TOTAL_BYTES="$2"; shift 2 ;; --limit-mailbox) LIMIT_MAILBOX_BYTES="$2"; shift 2 ;;
--limit-attachment) LIMIT_ATTACHMENT_BYTES="$2"; shift 2 ;; --limit-recipients) LIMIT_RECIPIENTS="$2"; shift 2 ;; --limit-monthly) LIMIT_MONTHLY_MESSAGES="$2"; shift 2 ;;
--large-bytes) LIMIT_LARGE_FILE_BYTES="$2"; shift 2 ;; --large-days) LIMIT_LARGE_FILE_DAYS="$2"; shift 2 ;; --large-downloads) LIMIT_LARGE_FILE_DOWNLOADS="$2"; shift 2 ;;
*) echo "알 수 없는 옵션: $1" >&2; exit 1 ;;
esac
done
[ -n "$MAIL_HOST" ] && [ -n "$MAILBOX" ] || { echo "--host 와 --mailbox 는 꼭 필요합니다" >&2; exit 1; }
[[ "$MAILBOX" == *@*.* ]] || { echo "--mailbox 는 이메일 주소여야 합니다: $MAILBOX" >&2; exit 1; }
case "$LOGIN_MODE" in
standalone) WWW_DB_FILE="" ;;
www) [ -n "$WWW_DB_FILE" ] || { echo "--login www 에는 --www-db <www 사이트 DB 경로> 가 필요합니다" >&2; exit 1; }; [ -n "$EXTRA_RW" ] || EXTRA_RW="$(dirname "$WWW_DB_FILE")" ;;
*) echo "--login 은 standalone 또는 www 입니다" >&2; exit 1 ;;
esac
for v in LIMIT_ACCOUNTS LIMIT_RECIPIENTS LIMIT_MONTHLY_MESSAGES; do [[ "${!v}" =~ ^[0-9]+$ ]] || { echo "$v 는 0 이상의 정수여야 합니다: ${!v}" >&2; exit 1; }; done
for v in LIMIT_LARGE_FILE_DAYS LIMIT_LARGE_FILE_DOWNLOADS; do [[ "${!v}" =~ ^[1-9][0-9]*$ ]] || { echo "$v 는 1 이상의 정수여야 합니다(무제한 불가): ${!v}" >&2; exit 1; }; done
[[ "$LIMIT_LARGE_FILE_BYTES" =~ ^[1-9][0-9]*(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "LIMIT_LARGE_FILE_BYTES 는 0 이 아닌 크기(예: 1G)여야 합니다: $LIMIT_LARGE_FILE_BYTES" >&2; exit 1; }
for v in LIMIT_TOTAL_BYTES LIMIT_MAILBOX_BYTES LIMIT_ATTACHMENT_BYTES; do [[ "${!v}" =~ ^[0-9]+(\.[0-9]+)?[KkMmGgTt]?[Bb]?$ ]] || { echo "$v 는 크기(예: 500M, 2G, 0)여야 합니다: ${!v}" >&2; exit 1; }; done
: "${APP_DIR:=/srv/$APP_NAME}" "${ETC_DIR:=/etc/$APP_NAME}" "${NODE_BIN:=/opt/$APP_NAME-node/bin/node}" "${MAIL_FROM_NAME:=$SITE}"
OUT="$DEPLOY_DIR/sites/$SITE.env"
[ "$FORCE" = 1 ] || [ ! -e "$OUT" ] || { echo "이미 있습니다: $OUT (덮어쓰려면 --force)" >&2; exit 1; }
cat > "$OUT" <<ENV
# 사이트 설정: $MAIL_HOST (deploy/new-site.sh 가 $(date +%Y-%m-%d) 에 만듦) — 로그인 방식 $LOGIN_MODE
# setup.sh / deploy.sh / tls-when-ready.sh 가 읽는 셸 변수입니다. 설치: sudo env SITE=$SITE SOURCE_BUNDLE=<번들> bash deploy/setup.sh
SITE=$SITE
APP_NAME=$APP_NAME
APP_USER=$APP_USER
APP_DIR=$APP_DIR
ETC_DIR=$ETC_DIR
NODE_BIN=$NODE_BIN
PORT=$PORT
MAIL_HOST=$MAIL_HOST
MAILBOX=$MAILBOX
MAIL_FROM_NAME="$MAIL_FROM_NAME"
SITE_TITLE="$SITE_TITLE"
SITE_BRAND=""
# 로그인 방식(0.9.0 설치 옵션): standalone = www 사이트 없이 게이트웨이 계정만 사용(기본) / www = www 사이트 DB 로 관리자 인증(WWW_DB_FILE 필수)
LOGIN_MODE=$LOGIN_MODE
WWW_DB_FILE=$WWW_DB_FILE
EXTRA_RW=$EXTRA_RW
ACME_WEBROOT=/var/www/letsencrypt
CF_CERT=
CF_KEY=
REPO_URL=$REPO_URL
# 시스템 제한(0.9.0): 메일 시스템 전체의 상위 설정. 0 = 제한 없음. 크기는 500M/2G 식(MIME 변환 뒤 크기 기준).
LIMIT_ACCOUNTS=$LIMIT_ACCOUNTS
LIMIT_TOTAL_BYTES=$LIMIT_TOTAL_BYTES
LIMIT_MAILBOX_BYTES=$LIMIT_MAILBOX_BYTES
LIMIT_ATTACHMENT_BYTES=$LIMIT_ATTACHMENT_BYTES
LIMIT_RECIPIENTS=$LIMIT_RECIPIENTS
LIMIT_MONTHLY_MESSAGES=$LIMIT_MONTHLY_MESSAGES
# 대용량 첨부파일 시스템 상한(0.9.3): 무제한 불가.
LIMIT_LARGE_FILE_BYTES=$LIMIT_LARGE_FILE_BYTES
LIMIT_LARGE_FILE_DAYS=$LIMIT_LARGE_FILE_DAYS
LIMIT_LARGE_FILE_DOWNLOADS=$LIMIT_LARGE_FILE_DOWNLOADS
ENV
echo "✔ 만들었습니다: $OUT"
echo "다음: 서버에서 sudo env SITE=$SITE SOURCE_BUNDLE=/tmp/arimail.bundle bash <저장소>/deploy/setup.sh"
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# nginx 설정만 다시 적용(0.9.5~) — root 로: sudo bash deploy/nginx-apply.sh <APP_DIR>/site.env
#
# setup.sh 전체를 다시 돌리지 않고(환경 파일·인증서 사본은 건드리지 않음) 저장소의 nginx 템플릿을 사이트 설정으로 채워
# 기존 vhost 파일을 바꿔 쓰고 nginx -t 로 검사한 뒤 reload 합니다. 템플릿은 setup.sh 와 같은 규칙으로 고릅니다
# (Let's Encrypt 인증서 → mail.conf.in, Cloudflare 오리진 인증서 → mail.cloudflare.conf.in, 둘 다 없으면 80 전용).
# vhost 파일 경로: sites-enabled 에서 이 호스트(server_name)를 쓰는 파일이 있으면 그 실제 파일(예전 이름 mail.arihakdang.conf 도 포함), 없으면 sites-available/<MAIL_HOST>.conf.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
load_site "${1:?site.env 경로}"
REPO_DEPLOY="$(cd "$(dirname "$0")" && pwd)"
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.conf.in"
elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then TEMPLATE="$REPO_DEPLOY/nginx/mail.cloudflare.conf.in"
else TEMPLATE="$REPO_DEPLOY/nginx/mail.http-only.conf.in"; fi
TARGET=""
for f in /etc/nginx/sites-enabled/*; do [ -f "$f" ] || continue; if grep -qE "^\s*server_name\s+$MAIL_HOST;" "$f"; then TARGET="$(readlink -f "$f")"; break; fi; done
[ -n "$TARGET" ] || { TARGET="/etc/nginx/sites-available/$MAIL_HOST.conf"; ln -sfn "$TARGET" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"; }
BACKUP="$TARGET.bak-$(date +%Y%m%d-%H%M%S)"; [ -f "$TARGET" ] && cp "$TARGET" "$BACKUP"
render "$TEMPLATE" "$TARGET"; chmod 644 "$TARGET"
if nginx -t; then systemctl reload nginx; echo "✔ nginx 적용: $TARGET ($(basename "$TEMPLATE")), 이전 설정 $BACKUP"
else echo "nginx 검사 실패 — 이전 설정으로 되돌립니다" >&2; [ -f "$BACKUP" ] && cp "$BACKUP" "$TARGET"; exit 1; fi
@@ -0,0 +1,27 @@
# AriMail 경로 접두사(1.0.8, 요구사항 1): 이 사이트의 @MAIL_PATH@/ 아래를 게이트웨이(127.0.0.1:@PORT@)로 넘깁니다. deploy/site-mail-path.sh 가 만들어
# /etc/nginx/snippets/ 에 두고, 사이트의 server(443) 블록에 include 합니다. 서브 도메인 사이트(site.example.com)처럼 메일용 호스트 이름을 따로 두기 어려울 때
# https://site.example.com@MAIL_PATH@/ 가 그 도메인의 웹 메일이 됩니다(전체 설정 화면의 호스트 항목은 "site.example.com@MAIL_PATH@").
#
# - proxy_pass 끝의 "/" 가 @MAIL_PATH@/ 를 떼고 넘기므로 게이트웨이는 늘 / 기준 경로를 봅니다. 대신 X-Forwarded-Prefix 로 접두사를 알려 주면 화면(<base>)·링크가 그 아래로 만들어집니다.
# - ^~ : 사이트 설정의 정규식 location(예: 숨김 파일 차단)이 이 경로를 가로채지 못하게 합니다.
# - 대용량 첨부파일(@MAIL_PATH@/api/files)은 본문 크기 제한 없이 버퍼링 없이 흘려보냅니다(mail.conf.in 과 같은 이유).
location = @MAIL_PATH@ { return 301 @MAIL_PATH@/; }
location ^~ @MAIL_PATH@/ {
proxy_pass http://127.0.0.1:@PORT@/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Prefix @MAIL_PATH@;
proxy_set_header CF-Connecting-IP "";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 0;
proxy_request_buffering off;
proxy_buffering off;
proxy_no_cache 1;
proxy_cache_bypass 1;
# add_header 를 두지 않는 이유: location 안의 add_header 는 사이트 server 블록의 헤더(HSTS 등) 상속을 끊습니다. 캐시 금지·보안 헤더는 게이트웨이가 응답마다 붙입니다.
}
@@ -0,0 +1,76 @@
# @MAIL_HOST@ 가 Cloudflare 프록시(주황 구름) 뒤에 있을 때의 443 설정.
# Cloudflare 오리진 인증서(@CF_CERT@)를 써서 웹 화면과 웹훅을 바로 제공합니다.
# POP3·IMAP·SMTP 는 프록시를 지나지 못하므로, 메일 프로그램을 쓰려면 DNS 를 회색 구름으로 바꾸고
# Let's Encrypt 발급 후 setup.sh 로 mail.conf 로 교체합니다.
server {
listen 80; listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 301 https://@MAIL_HOST@$request_uri; }
}
server {
listen 443 ssl http2; listen [::]:443 ssl http2;
server_name @MAIL_HOST@;
ssl_certificate @CF_CERT@;
ssl_certificate_key @CF_KEY@;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off; charset utf-8; client_max_body_size 32m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cache-Control "no-store, no-transform" always;
# 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다.
# 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다.
# 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다.
location = /api/files {
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다.
location /dl/ {
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP $http_cf_connecting_ip;
proxy_read_timeout 180s;
proxy_no_cache 1; proxy_cache_bypass 1;
}
access_log /var/log/nginx/@APP_NAME@.access.log;
error_log /var/log/nginx/@APP_NAME@.error.log;
}
+90
View File
@@ -0,0 +1,90 @@
# @MAIL_HOST@ — 웹 메일함과 Resend 웹훅. POP3·IMAP·SMTP 포트는 Node 프로세스가 직접 엽니다.
#
# DNS: @MAIL_HOST@ 는 이 서버를 직접 가리키는 A 레코드여야 합니다(Cloudflare 를 쓰면 프록시 끔, 회색 구름).
# 메일 프로그램 포트는 프록시를 지나지 못하고 공개 CA 인증서를 요구하므로 Let's Encrypt 인증서를 원본 서버에 둡니다.
#
# 인증서: certbot certonly --webroot -w @ACME_WEBROOT@ -d @MAIL_HOST@ (아래 경로가 만들어집니다)
server {
listen 80;
listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 301 https://@MAIL_HOST@$request_uri; }
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name @MAIL_HOST@;
ssl_certificate /etc/letsencrypt/live/@MAIL_HOST@/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/@MAIL_HOST@/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
server_tokens off;
charset utf-8;
client_max_body_size 32m;
# 로그인 전용 화면: 색인·캐시·프레임 금지. 응답 헤더는 Node 도 같이 붙이지만 nginx 레벨에서도 보장합니다.
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
add_header X-Robots-Tag "noindex, nofollow" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Cache-Control "no-store, no-transform" always;
# 대용량 첨부파일 업로드(0.9.5): 본문 크기 제한을 두지 않고(크기는 Node 가 관리자·시스템 설정으로 거부) 버퍼링 없이 바로 흘려보냅니다.
# 예전에는 server 블록의 client_max_body_size 32m 에 걸려 32MB 가 넘는 파일이 nginx 의 413(HTML)으로 끊겼고 화면에는 일반 오류만 보였습니다.
# 버퍼링을 끄는 이유: 켜 두면 nginx 가 1GB 를 임시 파일에 다 받은 뒤 넘기므로 브라우저의 진행률은 100% 인데 서버 저장은 한참 뒤에 끝나고 디스크를 두 번 씁니다.
location = /api/files {
client_max_body_size 0;
proxy_request_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_no_cache 1;
proxy_cache_bypass 1;
}
# 대용량 첨부파일 다운로드 링크: 큰 파일을 오래 흘려보내므로 응답 버퍼링을 끄고 시간 제한을 넉넉히 둡니다.
location /dl/ {
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_no_cache 1;
proxy_cache_bypass 1;
}
location / {
proxy_pass http://127.0.0.1:@PORT@;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Prefix "";
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header CF-Connecting-IP "";
proxy_read_timeout 180s;
proxy_no_cache 1;
proxy_cache_bypass 1;
}
access_log /var/log/nginx/@APP_NAME@.access.log;
error_log /var/log/nginx/@APP_NAME@.error.log;
}
@@ -0,0 +1,8 @@
# 인증서 발급 전 임시 설정: ACME 챌린지만 받고 나머지는 503. 발급 후 setup.sh(또는 tls-when-ready 타이머)가 443 설정으로 교체합니다.
server {
listen 80;
listen [::]:80;
server_name @MAIL_HOST@;
location /.well-known/acme-challenge/ { root @ACME_WEBROOT@; }
location / { return 503; }
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# 최초 1회 서버 준비(이후 인증서 발급 뒤에 다시 실행) — root 로 실행:
# sudo env SITE=heart-m SOURCE_BUNDLE=/tmp/arimail.bundle bash deploy/setup.sh
# SITE 는 deploy/sites/<SITE>.env 를 고릅니다(기본 arihakdang). 그 파일의 실행 계정(APP_USER)과 Node(NODE_BIN)는 미리 있어야 합니다.
# 새 사이트 파일은 deploy/new-site.sh 로 만듭니다(0.9.0). LOGIN_MODE(standalone | www)와 시스템 제한(LIMIT_*)은 그 파일에서 정하며,
# 환경 파일(gateway.env)은 처음 한 번만 만들므로 이후 바꾸려면 $ETC_DIR/gateway.env 를 직접 고치고 서비스를 재시작합니다.
# 하는 일: 디렉터리·사이트 설정 사본·환경 파일 → 저장소(번들 또는 원격) → systemd·sudoers → nginx(인증서 유무에 따라 80 전용/443) → certbot 훅 → 내장 Haraka(haraka-apply.sh, 1.0.0).
# 1.0.0: 운영 설정(Resend 키 등)은 환경 파일이 아니라 데이터 디렉터리의 config.json 에 있으며 배포 뒤 초기 설정 페이지에서 채웁니다(값이 없어도 배포는 성공).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'sudo 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
SITE="${SITE:-arihakdang}"
load_site "$DEPLOY_DIR/sites/$SITE.env"
echo "▶ 사이트: $SITE ($MAIL_HOST, 계정 $APP_USER, $APP_DIR, 로그인 $LOGIN_MODE)"
id "$APP_USER" >/dev/null 2>&1 || { echo "실행 계정 $APP_USER 이 없습니다. 계정을 먼저 만드세요(www 연동 사이트라면 www 배포 문서를 따르세요)." >&2; exit 1; }
[ -x "$NODE_BIN" ] || { echo "Node 실행 파일이 없습니다: $NODE_BIN — nodejs.org 의 Linux 바이너리(22.13+)를 풀어 두세요(예: tar -xJf node-v22.x-linux-x64.tar.xz -C $(dirname "$(dirname "$NODE_BIN")") --strip-components=1)." >&2; exit 1; }
MAJOR="$("$NODE_BIN" --version | sed -E 's/^v([0-9]+).*/\1/')"; [ "$MAJOR" -ge 22 ] || { echo "Node 22.13+ 필요: $("$NODE_BIN" --version)" >&2; exit 1; }
mkdir -p "$APP_DIR"/{repo,releases,data} "$ETC_DIR" "$ACME_WEBROOT"
chown -R "$APP_USER:$APP_USER" "$APP_DIR"
# 사이트 설정 사본: deploy.sh 와 tls-when-ready.sh 가 저장소 밖의 이 파일로 자기 사이트를 압니다.
install -o "$APP_USER" -g "$APP_USER" -m 644 "$DEPLOY_DIR/sites/$SITE.env" "$APP_DIR/site.env"
if [ ! -f "$ETC_DIR/gateway.env" ]; then
render "$DEPLOY_DIR/gateway.env.in" "$ETC_DIR/gateway.env"
chown "$APP_USER:$APP_USER" "$ETC_DIR/gateway.env"; chmod 600 "$ETC_DIR/gateway.env"
echo "→ $ETC_DIR/gateway.env 를 만들었습니다(배포에 필요한 값만). Resend 키 등 운영 설정은 배포 뒤 초기 설정 페이지에서 채웁니다."
fi
if [ ! -d "$APP_DIR/repo/.git" ]; then
if [ -n "${SOURCE_BUNDLE:-}" ]; then
echo '→ 번들에서 저장소를 만듭니다 (서버에 원격 자격 증명을 남기지 않음).'
sudo -u "$APP_USER" git clone -q -b main "$SOURCE_BUNDLE" "$APP_DIR/repo"
sudo -u "$APP_USER" git -C "$APP_DIR/repo" remote set-url origin "$REPO_URL"
else
echo "→ 저장소를 $APP_USER 계정으로 복제합니다 ($REPO_URL 자격 증명 필요)."
sudo -u "$APP_USER" git clone "$REPO_URL" "$APP_DIR/repo"
fi
fi
REPO_DEPLOY="$APP_DIR/repo/deploy"
render "$REPO_DEPLOY/systemd/mail-gateway.service.in" "/etc/systemd/system/$APP_NAME.service"; chmod 644 "/etc/systemd/system/$APP_NAME.service"
# 배포 스크립트(실행 계정)가 서비스 재시작과 nginx 재적재만 할 수 있게 합니다.
sudoers_line > "/etc/sudoers.d/$APP_NAME"; chmod 440 "/etc/sudoers.d/$APP_NAME"; visudo -c -q
# nginx: 인증서가 있으면 443, Cloudflare 오리진 인증서만 있으면 프록시용 443, 둘 다 없으면 80(ACME 챌린지)만 엽니다.
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then
render "$REPO_DEPLOY/nginx/mail.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
elif [ -n "$CF_CERT" ] && [ -f "$CF_CERT" ]; then
render "$REPO_DEPLOY/nginx/mail.cloudflare.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
else
render "$REPO_DEPLOY/nginx/mail.http-only.conf.in" "/etc/nginx/sites-available/$MAIL_HOST.conf"
fi
chmod 644 "/etc/nginx/sites-available/$MAIL_HOST.conf"
ln -sfn "/etc/nginx/sites-available/$MAIL_HOST.conf" "/etc/nginx/sites-enabled/$MAIL_HOST.conf"
nginx -t && systemctl reload nginx
systemctl daemon-reload
systemctl enable -q "$APP_NAME"
# 메일 포트 TLS: certbot 의 개인키는 root 만 읽으므로, 실행 계정이 읽을 사본을 만들고 갱신 때마다 다시 복사합니다.
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
render "$REPO_DEPLOY/letsencrypt-deploy-hook.sh.in" "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"; chmod 755 "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then
RENEWED_LINEAGE="/etc/letsencrypt/live/$MAIL_HOST" SKIP_RESTART=1 sh "/etc/letsencrypt/renewal-hooks/deploy/$APP_NAME.sh"
sed -i -E "s#^POP_TLS_CERT=.*#POP_TLS_CERT=$ETC_DIR/tls/fullchain.pem#; s#^POP_TLS_KEY=.*#POP_TLS_KEY=$ETC_DIR/tls/privkey.pem#" "$ETC_DIR/gateway.env"
# 서비스가 이미 돌고 있으면 새 인증서로 다시 띄웁니다(처음 설치라면 아직 current 가 없어 deploy.sh 가 시작합니다).
if systemctl is-active --quiet "$APP_NAME"; then systemctl restart "$APP_NAME"; fi
else
# 인증서가 생길 때까지 15분마다 DNS 를 확인해 자동 발급하는 타이머를 켭니다(발급되면 스스로 꺼짐).
render "$REPO_DEPLOY/systemd/mail-tls.service.in" "/etc/systemd/system/$APP_NAME-tls.service"
render "$REPO_DEPLOY/systemd/mail-tls.timer.in" "/etc/systemd/system/$APP_NAME-tls.timer"
systemctl daemon-reload; systemctl enable -q --now "$APP_NAME-tls.timer"
cat <<MSG
→ TLS 인증서가 아직 없습니다. DNS($MAIL_HOST → 이 서버, Cloudflare 프록시 끔)가 바뀌면 $APP_NAME-tls.timer 가 15분 안에
certbot 으로 인증서를 받고 443 설정과 메일 포트 TLS(995/993/465)를 켭니다. 바로 하려면:
sudo bash $REPO_DEPLOY/tls-when-ready.sh $APP_DIR/site.env
MSG
fi
if [ -z "$WWW_DB_FILE" ]; then
cat <<MSG
→ 독립 실행(www 연동 없음)입니다. 첫 배포 때 서버가 초기 관리자 계정(${MAILBOX%@*} / adminpasswd)을 자동으로 만듭니다(0.8.24~).
https://$MAIL_HOST/admin 에 로그인해 바로 비밀번호를 바꾸세요. 추가 계정·비밀번호 재설정은 관리자 페이지 또는:
sudo -u $APP_USER env \$(grep -E '^(DB_FILE|MAILBOX)=' $ETC_DIR/gateway.env | xargs) $NODE_BIN $APP_DIR/current/server/user-cli.mjs passwd ${MAILBOX%@*}
MSG
fi
# 내장 Haraka(1.0.0): npm 설치와 관리 서비스 등록. 실패해도(오프라인) 설치는 계속되고 설정 화면이 안내합니다.
bash "$REPO_DEPLOY/haraka-apply.sh" "$APP_DIR/site.env" || echo "⚠️ Haraka 준비에 실패했습니다. 나중에 다시: sudo bash $REPO_DEPLOY/haraka-apply.sh $APP_DIR/site.env" >&2
echo "완료. 다음: sudo -u $APP_USER env SOURCE_BUNDLE=... $APP_DIR/repo/deploy/deploy.sh"
echo "배포가 끝나면 초기 설정 페이지 주소(포트 8791, 토큰 포함)가 출력됩니다. 방화벽을 쓰면 8791 포트를 열어 두세요(예: sudo ufw allow 8791/tcp)."
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# 사이트의 /mail 경로를 AriMail 웹 메일로 잇습니다(1.0.8, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/site-mail-path.sh <nginx 설정 파일 | 사이트 호스트 이름> [--path /mail] [--site <site.env 경로>]
# 예) sudo bash deploy/site-mail-path.sh site.ariweb.net # sites-enabled 에서 server_name 에 그 이름이 있는 설정 파일을 찾음
# sudo bash deploy/site-mail-path.sh /etc/nginx/sites-enabled/wh-0aa8….conf
#
# 왜 필요한가
# 웹메이커가 만든 서브 도메인 사이트(site.ariweb.net)는 메일용 호스트 이름(mail.site.ariweb.net)을 따로 두기 어렵습니다(와일드카드 인증서가 한 단계만 덮음).
# 그래서 그 사이트 주소의 /mail 아래를 게이트웨이로 넘겨 https://site.ariweb.net/mail/ 이 그 도메인의 웹 메일이 되게 합니다. 게이트웨이는 Host 로 도메인(테넌트)을
# 고르고 X-Forwarded-Prefix 로 화면의 기준 경로를 압니다. 전체 설정 화면의 호스트 항목에는 "site.ariweb.net/mail" 처럼 경로까지 적습니다.
#
# 하는 일
# 1) deploy/nginx/mail-path.conf.in 을 포트·경로로 채워 /etc/nginx/snippets/<APP_NAME>-mail-path.conf 로 둡니다(여러 사이트가 같은 조각을 include).
# 2) 지정한 설정 파일의 443 server 블록마다 그 조각의 include 줄을 넣습니다(이미 있으면 그대로, 80 전용 블록은 건드리지 않음 — https 로의 301 이 유지되게).
# 바꾸기 전 사본(<파일>.bak-<시각>)을 남기고 nginx -t 가 실패하면 되돌립니다.
# 3) nginx 를 다시 읽습니다.
# 주의: 웹메이커는 사이트를 다시 발행할 때 그 vhost 파일(wh-<사이트ID>.conf)을 다시 쓰므로 include 줄이 사라질 수 있습니다. 그때는 이 명령을 다시 실행하거나,
# 웹메이커의 vhost 템플릿(lib/deploy.js vhostBlock)에 같은 include 를 넣어 두세요. 게이트웨이 설정·데이터는 건드리지 않습니다.
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
TARGET=""; MAIL_PATH=/mail; SITE_FILE=""
while [ $# -gt 0 ]; do
case "$1" in
--path) MAIL_PATH="$2"; shift 2 ;;
--site) SITE_FILE="$2"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) [ -z "$TARGET" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; TARGET="$1"; shift ;;
esac
done
[ -n "$TARGET" ] || { echo "nginx 설정 파일이나 사이트 호스트 이름을 주세요(--help)" >&2; exit 1; }
printf '%s' "$MAIL_PATH" | grep -Eq '^/[A-Za-z0-9._~-]+(/[A-Za-z0-9._~-]+)*$' || { echo "경로 형식이 아닙니다: $MAIL_PATH (예: /mail)" >&2; exit 1; }
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
# 설정 파일 찾기: 파일 경로면 그대로, 아니면 sites-enabled 에서 server_name 줄에 그 호스트가 있는 파일(하나여야 함).
if [ -f "$TARGET" ]; then CONF="$TARGET"; else
HOST_LC="$(printf '%s' "$TARGET" | tr 'A-Z' 'a-z')"
mapfile -t FOUND < <(grep -lE "^[[:space:]]*server_name[[:space:]]+([^;]*[[:space:]])?${HOST_LC//./\\.}([[:space:];]|$)" /etc/nginx/sites-enabled/*.conf /etc/nginx/sites-enabled/* /etc/nginx/conf.d/*.conf 2>/dev/null | sort -u || true)
[ "${#FOUND[@]}" -gt 0 ] || { echo "server_name 에 $HOST_LC 가 있는 nginx 설정을 찾지 못했습니다(사이트를 먼저 발행하세요)" >&2; exit 1; }
[ "${#FOUND[@]}" -eq 1 ] || { echo "설정 파일이 여럿입니다 — 파일 경로를 직접 주세요: ${FOUND[*]}" >&2; exit 1; }
CONF="${FOUND[0]}"
fi
SNIPPET="/etc/nginx/snippets/$APP_NAME-mail-path.conf"; INCLUDE=" include $SNIPPET;"
mkdir -p /etc/nginx/snippets
sed -e "s|@PORT@|$PORT|g" -e "s|@MAIL_PATH@|$MAIL_PATH|g" "$DEPLOY_DIR/nginx/mail-path.conf.in" > "$SNIPPET"; chmod 644 "$SNIPPET"
echo "▶ $CONF 의 443 블록에 $MAIL_PATH → 127.0.0.1:$PORT (조각 $SNIPPET)"
BAK="$CONF.bak-$(date +%Y%m%d-%H%M%S)"; cp -p "$CONF" "$BAK"
# server 블록 단위로 읽어 443 을 듣고 include 가 없는 블록의 닫는 괄호 앞에 include 를 넣습니다. 블록은 "server {" 로 열리고 첫 칸의 "}" 로 닫힙니다(웹메이커·AriMail 템플릿 모두 이 꼴).
awk -v inc="$INCLUDE" -v snippet="$SNIPPET" '
/^[[:space:]]*server[[:space:]]*\{[[:space:]]*$/ { inblock = 1; buf = ""; ssl = 0; has = 0 }
inblock {
buf = buf $0 "\n"
if ($0 ~ /^[[:space:]]*listen[^;]*[^0-9]443([^0-9]|$)/) ssl = 1
if (index($0, snippet)) has = 1
if ($0 ~ /^\}[[:space:]]*$/) { if (ssl && !has) { sub(/\}[[:space:]]*\n$/, inc "\n}\n", buf); added++ } printf "%s", buf; inblock = 0 }
next
}
{ print }
END { if (inblock) printf "%s", buf; if (!added) print "(이미 적용되어 있거나 443 블록이 없음)" > "/dev/stderr" }
' "$BAK" > "$CONF"
# 닫는 괄호를 못 찾아 블록이 잘리는 일이 없도록 줄 수를 비교합니다(include 줄만 늘어야 함). 사본은 성공해도 남겨 둡니다.
[ "$(wc -l < "$CONF")" -ge "$(wc -l < "$BAK")" ] || { cp -p "$BAK" "$CONF"; echo "✖ 설정 파일 구조를 해석하지 못해 되돌렸습니다(블록 닫는 괄호가 첫 칸에 있어야 함): $CONF" >&2; exit 1; }
if nginx -t; then systemctl reload nginx; echo "✔ https://<사이트 호스트>$MAIL_PATH/ 준비 완료(사본 $BAK). 전체 설정 화면의 도메인 호스트 항목에 \"<사이트 호스트>$MAIL_PATH\" 를 넣으세요."
else cp -p "$BAK" "$CONF"; echo "✖ nginx 설정 검사 실패 — 원래 파일로 되돌렸습니다($BAK 는 남김)" >&2; exit 1; fi
@@ -0,0 +1,39 @@
[Unit]
Description=@APP_NAME@ — AriMail gateway (@MAIL_HOST@: webmail, IMAP/POP3/SMTP, Resend or Haraka)
Documentation=https://source.ibit.kr/OZ-Group/AriMail
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
# 사이트 설정(deploy/sites/@SITE@.env)의 실행 계정. www 사이트와 연동하면 www DB 를 같은 권한으로 읽기 위해 www 와 같은 계정을 씁니다.
User=@APP_USER@
Group=@APP_USER@
WorkingDirectory=@APP_DIR@/current
EnvironmentFile=@ETC_DIR@/gateway.env
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=@PORT@
ExecStart=@NODE_BIN@ server/index.mjs
Restart=always
RestartSec=3
# 110/143/465/587/993/995 포트를 일반 계정으로 열기 위한 권한
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
# 게이트웨이 DB·설정 파일(config.json, 1.0.0)·초기 설정 토큰과(www 연동 시) WAL 모드 www DB 를 읽기 위한 공유 메모리(-shm) 경로
ReadWritePaths=@APP_DIR@/data @EXTRA_RW@
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=@APP_NAME@
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,42 @@
[Unit]
Description=@APP_NAME@ — AriMail 내장 Haraka 관리(수신 25 / 로컬 제출 포트, 설정 파일에 따라 켜고 끔)
Documentation=https://source.ibit.kr/OZ-Group/AriMail
After=network-online.target @APP_NAME@.service
Wants=network-online.target
[Service]
Type=simple
User=@APP_USER@
Group=@APP_USER@
WorkingDirectory=@APP_DIR@/current
# 게이트웨이와 같은 환경 파일: DB_FILE(설정 파일 위치)·PORT(게이트웨이 HTTP)·MAILBOX(도메인)·PUBLIC_HOST·POP_TLS_* 를 읽습니다. 값이 없어도 뜹니다.
EnvironmentFile=@ETC_DIR@/gateway.env
Environment=NODE_ENV=production
Environment=PORT=@PORT@
Environment=HARAKA_DIR=@APP_DIR@/haraka
# haraka/run.mjs 는 config.json 을 5초마다 보고 Haraka(자식 프로세스)를 띄우거나 끕니다. 쓰지 않을 때는 아무 포트도 열지 않고 대기만 합니다.
ExecStart=@NODE_BIN@ @APP_DIR@/current/haraka/run.mjs
Restart=always
RestartSec=3
KillMode=mixed
TimeoutStopSec=15
# 25 포트를 일반 계정으로 열기 위한 권한(자식 프로세스 Haraka 도 상속)
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
# haraka/: 설정·플러그인·큐·node_modules, data/: 상태 파일(haraka-status.json)
ReadWritePaths=@APP_DIR@/haraka @APP_DIR@/data
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
StandardOutput=journal
StandardError=journal
SyslogIdentifier=@APP_NAME@-haraka
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,8 @@
[Unit]
Description=@APP_NAME@ — DNS 가 이 서버를 가리키면 Let's Encrypt 인증서를 받고 443·메일 포트 TLS 를 켭니다
After=network-online.target nginx.service
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/env bash @APP_DIR@/repo/deploy/tls-when-ready.sh @APP_DIR@/site.env
@@ -0,0 +1,10 @@
[Unit]
Description=@APP_NAME@ — 인증서가 생길 때까지 15분마다 DNS 를 확인해 TLS 를 켭니다(발급되면 스스로 멈춤)
[Timer]
OnBootSec=2min
OnUnitActiveSec=15min
Unit=@APP_NAME@-tls.service
[Install]
WantedBy=timers.target
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# 인증서 자동 발급(0.8.18~) — root 로 실행(보통 <APP_NAME>-tls.timer 가 15분마다 부름): bash deploy/tls-when-ready.sh <site.env>
#
# 새 서버에 설치할 때 DNS(MAIL_HOST → 이 서버)가 아직 바뀌지 않았으면 Let's Encrypt HTTP-01 검증이 실패하므로 setup.sh 는
# 80 포트만 여는 임시 nginx 설정으로 끝납니다. 이 스크립트는 MAIL_HOST 의 A 레코드가 이 서버의 IP 와 같아지는 순간을 기다렸다가
# certbot 으로 인증서를 받고 setup.sh 를 다시 실행해 443 설정·인증서 사본·메일 포트 TLS 를 켠 뒤 타이머를 멈춥니다.
# 이미 인증서가 있으면 아무 일도 하지 않고 타이머만 끕니다(갱신은 certbot 의 자체 타이머가 합니다).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
load_site "${1:?site.env 경로}"
TIMER="$APP_NAME-tls.timer"
stop_timer() { systemctl disable --now "$TIMER" >/dev/null 2>&1 || true; }
if [ -d "/etc/letsencrypt/live/$MAIL_HOST" ]; then echo "이미 인증서가 있습니다: $MAIL_HOST"; stop_timer; exit 0; fi
# DNS 확인: 권위 DNS 가 아니라 이 서버의 해석기 기준입니다(certbot 검증 서버가 보는 값과 보통 같습니다).
RESOLVED="$(getent ahostsv4 "$MAIL_HOST" 2>/dev/null | awk '{print $1}' | sort -u || true)"
MINE="$(ip -4 -o addr show scope global | awk '{print $4}' | cut -d/ -f1 | sort -u)"
MATCH=""; for a in $RESOLVED; do for b in $MINE; do [ "$a" = "$b" ] && MATCH="$a"; done; done
if [ -z "$MATCH" ]; then echo "$MAIL_HOST 가 아직 이 서버를 가리키지 않습니다 (DNS: ${RESOLVED:-없음} / 서버: $MINE) — 다음에 다시 확인"; exit 0; fi
echo "DNS 확인됨($MAIL_HOST → $MATCH). 인증서를 발급합니다"
mkdir -p "$ACME_WEBROOT"
# 등록된 certbot 계정이 없으면 이메일 없이 등록합니다(만료 안내 메일을 받으려면 나중에 certbot register/update_account 로 바꾸세요).
ACCOUNT_FLAG=""; ls /etc/letsencrypt/accounts/*/directory/*/regr.json >/dev/null 2>&1 || ACCOUNT_FLAG="--register-unsafely-without-email"
certbot certonly --webroot -w "$ACME_WEBROOT" -d "$MAIL_HOST" --non-interactive --agree-tos --keep-until-expiring $ACCOUNT_FLAG
echo "발급 완료. setup.sh 를 다시 실행해 443 설정과 메일 포트 TLS 를 켭니다"
SITE="$SITE" bash "$APP_DIR/repo/deploy/setup.sh"
stop_timer
echo "✔ TLS 준비 완료: https://$MAIL_HOST/"
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# 웹메이커(webmaker) 계정 연동을 위한 읽기 도우미 설치(1.0.8, 요구사항 1) — root 로 실행:
# sudo bash <APP_DIR>/repo/deploy/webmaker-access.sh [<웹메이커 DATA_ROOT>] [--site <site.env 경로>] [--node <node 실행 파일>] [--sudo]
#
# 왜 필요한가
# webmaker 인증 모드는 웹메이커의 사용자 파일(DATA_ROOT/users.json, sites/, modules/, domains.json)을 읽기 전용으로 참조합니다. 그런데 웹메이커는 그 파일을
# 자기 서비스 계정(예: master)만 읽을 수 있게(0600/0700) 쓰므로 AriMail 실행 계정(예: arihakdang)이 직접 읽을 수 없습니다. 웹메이커가 파일을 다시 쓸 때마다
# 권한이 0600 으로 돌아가므로 ACL 로도 풀리지 않습니다. 그래서 "그 계정으로 스냅샷 하나를 읽어 JSON 으로 돌려주는" 도우미를 둡니다.
#
# 하는 일
# 1) server/webmaker-snapshot.mjs 의 사본을 root 소유로 /usr/local/lib/arimail/ 에 둡니다(실행 계정이 바꿀 수 없어야 다른 계정으로 도는 코드가 안전).
# 실행용 node 도 root 소유여야 하므로 /usr/bin/node(22 이상)를 먼저 찾고, 없으면 --node 로 지정한 root 소유 파일을 씁니다.
# 2) systemd 소켓 도우미(기본): /run/arimail-webmaker/<슬러그>.sock 에 접속하면 systemd 가 웹메이커 소유 계정으로 도우미를 띄워 JSON 을 소켓에 씁니다(Accept=yes).
# AriMail 서비스 유닛은 NoNewPrivileges=yes·CapabilityBoundingSet 로 잠겨 있어 sudo 가 동작하지 않으므로 이 방식이 필요합니다. 소켓은 AriMail 실행 계정의 그룹만 접속(0660).
# 3) --sudo: systemd 가 없는 서버용 — 감싸는 스크립트 /usr/local/lib/arimail/webmaker-snapshot 와 sudoers 규칙 한 줄(그 디렉터리 인수 하나만 허용)을 설치합니다.
# 4) 실행 계정으로 한 번 읽어 보고(계정 수 출력), 전체 설정 화면에서 적을 값(디렉터리·읽을 계정)을 안내합니다.
# 게이트웨이 설정·환경 파일·데이터와 웹메이커의 파일은 건드리지 않습니다. 다시 실행해도 안전합니다(같은 내용으로 덮어씀).
set -euo pipefail
[ "$(id -u)" = 0 ] || { echo 'root 로 실행하세요' >&2; exit 1; }
. "$(dirname "$0")/lib.sh"
DATA_ROOT=""; SITE_FILE=""; NODE_OVERRIDE=""; MODE=socket
while [ $# -gt 0 ]; do
case "$1" in
--site) SITE_FILE="$2"; shift 2 ;;
--node) NODE_OVERRIDE="$2"; shift 2 ;;
--sudo) MODE=sudo; shift ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) [ -z "$DATA_ROOT" ] || { echo "알 수 없는 인수: $1" >&2; exit 1; }; DATA_ROOT="$1"; shift ;;
esac
done
if [ -z "$SITE_FILE" ]; then APP_GUESS="$(cd "$DEPLOY_DIR/../.." && pwd)"; if [ -f "$APP_GUESS/site.env" ]; then SITE_FILE="$APP_GUESS/site.env"; else SITE_FILE="$DEPLOY_DIR/sites/${SITE:-arihakdang}.env"; fi; fi
load_site "$SITE_FILE"
# 데이터 디렉터리: 인수 → webmaker 서비스의 환경 변수(DATA_ROOT) → /data/webmaker
if [ -z "$DATA_ROOT" ]; then
DATA_ROOT="$(systemctl show -p Environment webmaker 2>/dev/null | tr ' ' '\n' | sed -n 's/^DATA_ROOT=//p' | head -1 || true)"
[ -n "$DATA_ROOT" ] || DATA_ROOT=/data/webmaker
fi
DATA_ROOT="${DATA_ROOT%/}"
# sudoers·유닛 파일에 그대로 적히므로 경로에 허용하는 글자를 제한합니다(공백·와일드카드·쉼표가 들어가면 규칙이 깨지거나 넓어짐).
printf '%s' "$DATA_ROOT" | grep -Eq '^/[A-Za-z0-9._/-]+$' || { echo "데이터 디렉터리는 영문·숫자·._- 로 된 절대 경로여야 합니다: $DATA_ROOT" >&2; exit 1; }
[ -f "$DATA_ROOT/users.json" ] || { echo "웹메이커 사용자 파일이 없습니다: $DATA_ROOT/users.json (DATA_ROOT 를 확인하세요)" >&2; exit 1; }
OWNER="$(stat -c %U "$DATA_ROOT/users.json")"
id "$APP_USER" >/dev/null 2>&1 || { echo "AriMail 실행 계정이 없습니다: $APP_USER (사이트 설정 $SITE_FILE)" >&2; exit 1; }
APP_GROUP="$(id -gn "$APP_USER")"
# 실행용 node: root 소유 + 22 이상. /usr/bin/node → --node 지정 → 사이트의 NODE_BIN(루트 소유일 때만).
pick_node() {
local cand; for cand in "$NODE_OVERRIDE" /usr/bin/node "$NODE_BIN"; do
[ -n "$cand" ] && [ -x "$cand" ] || continue
[ "$(stat -c %U "$cand")" = root ] || { echo " (건너뜀) root 소유가 아닌 node: $cand" >&2; continue; }
local major; major="$("$cand" -p 'process.versions.node.split(".")[0]' 2>/dev/null || echo 0)"
[ "$major" -ge 22 ] 2>/dev/null || { echo " (건너뜀) node 22 미만: $cand" >&2; continue; }
printf '%s' "$cand"; return 0
done; return 1
}
NODE_EXEC="$(pick_node)" || { echo "root 소유의 node 22 이상을 찾지 못했습니다. apt 로 설치하거나 --node <경로> 로 지정하세요(실행 계정이 바꿀 수 있는 파일은 쓰지 않습니다)." >&2; exit 1; }
LIB=/usr/local/lib/arimail
# 슬러그·소켓 경로: server/webmaker.mjs 의 webmakerSocketPath 와 같은 규칙(/data/webmaker → data-webmaker).
SLUG="$(printf '%s' "$DATA_ROOT" | sed 's#^/*##; s#[^A-Za-z0-9]#-#g')"; SOCK_DIR=/run/arimail-webmaker; SOCK="$SOCK_DIR/$SLUG.sock"
echo "▶ 웹메이커 읽기 도우미 설치($MODE): 데이터 $DATA_ROOT (소유 $OWNER) · AriMail 계정 $APP_USER($APP_GROUP) · node $NODE_EXEC"
install -d -o root -g root -m 755 "$LIB"
install -o root -g root -m 644 "$DEPLOY_DIR/../server/webmaker-snapshot.mjs" "$LIB/webmaker-snapshot.mjs"
[ "$MODE" = socket ] && ! command -v systemctl >/dev/null 2>&1 && { echo " systemd 가 없어 sudo 방식으로 설치합니다"; MODE=sudo; }
if [ "$MODE" = socket ]; then
UNIT="arimail-webmaker-$SLUG"
install -d -o root -g root -m 755 "$SOCK_DIR"
printf 'd %s 0755 root root -\n' "$SOCK_DIR" > /etc/tmpfiles.d/arimail-webmaker.conf
cat > "/etc/systemd/system/$UNIT.socket" <<UNIT1
[Unit]
Description=AriMail webmaker account snapshot socket ($DATA_ROOT)
# deploy/webmaker-access.sh 가 설치. AriMail(LOGIN_MODE=webmaker, 읽을 계정 $OWNER)이 접속하면 아래 서비스가 $OWNER 로 스냅샷 JSON 을 써 줍니다.
[Socket]
ListenStream=$SOCK
SocketUser=$OWNER
SocketGroup=$APP_GROUP
SocketMode=0660
Accept=yes
MaxConnections=16
[Install]
WantedBy=sockets.target
UNIT1
cat > "/etc/systemd/system/$UNIT@.service" <<UNIT2
[Unit]
Description=AriMail webmaker account snapshot ($DATA_ROOT, per connection)
CollectMode=inactive-or-failed
[Service]
User=$OWNER
ExecStart=$NODE_EXEC $LIB/webmaker-snapshot.mjs $DATA_ROOT
StandardInput=socket
StandardOutput=socket
StandardError=journal
NoNewPrivileges=yes
ProtectSystem=strict
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
UNIT2
chmod 644 "/etc/systemd/system/$UNIT.socket" "/etc/systemd/system/$UNIT@.service"
systemctl daemon-reload; systemctl enable --now "$UNIT.socket" >/dev/null 2>&1 || systemctl restart "$UNIT.socket"
systemctl restart "$UNIT.socket"
echo " 소켓: $SOCK ($UNIT.socket, 서비스 $UNIT@.service → $OWNER)"
# 실행 계정으로 실제 접속(계정 수만 출력)
READ_CMD="sudo -u $APP_USER $NODE_EXEC -e"
JS='const s=require("net").connect(process.argv[1]);let b="";s.setEncoding("utf8");s.on("data",c=>b+=c);s.on("end",()=>{const d=JSON.parse(b);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});s.on("error",e=>{console.error(e.message);process.exit(1);});'
if OUT="$($READ_CMD "$JS" "$SOCK" 2>&1)"; then echo "✔ 확인: $APP_USER 계정이 소켓으로 스냅샷을 읽었습니다 — $OUT"
else echo "✖ 실행 계정으로 소켓 읽기에 실패했습니다: $OUT (journalctl -u '$UNIT@*' 확인)" >&2; exit 1; fi
else
cat > "$LIB/webmaker-snapshot" <<WRAP
#!/bin/sh
# AriMail 웹메이커 계정 스냅샷 도우미(deploy/webmaker-access.sh 가 설치). 첫 인수(데이터 디렉터리)만 넘기고 나머지는 무시합니다.
exec "$NODE_EXEC" "$LIB/webmaker-snapshot.mjs" "\$1"
WRAP
chmod 755 "$LIB/webmaker-snapshot"; chown root:root "$LIB/webmaker-snapshot"
# sudoers.d 는 이름에 점이 든 파일을 무시하므로 점을 빼고, 임시 파일을 visudo 로 검사한 뒤에 설치합니다(틀린 파일이 들어가면 서버의 sudo 전체가 막힘).
RULE="/etc/sudoers.d/$(printf '%s' "$APP_NAME" | tr -c 'A-Za-z0-9_-' '-')-webmaker"
TMP_RULE="$(mktemp)"; printf '%s ALL=(%s) NOPASSWD: %s %s\n' "$APP_USER" "$OWNER" "$LIB/webmaker-snapshot" "$DATA_ROOT" > "$TMP_RULE"
visudo -cf "$TMP_RULE" >/dev/null || { rm -f "$TMP_RULE"; echo "✖ sudoers 규칙 검사 실패(계정 이름·경로 확인)" >&2; exit 1; }
install -o root -g root -m 440 "$TMP_RULE" "$RULE"; rm -f "$TMP_RULE"
echo " sudoers: $RULE (주의: AriMail 서비스 유닛에 NoNewPrivileges=yes 가 있으면 sudo 가 동작하지 않습니다 — systemd 서버에서는 소켓 방식을 쓰세요)"
if OUT="$(sudo -u "$APP_USER" sudo -n -u "$OWNER" "$LIB/webmaker-snapshot" "$DATA_ROOT" 2>&1)"; then
N="$(printf '%s' "$OUT" | "$NODE_EXEC" -e 'let s="";process.stdin.on("data",(c)=>s+=c).on("end",()=>{const d=JSON.parse(s);console.log(`회원 ${Object.keys(d.users).length}명 · 사이트 ${Object.keys(d.sites).length}개 · 도메인 연결 ${Object.keys(d.domainSites).length}개`);});')"
echo "✔ 확인: $APP_USER 계정이 $OWNER 로 스냅샷을 읽었습니다 — $N"
else echo "✖ 실행 계정으로 읽기에 실패했습니다: $(printf '%s' "$OUT" | tail -1)" >&2; exit 1; fi
fi
cat <<EOF2
다음 단계: 전체 설정 화면(https://$MAIL_HOST:8791/?token=…)의 도메인 카드에서 도메인을 추가·수정하며
로그인 방식 = 웹메이커(webmaker) 계정과 연동, 웹메이커 데이터 디렉터리 = $DATA_ROOT, 파일을 읽을 계정 = $OWNER
로 저장하세요(기본 도메인이면 환경 파일 $ETC_DIR/gateway.env 에 LOGIN_MODE=webmaker, WEBMAKER_DIR=$DATA_ROOT, WEBMAKER_USER=$OWNER 를 적고 재시작).
EOF2