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
+144
View File
@@ -0,0 +1,144 @@
#!/bin/bash
# 이 서비스(웹메이커)를 다른 서버에 설치하고 192.168.20.66 의 DB 를 복사한다.
# 복사 대상: data/users.json, data/sites/, data/git/(버전 저장소), data/billing.json, data/fx.json,
# data/deploy-targets.json(접속 정보만), settings.json 의 비밀 아닌 값(모델·환율·마진), 사이트 파일(/var/www/sites)
# 복사 제외(비밀값 — 새 서버 관리자 콘솔에서 다시 입력): settings.json 의 API 키/OAuth 토큰, session.secret, deploy-keys/(SSH 개인키), credentials.json(외부 연동 접속 정보, v1.3.1)
# v1.3.17 (B29·B32): settings.json 은 "비밀이 아닌 항목" 목록(허용 목록)만 옮긴다 — 외부 서비스 키(serviceApiKey)·수집 키(backlogKey)·
# 다른 AI 제공자 키(providers, session.secret 로 암호화되어 새 서버에서는 풀 수 없음)는 옮기지 않는다. data/site-keys/(사이트 원본 서버 접속 키)도 제외.
# 사용: deploy/remote-install.sh <ssh-host> [FQDN|-] [app-port] [sites-dir]
# 예: deploy/remote-install.sh bear webmaker.ibit.kr 3080 /var/www/webmaker-sites
# deploy/remote-install.sh newbox ← v1.5.11: FQDN 을 모르면 생략(또는 -). 서버 IP 로 설치하고 http://<IP>:8795/ 설정 페이지에서 나머지를 정한다
# 전제: <ssh-host> 에 비밀번호 없는 SSH 접속과 sudo 가 가능. 대상 서버의 기존 서비스(Caddy/nginx 등)는 건드리지 않고
# FQDN(또는 IP) 전용 사이트 블록만 추가한다.
# v1.5.11 (요구 1·2) 설치 중 입력을 받지 않는다. 처음 설치(FRESH=1 또는 FQDN 생략)는 이 PC 의 DB 를 복사하지 않고 빈 상태로 시작하며,
# v1.6.31: 고정 관리자 비밀번호는 만들지 않는다 — 설정 페이지(접속 토큰)에서 최초 관리자를 만든다. 설치가 끝나면 웹(설정 페이지)에서 나머지 설정을 잇는다.
set -euo pipefail
HOST=${1:?ssh host}; FQDN=${2:-}; PORT=${3:-3080}; SITES=${4:-/var/www/webmaker-sites}
[ "$FQDN" = "-" ] && FQDN=""
SRC=$(cd "$(dirname "$0")/.." && pwd)
APPDIR=/home/master/webmaker
echo "== 1/7 대상 서버 점검 ($HOST)"
ssh -o BatchMode=yes "$HOST" 'set -e; echo "user=$(id -un) host=$(hostname) os=$(. /etc/os-release; echo $PRETTY_NAME)"; sudo -n true && echo "sudo: ok"; command -v node && node --version || echo "node: missing"; command -v caddy && caddy version | head -1 || true; command -v nginx && nginx -v 2>&1 || true; (ss -tlnp 2>/dev/null | grep -E ":(80|443|'"$PORT"') " || true)'
# FQDN 이 없으면 서버의 대표 IP(공인 IP 가 있으면 그것, 없으면 첫 사설 IP)를 접속 주소로 쓴다
SERVER_IP=$(ssh "$HOST" "ip -4 -o addr show scope global | awk '{print \$4}' | cut -d/ -f1 | grep -vE '^(172\.(1[6-9]|2[0-9]|3[01])\.|10\.|192\.168\.)' | head -1; ip -4 -o addr show scope global | awk '{print \$4}' | cut -d/ -f1 | head -1" | head -1)
if [ -z "$FQDN" ]; then FRESH=${FRESH:-1}; SITE_HOST=$SERVER_IP; PUBLIC_URL="http://$SERVER_IP"; echo " FQDN 없음 → 서버 IP $SERVER_IP 로 설치 (공개 주소 $PUBLIC_URL, 나중에 설정 페이지에서 바꿀 수 있음)"; else FRESH=${FRESH:-0}; SITE_HOST=$FQDN; PUBLIC_URL="https://$FQDN"; fi
echo "== 2/7 앱 코드 전송 (main 작업 트리, data 제외)"
ssh "$HOST" "mkdir -p $APPDIR"
tar -C "$SRC" --exclude=./data --exclude=./.git --exclude=./node_modules -cf - . | ssh "$HOST" "tar -C $APPDIR -xf -"
ssh "$HOST" "mkdir -p $APPDIR/data && sudo mkdir -p $SITES && sudo chown \$(id -un) $SITES && chmod 755 $SITES" # 사이트 폴더는 웹서버(www-data)가 읽어야 하므로 755
if [ "$FRESH" = "1" ]; then
echo "== 3/7 처음 설치 — DB 를 복사하지 않습니다 (빈 상태 — 최초 관리자는 설정 페이지에서 만듭니다)"
else
echo "== 3/7 DB 복사 (비밀값 제외)"
# v1.5.7: 설정 파일(config.json — 서버마다 다른 주소·연동 정보)과 초기 설정 페이지 토큰(setup.token)도 복사하지 않는다 (새 서버의 유닛·설정 페이지에서 새로 만듦)
tar -C "$SRC" --exclude=./data/settings.json --exclude=./data/session.secret --exclude=./data/deploy-keys --exclude=./data/credentials.json --exclude=./data/site-keys --exclude=./data/user-connections.json --exclude=./data/config.json --exclude=./data/setup.token -cf - ./data | ssh "$HOST" "tar -C $APPDIR -xf -"
# settings.json: 모델·환율·마진·LE 이메일만 복사, 인증 정보는 비움
python3 - "$SRC/data/settings.json" <<'PY' | ssh "$HOST" "cat > $APPDIR/data/settings.json && chmod 600 $APPDIR/data/settings.json"
import json,sys
try: s=json.load(open(sys.argv[1]))
except Exception: s={}
# 허용 목록: 비밀이 아닌 설정만 (v1.3.17)
KEEP=['defaultModel','tiers','defaultTier','fx','marginPct','letsEncryptEmail','maxEdits','signupCredit','theme','mailSystem','uiLang','passwordPolicy','tierGuide']
out={k:s[k] for k in KEEP if k in s}
m=s.get('mail') or {}
out['mail']={k:m[k] for k in ('defaultDomain','fromName','formBase') if k in m}
out['auth']={'mode':'server-login','apiKey':'','oauthToken':''}
print(json.dumps(out,ensure_ascii=False,indent=2))
PY
tar -C /var/www/sites -cf - . | ssh "$HOST" "tar -C $SITES -xf - && chmod -R a+rX $SITES"
fi
echo "== 4/7 node / claude CLI / 서비스 설치"
ssh "$HOST" bash -s "$APPDIR" "$PORT" "$SITES" "$SITE_HOST" "$PUBLIC_URL" "$FRESH" <<'REMOTE'
set -e; APPDIR=$1; PORT=$2; SITES=$3; FQDN=$4; PUBLIC_URL=$5; FRESH=$6
if ! command -v node >/dev/null 2>&1; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -n bash - >/dev/null && sudo -n apt-get install -y -q nodejs >/dev/null; fi
sudo -n apt-get install -y -q python3-docx git tar >/dev/null 2>&1 || true
if ! command -v codex >/dev/null 2>&1 && [ ! -x ~/.npm-global/bin/codex ]; then mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global && npm i -g @openai/codex >/dev/null 2>&1 && echo "codex CLI 설치됨"; fi
if ! command -v claude >/dev/null 2>&1 && [ ! -x ~/.npm-global/bin/claude ]; then mkdir -p ~/.npm-global && npm config set prefix ~/.npm-global && npm i -g @anthropic-ai/claude-code >/dev/null 2>&1 && echo "claude CLI 설치됨"; fi
sudo -n cp "$APPDIR/deploy/nginx-wmstats.conf" /etc/nginx/conf.d/wmstats.conf 2>/dev/null || true # 접속 통계 로그 형식
sed -e "s#/home/master/work/WebHoting_AI_Agent#$APPDIR#g" -e "s#Environment=PORT=3080#Environment=PORT=$PORT#" -e "s#Environment=SITES_ROOT=/var/www/sites#Environment=SITES_ROOT=$SITES#" -e "s#Environment=PUBLIC_BASE_URL=http://192.168.20.66#Environment=PUBLIC_BASE_URL=$PUBLIC_URL#" -e "/^Environment=CLAUDE_BIN=/d" -e "s#^Environment=PATH=.*#Environment=PATH=$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin#" -e "s#^Environment=SITES_GIT_REMOTE=.*#Environment=SITES_GIT_REMOTE=#" -e "s#^Environment=HOME=.*#Environment=HOME=$HOME#" -e "s#^User=.*#User=$(id -un)#" -e "s#^Group=.*#Group=$(id -gn)#" "$APPDIR/deploy/webhosting-agent.service" | sudo -n tee /etc/systemd/system/webmaker.service >/dev/null
sed -i "s#/home/master/work/WebHoting_AI_Agent/hooks/guard.sh#$APPDIR/hooks/guard.sh#" "$APPDIR/hooks/settings.json"
sudo -n systemctl daemon-reload && sudo -n systemctl enable --now webmaker >/dev/null && sleep 1 && systemctl is-active webmaker
# v1.5.7 (요구 1~3): 유닛의 환경 변수를 설정 파일(data/config.json)로 옮기고, 초기 설정 페이지 서비스(webmaker-setup, 기본 포트 3081)와 재시작 sudoers 규칙을 설치
(cd "$APPDIR" && node scripts/config.js import-env --unit /etc/systemd/system/webmaker.service >/dev/null && node scripts/config.js set service.systemdUnit webmaker >/dev/null)
# v1.5.11: 본 서비스 유닛에 DATA_ROOT 가 있으면 설정 페이지 유닛에도 같은 값을 넣는다 (같은 설정 파일·관리자 계정·토큰)
DR=$(grep -oP '^Environment=DATA_ROOT=\K.*' /etc/systemd/system/webmaker.service | head -1 || true)
sed -e "s#/home/master/work/WebHoting_AI_Agent#$APPDIR#g" -e "s#^Environment=PATH=.*#Environment=PATH=$HOME/.local/bin:$HOME/.npm-global/bin:/usr/local/bin:/usr/bin:/bin#" -e "s#^Environment=HOME=.*#Environment=HOME=$HOME#" -e "s#^User=.*#User=$(id -un)#" -e "s#^Group=.*#Group=$(id -gn)#" "$APPDIR/deploy/webmaker-setup.service" | { if [ -n "$DR" ]; then sed -e "s#^\# Environment=DATA_ROOT=.*#Environment=DATA_ROOT=$DR#"; else cat; fi; } | sudo -n tee /etc/systemd/system/webmaker-setup.service >/dev/null
printf '%s ALL=(root) NOPASSWD: /usr/bin/systemctl restart webmaker, /usr/bin/systemctl restart webmaker-setup\n' "$(id -un)" | sudo -n tee /etc/sudoers.d/webmaker-setup >/dev/null && sudo -n chmod 440 /etc/sudoers.d/webmaker-setup
sudo -n systemctl daemon-reload && sudo -n systemctl enable --now webmaker-setup >/dev/null && sudo -n systemctl restart webmaker-setup && echo "webmaker-setup: $(systemctl is-active webmaker-setup)"
# v1.5.11 (요구 2): 설정 페이지 포트(기본 8795, 모든 인터페이스)를 방화벽(ufw)이 켜져 있으면 허용 — http://<서버 IP>:8795/ 가 기본 접속 위치. 접속은 토큰·관리자 로그인·시도 제한으로 보호
SP=$(cd "$APPDIR" && node scripts/config.js get service.setupPort); SP=${SP:-8795}
if command -v ufw >/dev/null 2>&1 && sudo -n ufw status 2>/dev/null | grep -q '^Status: active'; then sudo -n ufw allow "$SP"/tcp comment 'AI Webmaker setup page' >/dev/null; fi
# v1.6.31 (요구 6): 처음 설치해도 고정 비밀번호 관리자를 만들지 않는다 — 최초 관리자는 설정 페이지(접속 토큰, 1회)에서 운영자가 아이디·비밀번호를 정해 만든다
if [ "$FRESH" = "1" ]; then (cd "$APPDIR" && DATA_ROOT="$APPDIR/data" node scripts/admin.js bootstrap); fi
echo "== 5/7 웹서버에 $FQDN 사이트 추가 (기존 서비스 유지, 오류는 일반 안내 페이지)" # FQDN 이 없으면 서버 IP 가 server_name
ERR404='<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>페이지를 찾을 수 없습니다</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>페이지를 찾을 수 없습니다</h1><p>요청하신 페이지가 존재하지 않거나 이동되었습니다.</p></body></html>'
# v1.5.27 (요구 5): 발행 취소 안내 페이지 — 영어 제목 + 한·일·중·서 안내 + "supported by <도메인>" (lib/maintpage.js 와 같은 문구; 도메인은 FQDN 의 상위 도메인)
WM_DOM=$([ -n "$FQDN" ] && echo "$FQDN" | awk -F. '{ if (NF>2) { for (i=2;i<=NF;i++) printf "%s%s", $i, (i<NF?".":"") } else print $0 }' || echo Webmaker)
MAINT=$(printf '%s' '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><title>Updating</title><style>body{margin:0;min-height:100vh;display:grid;place-items:center;font-family:-apple-system,"Segoe UI","Noto Sans KR","Noto Sans JP","Noto Sans SC",sans-serif;background:#f4f5f7;color:#1f2933}main{text-align:center;padding:32px 24px;max-width:640px}h1{font-size:28px;line-height:1.3;margin:0 0 18px}p{color:#4b5563;margin:6px 0;line-height:1.6;font-size:15px}.f{margin-top:26px;font-size:12px;color:#9aa3af}</style></head><body><main><h1>We&#39;re updating our site. Please check back soon.</h1><p lang="ko">사이트 개정 작업 중입니다. 잠시 후에 다시 접속해주세요.</p><p lang="ja">サイトの改訂作業中です。しばらくしてから再度アクセスしてください。</p><p lang="zh">网站正在改版中,请稍后再访问。</p><p lang="es">El sitio está en proceso de revisión. Vuelva a intentarlo en unos minutos.</p><p class="f">Webmaker is working .... supported by __WM_DOMAIN__</p></main></body></html>' | sed "s/__WM_DOMAIN__/$WM_DOM/g")
ERR50X='<!doctype html><html lang="ko"><head><meta charset="utf-8"><title>일시적인 오류가 발생했습니다</title></head><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>일시적인 오류가 발생했습니다</h1><p>잠시 후 다시 시도해 주세요.</p></body></html>'
if command -v caddy >/dev/null 2>&1 && [ -f /etc/caddy/Caddyfile ]; then
sudo -n mkdir -p /etc/caddy/sites
# 공인 80/443 이 프런트 프록시를 거쳐 오므로 http:// 로 선언해 Caddy 의 자동 인증서 발급을 끈다(인증서는 프런트에서)
# v1.3.17 (B05·B06): 생성 사이트(/<32자리ID>/)는 Caddy 가 파일을 직접 내보낸다 — 예전에는 모든 경로를 앱으로 넘겨 사이트 주소가 404 였다.
# 발행 취소 표식(.unpublished)이 있으면 nginx 설정과 같이 "사이트 수정 중" 안내(503). 숨김 파일(.으로 시작)은 404.
sudo -n tee /etc/caddy/sites/webmaker.caddy >/dev/null <<CADDY
http://$FQDN {
encode gzip
handle_errors {
@nf expression {http.error.status_code} == 404
handle @nf {
header Content-Type "text/html; charset=utf-8"
respond \`$ERR404\` 404
}
header Content-Type "text/html; charset=utf-8"
respond \`$ERR50X\` {http.error.status_code}
}
@site path_regexp site ^/([a-f0-9]{32})(/|$)
handle @site {
root * $SITES
@dot path_regexp dot /\.
respond @dot 404
@unpub file /{re.site.1}/.unpublished
handle @unpub {
header Content-Type "text/html; charset=utf-8"
header Cache-Control "no-store"
respond \`$MAINT\` 503
}
header Cache-Control "no-cache"
file_server
}
handle {
reverse_proxy 127.0.0.1:$PORT {
flush_interval -1
}
}
}
CADDY
grep -q "import /etc/caddy/sites/\*.caddy" /etc/caddy/Caddyfile || echo 'import /etc/caddy/sites/*.caddy' | sudo -n tee -a /etc/caddy/Caddyfile >/dev/null
sudo -n caddy validate --config /etc/caddy/Caddyfile >/dev/null && sudo -n systemctl reload caddy && echo "caddy: $FQDN 사이트 추가·reload"
elif command -v nginx >/dev/null 2>&1; then
printf 'server {\n listen 80;\n server_name %s;\n server_tokens off;\n client_max_body_size 200m;\n error_page 404 /__wh_404; error_page 500 502 503 504 /__wh_50x;\n location = /__wh_404 { internal; default_type text/html; return 404 '"'"'%s'"'"'; }\n location = /__wh_50x { internal; default_type text/html; return 503 '"'"'%s'"'"'; }\n location = /__wh_maint { internal; default_type text/html; add_header Cache-Control "no-store"; return 503 '"'"'%s'"'"'; }\n # 생성된 사이트: https://FQDN/<32자리ID>/ — 발행 취소 표식(.unpublished)이 있으면 안내 페이지 (v1.3.17 B06)\n location ~ "^/([a-f0-9]{32})(/|$)" { root %s; index index.html; if (-f $document_root/$1/.unpublished) { rewrite ^ /__wh_maint last; } try_files $uri $uri/ =404; add_header Cache-Control "no-cache"; access_log /var/log/nginx/webmaker-sites.log wmstats; location ~ /\\. { return 404; } }\n location / { proxy_pass http://127.0.0.1:%s; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header Connection ""; proxy_buffering off; proxy_read_timeout 3600s; }\n}\n' "$FQDN" "$ERR404" "$ERR50X" "$MAINT" "$SITES" "$PORT" | sudo -n tee /etc/nginx/sites-available/webmaker.conf >/dev/null
sudo -n ln -sf /etc/nginx/sites-available/webmaker.conf /etc/nginx/sites-enabled/webmaker.conf && sudo -n nginx -t && sudo -n systemctl reload nginx && echo "nginx: $FQDN 사이트 추가·reload"
else echo "웹서버(caddy/nginx)를 찾지 못했습니다. 앱은 127.0.0.1:$PORT 에서 실행 중입니다."; fi
if command -v ufw >/dev/null 2>&1 && sudo -n ufw status | grep -q '^Status: active'; then sudo -n ufw allow from 192.168.20.0/24 to any port 80 proto tcp >/dev/null; fi
echo "== 6/7 설치 확인"; curl -s -o /dev/null -w "local app: %{http_code}\n" "http://127.0.0.1:$PORT/login?direct=1"; curl -s -o /dev/null -w "via webserver (Host $FQDN): %{http_code} (설치 점검 전에는 / 가 /setup 으로 302)\n" -H "Host: $FQDN" http://127.0.0.1/
REMOTE
# v1.5.7 (요구 4): 연동 서비스 — Gitea 는 항상(docker 가 있으면), Arimail 은 ARIMAIL_HOST·ARIMAIL_MAILBOX 환경 변수를 주었을 때 함께 설치한다.
# 예: ARIMAIL_HOST=mail.example.com ARIMAIL_MAILBOX=admin@example.com deploy/remote-install.sh bear webmaker.example.com
# Arimail 소스는 이 PC 의 ~/work/mail-gateway 체크아웃을 git bundle 로 묶어 보낸다(서버에 저장소 자격 증명을 두지 않음).
echo "== 7/7 연동 서비스 (Gitea · Arimail)"
ssh "$HOST" "$APPDIR/deploy/install-services.sh --gitea --service webmaker" 2>&1 | sed 's/^/ /' || echo " Gitea 설치를 건너뛰었습니다 — 대상 서버에서 $APPDIR/deploy/install-gitea.sh --service webmaker 를 직접 실행하세요"
if [ -n "${ARIMAIL_HOST:-}" ] && [ -n "${ARIMAIL_MAILBOX:-}" ]; then
if [ -d "$HOME/work/mail-gateway/.git" ]; then git -C "$HOME/work/mail-gateway" bundle create /tmp/arimail-$$.bundle main >/dev/null 2>&1 && scp -q /tmp/arimail-$$.bundle "$HOST:/tmp/arimail.bundle" && rm -f /tmp/arimail-$$.bundle; BUNDLE_OPT="--bundle /tmp/arimail.bundle"; else BUNDLE_OPT=""; fi
ssh "$HOST" "$APPDIR/deploy/install-arimail.sh --host $ARIMAIL_HOST --mailbox $ARIMAIL_MAILBOX $BUNDLE_OPT ${ARIMAIL_OPTS:-}" 2>&1 | sed 's/^/ /' || echo " Arimail 설치 실패 — 대상 서버에서 $APPDIR/deploy/install-arimail.sh --host $ARIMAIL_HOST --mailbox $ARIMAIL_MAILBOX 를 직접 실행하세요"
else ssh "$HOST" "$APPDIR/deploy/install-arimail.sh --check" 2>&1 | sed 's/^/ /' || true; fi
# v1.5.17 (요구 1): 대상 서버에 이미 *.<도메인> 와일드카드 인증서가 있으면 인지해 알린다 (서브 도메인 사이트에 자동 적용됨)
echo "== 와일드카드 인증서"; ssh "$HOST" "cd $APPDIR && node scripts/ssl-wildcard.js" 2>&1 | sed 's/^/ /' || true
echo "== 완료. 설치 중 더 입력할 것은 없습니다 — 나머지는 웹에서 이어집니다:"
echo " 0) 초기 설정 페이지(설치 점검표: 관리자 비밀번호 · AI 엔진 인증 · 공개 주소 → 연동 정보)에서 설정하고 서비스를 재시작:"
ssh "$HOST" "cd $APPDIR && node scripts/config.js setup-url" | sed 's/^/ /'
echo " 서비스 첫 화면 $PUBLIC_URL/ 도 점검표가 끝나기 전에는 같은 설정 페이지(/setup)로 넘어갑니다. 최초 관리자는 설정 페이지의 "관리자 계정" 카드에서 만듭니다(고정 비밀번호 없음). 설치 점검이 끝나면 접속 토큰은 더 쓸 수 없습니다"
echo " (같은 화면이 서비스 안 https://$FQDN/setup 과 관리자 콘솔 › 시스템 설정 탭에도 있습니다)"
[ -n "$FQDN" ] && echo " 1) 대상 서버가 공인 80/443 을 직접 받으면 그 서버에서: sudo certbot --nginx -d $FQDN --redirect ; 다른 프런트 서버를 거치면 deploy/front-proxy-webmaker.conf 를 그 서버에 적용" || echo " 1) 도메인이 정해지면: 설정 페이지에서 서비스 공개 주소를 https://<도메인> 으로 바꾸고, 그 서버에서 nginx server_name 변경 후 sudo certbot --nginx -d <도메인> --redirect"
echo " 2) Claude 연결(서버 로그인/OAuth 토큰/API 키)은 설정 페이지의 \"AI 엔진 인증\" 에서(관리자 콘솔 › 설정과 같은 값), 배포 서버 탭에서 SSH 키 재생성·등록"
echo " 3) Gitea 를 건너뛰었다면 대상 서버에서 $APPDIR/deploy/install-gitea.sh --service webmaker [--migrate-from <옛 원격>]; Arimail 은 $APPDIR/deploy/install-arimail.sh --host mail.<도메인> --mailbox admin@<도메인>"