2026-05-10 13:12:44 +08:00

609 lines
26 KiB
Bash

#!/usr/bin/env bash
# ════════════════════════════════════════════════════════════════
# 国内域名机 · 一键部署 · bootstrap.sh
# Sovereign: TCS-0002∞ · ICE-GL∞ · 国作登字-2026-A-00037559
# 守护: 铸渊 · ICE-GL-ZY001
# 模板版本: 0.2.0
# ════════════════════════════════════════════════════════════════
#
# 服务器: GH-CVM-DOMAIN-PROD-01 / ZY-SVR-CN01 / 广州 2C2G 轻量
# 域名: guanghulab.com (已备案 + Let's Encrypt 续到 2026-08-07)
# 角色: 唯一对外服务器 (Portal + 密钥管理页 + 自托管 git, 后续 PR 填业务)
#
# 这一份 bootstrap 把一台**重装系统后空白的 2C2G Ubuntu 22.04** 准备好:
# 1. 自我感知 (跑 detect-env.sh) + 动态调优 (跑 tune-from-env.sh)
# 2. 切换 APT 镜像源到阿里云 (国内可达, 不卡 Docker mirror)
# 3. 装基础工具链 + nginx + node 20 + pm2 + certbot
# 4. 在 /data/guanghulab/ 下建标准目录, 给后续 PR (portal/secrets-vault/forgejo) 留空位
# 5. enable certbot.timer (LE 证书续期 cron)
# 6. 渲染 nginx server block (不覆盖 SSL, 只追加 guanghulab.com 站点)
# 7. (tiny 档) 建 1G swap 兜底防 OOM
# 8. (失败时) 自动调用 rollback.sh 回到 pre-bootstrap 快照
#
# 设计理念:
# - cc-003 动态适配: 不假设 2C2G, 探测后决档
# - cc-004 强制自主: 失败自动回滚, 中文回执 → /data/guanghulab/_logs/deploy-report.md
# - 不 apt upgrade (2C2G 跑 upgrade 会卡死 30+ 分钟)
# - 不动 /etc/letsencrypt 已有证书 (LE 配置已在, 续期到 2026-08-07)
#
# 用法 (本地直跑):
# sudo bash bootstrap.sh
#
# 通常通过 .github/workflows/deploy-domain-server.yml 远程触发,
# 该 workflow 设有"重装广州"误触锁.
#
# 环境变量:
# DATA_ROOT 默认 /data/guanghulab (持久数据根)
# DEPLOY_ROOT 默认 /opt/guanghulab (代码/快照/日志根)
# DOMAIN 默认 guanghulab.com
# STAGE 默认 bootstrap (合法值: bootstrap / update / rollback)
# SERVER_ID 默认 GH-CVM-DOMAIN-PROD-01
# ROLLBACK_TS 可选 (STAGE=rollback 时回到指定快照)
#
# 退出码:
# 0 全部 OK
# 1 bootstrap 失败 (失败前已尝试 autorollback)
# 2 前置条件错 (非 root / DATA_ROOT 缺失等), 不会动机器
# ════════════════════════════════════════════════════════════════
set -euo pipefail
# ─── 全局参数 ─────────────────────────────────────────────────
TEMPLATE_VERSION="0.2.0"
SERVER_ID="${SERVER_ID:-GH-CVM-DOMAIN-PROD-01}"
DATA_ROOT="${DATA_ROOT:-/data/guanghulab}"
DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/guanghulab}"
DOMAIN="${DOMAIN:-guanghulab.com}"
STAGE="${STAGE:-bootstrap}"
export DEBIAN_FRONTEND=noninteractive
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SERVER_ENV_JSON="$DEPLOY_ROOT/_logs/server-env.json"
# ─── 校验前置条件 ─────────────────────────────────────────────
if [ "$EUID" -ne 0 ]; then
echo "❌ 需要 root (请用 sudo bash bootstrap.sh)" >&2
exit 2
fi
if ! [[ "$SERVER_ID" =~ ^GH-CVM-[A-Z0-9-]+$ ]]; then
echo "❌ SERVER_ID 格式不合法: $SERVER_ID (应为 GH-CVM-[A-Z0-9-]+)" >&2
exit 2
fi
# ─── 中文回执 (整个流程持续追加, 任何阶段失败都能给霜砚看) ───
mkdir -p "$DEPLOY_ROOT/_logs"
DEPLOY_REPORT="$DEPLOY_ROOT/_logs/deploy-report.md"
RECEIPT_TS="$(date +%Y%m%d-%H%M%S)"
# 重置当次 report (每次跑 bootstrap 都覆盖, 历史在 _logs/deploy-report-*.md.bak)
if [ -f "$DEPLOY_REPORT" ]; then
cp -a "$DEPLOY_REPORT" "$DEPLOY_REPORT.$RECEIPT_TS.bak" || true
fi
report() {
# 中文回执 + 控制台同步打印
local line="$1"
echo "$line"
printf '%s\n' "$line" >> "$DEPLOY_REPORT"
}
cat > "$DEPLOY_REPORT" <<EOF
# 国内域名机 · 部署回执
> 服务器: $SERVER_ID ($DOMAIN)
> 阶段: $STAGE · 模板版本 $TEMPLATE_VERSION
> 开始时间: $(date '+%Y-%m-%d %H:%M:%S %Z')
EOF
report "## ① 自我感知 + 动态调优"
# ─── 阶段路由 ─────────────────────────────────────────────────
case "$STAGE" in
bootstrap|update) ;;
rollback)
report ""
report "## 阶段=rollback · 委派给 rollback.sh"
if [ -x "$SCRIPT_DIR/rollback.sh" ]; then
if [ -n "${ROLLBACK_TS:-}" ]; then
DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \
bash "$SCRIPT_DIR/rollback.sh" --to "$ROLLBACK_TS"
else
DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \
bash "$SCRIPT_DIR/rollback.sh"
fi
exit $?
else
report "❌ rollback.sh 不存在或不可执行"
exit 1
fi
;;
*)
echo "❌ 未知 STAGE: $STAGE (合法: bootstrap / update / rollback)" >&2
exit 2
;;
esac
# ─── 0. 自我感知 ─────────────────────────────────────────────
echo "═══════════════════════════════════════════════════════════"
echo " 国内域名机 bootstrap"
echo " 母编号: $SERVER_ID"
echo " 数据根: $DATA_ROOT"
echo " 部署根: $DEPLOY_ROOT"
echo " 域名: $DOMAIN"
echo " 阶段: $STAGE"
echo " 模板版本: $TEMPLATE_VERSION"
echo "═══════════════════════════════════════════════════════════"
# 必需 jq 给 detect/tune 用
if ! command -v jq >/dev/null 2>&1; then
apt-get update -qq || true
apt-get install -y -qq jq || {
report "❌ jq 装不上, 终止"
exit 1
}
fi
if [ -x "$SCRIPT_DIR/detect-env.sh" ]; then
echo "[0/8] 探测当前服务器实际配置 (detect-env.sh)..."
DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" DOMAIN="$DOMAIN" \
bash "$SCRIPT_DIR/detect-env.sh" "$SERVER_ENV_JSON" || {
report "⚠️ detect-env.sh 失败, 退化到默认 tiny 档"
}
fi
# ─── 0.5 跑前快照 (autorollback 兜底用) ──────────────────────
# 把当前 nginx 配置 + .env.tune + systemd unit 打包, 失败时能回这刻
PRE_TS="$(date +%Y%m%d-%H%M%S)"
SNAP_ROOT="$DATA_ROOT/snapshots"
PRE_SNAP="$SNAP_ROOT/$PRE_TS-pre-$STAGE"
mkdir -p "$PRE_SNAP/nginx" "$PRE_SNAP/systemd"
[ -d /etc/nginx/sites-available ] && cp -a /etc/nginx/sites-available "$PRE_SNAP/nginx/" || true
[ -d /etc/nginx/sites-enabled ] && cp -a /etc/nginx/sites-enabled "$PRE_SNAP/nginx/" || true
[ -f "$DATA_ROOT/.env.tune" ] && cp -a "$DATA_ROOT/.env.tune" "$PRE_SNAP/" || true
[ -f /etc/systemd/system/guanghulab-portal.service ] && \
cp -a /etc/systemd/system/guanghulab-portal.service "$PRE_SNAP/systemd/" || true
echo "snapshot before $STAGE @ $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PRE_SNAP/NOTE"
# 只保留最近 10 份, 防系统盘塞满
if [ -d "$SNAP_ROOT" ]; then
ls -1tr "$SNAP_ROOT" 2>/dev/null \
| head -n -10 \
| xargs -I{} rm -rf "$SNAP_ROOT/{}" 2>/dev/null || true
fi
report "- 跑前快照: \`$PRE_SNAP\`"
# ─── autorollback 钩子: ERR/EXIT 触发 ────────────────────────
# bootstrap 阶段任何步骤失败 → 自动调 rollback.sh 回到 PRE_SNAP
BOOTSTRAP_OK="false"
autorollback_on_failure() {
local rc=$?
if [ "$BOOTSTRAP_OK" = "true" ]; then return 0; fi
if [ "$rc" -eq 0 ]; then return 0; fi
echo
echo "═══════════════════════════════════════════════════════════"
echo " ❌ bootstrap 失败 (exit=$rc), 触发自动回滚到 $PRE_TS"
echo "═══════════════════════════════════════════════════════════"
report ""
report "## ❌ bootstrap 失败 (exit=$rc), 触发自动回滚"
report ""
report "- 目标快照: \`$PRE_TS-pre-$STAGE\`"
if [ -x "$SCRIPT_DIR/rollback.sh" ]; then
DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \
bash "$SCRIPT_DIR/rollback.sh" --to "$(basename "$PRE_SNAP")" \
|| report "⚠️ 自动回滚也失败了, 请人工介入. snapshot=\`$PRE_SNAP\`"
else
report "⚠️ rollback.sh 不可执行, 自动回滚跳过"
fi
report ""
report "**给冰朔的人话**: 这次部署没成功. 我已经把机器恢复到部署前的状态."
report "**Awen 不需要做任何事**, 把这份 deploy-report.md 截图发给冰朔即可."
}
trap autorollback_on_failure EXIT
# ─── 0.6 动态调优 ─────────────────────────────────────────────
if [ -x "$SCRIPT_DIR/tune-from-env.sh" ] && [ -f "$SERVER_ENV_JSON" ]; then
ENV_FILE="$SERVER_ENV_JSON" DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \
bash "$SCRIPT_DIR/tune-from-env.sh" || {
report "⚠️ tune-from-env.sh 失败, 用默认 tiny 档继续"
}
fi
# 兜底默认值 (tune 失败 / .env.tune 缺失时)
SIZE_TIER="tiny"
PORTAL_INSTANCES="1"
PORTAL_MAX_MEMORY_MB="384"
NGINX_WORKER_CONN="512"
NGINX_WORKER_PROCESSES="1"
FORGEJO_ENABLED="false"
LFS_ENABLED="false"
NEED_SWAP_GB="1"
SYSTEMD_PORTAL_HIGH_MB="500"
SYSTEMD_PORTAL_MAX_MB="700"
if [ -f "$DATA_ROOT/.env.tune" ]; then
# shellcheck disable=SC1091
. "$DATA_ROOT/.env.tune"
fi
report "- 档位: **$SIZE_TIER** · portal=${PORTAL_INSTANCES} 进程 · nginx=${NGINX_WORKER_PROCESSES} worker / ${NGINX_WORKER_CONN} conn"
report ""
# ─── 1. APT 镜像源 (阿里云) ──────────────────────────────────
report "## ② 装基础工具链 (跳过 apt upgrade)"
echo "[1/8] 切换 APT 源到阿里云镜像 ..."
CODENAME="$(. /etc/os-release && echo "${VERSION_CODENAME:-jammy}")"
if ! grep -q "mirrors.aliyun.com" /etc/apt/sources.list 2>/dev/null; then
cp /etc/apt/sources.list "/etc/apt/sources.list.bak.$(date +%s)" || true
cat > /etc/apt/sources.list <<EOF
deb https://mirrors.aliyun.com/ubuntu/ $CODENAME main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ $CODENAME-updates main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ $CODENAME-security main restricted universe multiverse
deb https://mirrors.aliyun.com/ubuntu/ $CODENAME-backports main restricted universe multiverse
EOF
fi
apt-get update -qq
# ─── 2. 基础工具链 ───────────────────────────────────────────
echo "[2/8] 安装基础工具链 (不 apt upgrade, 2C2G 卡死风险) ..."
# 注: 单独装, 不 apt upgrade. nginx 已装则 apt 自动跳过 (不动 SSL 配置)
apt-get install -y -qq \
curl wget ca-certificates gnupg lsb-release \
git jq unzip vim htop net-tools \
nginx certbot python3-certbot-nginx \
apache2-utils \
fail2ban ufw \
rsync openssh-client \
build-essential
report "- apt 工具链 OK (nginx / certbot / fail2ban / git / htpasswd ...)"
# ─── 3. Node 20 + PM2 (npmmirror 镜像) ───────────────────────
NODE_OK="false"
if command -v node >/dev/null 2>&1; then
NODE_MAJOR="$(node -v 2>/dev/null | sed 's/v//;s/\..*//')"
[ "${NODE_MAJOR:-0}" -ge 20 ] && NODE_OK="true"
fi
if [ "$NODE_OK" = "false" ]; then
echo "[3/8] 安装 Node 20 ..."
if ! curl -fsSL --max-time 30 https://deb.nodesource.com/setup_20.x | bash - ; then
echo " NodeSource 不可达, 改用 nvm 国内镜像..."
export NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node
curl -fsSL https://gitee.com/mirrors/nvm/raw/master/install.sh | bash || {
report "❌ Node 20 装不上"
exit 1
}
fi
apt-get install -y -qq nodejs || true
fi
if ! command -v pm2 >/dev/null 2>&1; then
npm config set registry https://registry.npmmirror.com
npm install -g pm2 --silent
fi
report "- Node $(node -v 2>/dev/null) + PM2 $(pm2 -v 2>/dev/null) OK"
# ─── 4. 标准目录 + 占位 (PR-4/5/6 填) ────────────────────────
report ""
report "## ③ 建标准目录 (留位给后续棒)"
echo "[4/8] 建 $DATA_ROOT 标准目录 + 后续 PR 占位 ..."
mkdir -p \
"$DATA_ROOT" \
"$DATA_ROOT/portal" \
"$DATA_ROOT/secrets-vault" \
"$DATA_ROOT/forgejo" \
"$DATA_ROOT/snapshots" \
"$DATA_ROOT/logs/nginx" \
"$DATA_ROOT/logs/portal" \
"$DEPLOY_ROOT/_active" \
"$DEPLOY_ROOT/_archive" \
"$DEPLOY_ROOT/_logs" \
"$DEPLOY_ROOT/_secrets" \
"$DEPLOY_ROOT/domain-cn"
chmod 700 "$DEPLOY_ROOT/_secrets" "$DATA_ROOT/secrets-vault"
# 占位 README, 后续 PR 来填具体内容
[ ! -f "$DATA_ROOT/portal/README.md" ] && cat > "$DATA_ROOT/portal/README.md" <<'EOF'
# /data/guanghulab/portal/ · 占位
Portal 业务代码留给 PR-4 (光湖门户) 填充。本目录在 PR-2 bootstrap 时建立, 当前为空。
EOF
[ ! -f "$DATA_ROOT/secrets-vault/README.md" ] && cat > "$DATA_ROOT/secrets-vault/README.md" <<'EOF'
# /data/guanghulab/secrets-vault/ · PR-5 落地
光湖国内域名机本地密钥库. AES-256-GCM 加密.
- `.master` — 32 字节随机主密钥 (chmod 600). **不上 git, 不上 COS, 不传冰朔.**
- `vault.enc` — 加密后的 secrets JSON (chmod 600).
服务: `pm2 status guanghulab-vault` · 监听 127.0.0.1:8080
访问: 走 nginx 的 /admin/ (basic-auth + 仅本机 IP)
首启凭据: /data/guanghulab/_logs/vault-credentials-FIRST-BOOT.txt (抄完删)
EOF
[ ! -f "$DATA_ROOT/forgejo/README.md" ] && cat > "$DATA_ROOT/forgejo/README.md" <<'EOF'
# /data/guanghulab/forgejo/ · 占位
Forgejo 自托管 git 留给 PR-6 (一台到底搬家方案) 填充。
当前 tune-from-env tiny 档下 FORGEJO_ENABLED=false, bootstrap 不会启动 forgejo。
EOF
report "- 建好 \`portal/\` \`secrets-vault/\` \`forgejo/\` 三个空目录给 PR-4/5/6"
# ─── 5. Swap 兜底 (tiny 档) ──────────────────────────────────
if [ "$NEED_SWAP_GB" = "1" ]; then
if [ ! -f /swapfile.guanghulab ]; then
echo "[5/8] 建 1G swap (tiny 档防 OOM) ..."
fallocate -l 1G /swapfile.guanghulab || dd if=/dev/zero of=/swapfile.guanghulab bs=1M count=1024
chmod 600 /swapfile.guanghulab
mkswap /swapfile.guanghulab
swapon /swapfile.guanghulab
if ! grep -q '/swapfile.guanghulab' /etc/fstab; then
echo '/swapfile.guanghulab none swap sw 0 0' >> /etc/fstab
fi
report "- 已建 1G swap \`/swapfile.guanghulab\` (tiny 档防 OOM)"
fi
fi
# ─── 6. Nginx 站点配置 ───────────────────────────────────────
report ""
report "## ④ 配置 Nginx (不动现有 SSL, 只追加站点)"
echo "[6/8] 渲染 nginx 站点配置 (不覆盖 /etc/letsencrypt/) ..."
NGX_TPL="$SCRIPT_DIR/nginx/guanghulab.conf.template"
NGX_OUT="/etc/nginx/sites-available/guanghulab.conf"
if [ ! -f "$NGX_TPL" ]; then
report "⚠️ nginx 模板缺失: $NGX_TPL, 跳过站点配置"
else
# 检查 LE 证书是否存在 (重装系统后 /etc/letsencrypt 可能在或不在)
LE_CERT="/etc/letsencrypt/live/$DOMAIN/fullchain.pem"
LE_KEY="/etc/letsencrypt/live/$DOMAIN/privkey.pem"
if [ -f "$LE_CERT" ] && [ -f "$LE_KEY" ]; then
USE_SSL="1"
report "- LE 证书在: \`$LE_CERT\` · 启用 HTTPS server block"
else
USE_SSL="0"
report "- LE 证书暂不在 (重装后未恢复), bootstrap 只起 80 端口, 不主动 issue (避免 LE rate-limit)"
report " → 冰朔: 把 \`/etc/letsencrypt/\` 整目录从备份恢复后再 \`systemctl reload nginx\` 即可启用 HTTPS"
fi
# 渲染模板 (用 awk, 不用 sed, 避开特殊字符)
DOMAIN_VAL="$DOMAIN" \
NGINX_WORKER_CONN_VAL="$NGINX_WORKER_CONN" \
USE_SSL_VAL="$USE_SSL" \
PORTAL_PORT_VAL="${PORTAL_PORT:-3000}" \
VAULT_PORT_VAL="${VAULT_PORT:-8080}" \
awk '
/^# __SSL_BEGIN__[[:space:]]*$/ { mode="ssl"; next }
/^# __SSL_END__[[:space:]]*$/ { mode=""; next }
/^# __NOSSL_BEGIN__[[:space:]]*$/ { mode="nossl"; next }
/^# __NOSSL_END__[[:space:]]*$/ { mode=""; next }
{
# 条件块: ssl 段仅 USE_SSL=1 输出, nossl 段仅 USE_SSL=0 输出
if (mode == "ssl") {
if (ENVIRON["USE_SSL_VAL"] != "1") next
sub(/^# /, "")
sub(/^#$/, "")
} else if (mode == "nossl") {
if (ENVIRON["USE_SSL_VAL"] == "1") next
sub(/^# /, "")
sub(/^#$/, "")
}
gsub(/__DOMAIN__/, ENVIRON["DOMAIN_VAL"])
gsub(/__PORTAL_PORT__/, ENVIRON["PORTAL_PORT_VAL"])
gsub(/__VAULT_PORT__/, ENVIRON["VAULT_PORT_VAL"])
gsub(/__NGINX_WORKER_CONN__/, ENVIRON["NGINX_WORKER_CONN_VAL"])
print
}' "$NGX_TPL" > "$NGX_OUT"
ln -sf "$NGX_OUT" "/etc/nginx/sites-enabled/guanghulab.conf"
# 不再删 default (避免误删管理员手配的 default), 只让我们的 server_name 优先
# 真要清除 default 的, 让冰朔在霜砚指导下 rm /etc/nginx/sites-enabled/default
# 调 nginx worker_connections (仅当本模板能匹配到默认值)
if [ -f /etc/nginx/nginx.conf ]; then
sed -i "s/worker_connections [0-9]\+;/worker_connections $NGINX_WORKER_CONN;/" /etc/nginx/nginx.conf || true
fi
if nginx -t 2>&1; then
systemctl reload nginx || systemctl restart nginx
report "- nginx 配置语法 OK, 已 reload"
else
report "❌ nginx 配置语法错, 拒绝 reload (旧配置仍在跑)"
exit 1
fi
fi
# ─── 6.5 生成 /admin/ basic-auth 凭据 (PR-5 secrets-vault) ───
report ""
report "## ④.5 生成密钥管理页 /admin/ basic-auth 凭据"
HTPASSWD_FILE="/etc/nginx/.htpasswd_admin"
VAULT_CRED_FILE="$DEPLOY_ROOT/_logs/vault-credentials-FIRST-BOOT.txt"
if [ ! -f "$HTPASSWD_FILE" ]; then
if ! command -v htpasswd >/dev/null 2>&1; then
report "⚠️ htpasswd 不存在 (apache2-utils 没装), /admin/ 走仅 IP 限制"
else
# 生成 24 位随机密码 (URL-safe, 不含会让 awk/bash 难处理的字符)
VAULT_USER="bingshuo"
VAULT_PASS="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 24)"
htpasswd -B -b -c "$HTPASSWD_FILE" "$VAULT_USER" "$VAULT_PASS" >/dev/null 2>&1
chmod 644 "$HTPASSWD_FILE"
# 落首启凭据文件 — 冰朔抄完应自行删除
umask 077
cat > "$VAULT_CRED_FILE" <<EOF
# 光湖密钥管理页 · 首启凭据 · 抄完后**手动删除本文件**
# 守护: 铸渊 · ICE-GL-ZY001
# 生成时间: $(date -u +%Y-%m-%dT%H:%M:%SZ)
# 域名机: $SERVER_ID ($DOMAIN)
#
# 访问方式 (在你电脑上跑):
# ssh -L 8443:127.0.0.1:443 ${SUDO_USER:-ubuntu}@<本机公网IP>
# 浏览器打开 https://localhost:8443/admin/ (TLS 警告点继续)
#
# basic-auth:
# 用户名: $VAULT_USER
# 密码: $VAULT_PASS
#
# 抄完请运行: sudo rm $VAULT_CRED_FILE
EOF
chmod 600 "$VAULT_CRED_FILE"
umask 022
report "- 已生成 /admin/ basic-auth (\`$HTPASSWD_FILE\`)"
report "- 凭据落在 \`$VAULT_CRED_FILE\` (chmod 600, 抄完 \`sudo rm\`)"
fi
else
report "- /admin/ basic-auth (\`$HTPASSWD_FILE\`) 已存在, 不覆盖 (改密码请手动 \`htpasswd -B $HTPASSWD_FILE bingshuo\`)"
fi
# ─── 7. Certbot 自动续期 timer ───────────────────────────────
echo "[7/8] enable certbot.timer (LE 证书自动续期, 续到 2026-08-07) ..."
systemctl enable certbot.timer >/dev/null 2>&1 || true
systemctl start certbot.timer >/dev/null 2>&1 || true
CERTBOT_TIMER_ACTIVE="$(systemctl is-active certbot.timer 2>/dev/null || echo unknown)"
report "- certbot.timer 状态: $CERTBOT_TIMER_ACTIVE"
# ─── 8. 防火墙 + 占位 portal systemd unit ────────────────────
echo "[8/8] 加固防火墙 + 留 portal systemd unit ..."
ufw --force reset >/dev/null
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
systemctl enable fail2ban >/dev/null 2>&1 || true
systemctl restart fail2ban || true
# 留一个 portal systemd unit 占位 (PR-4 真正填业务时, 改 ExecStart 即可)
PORTAL_UNIT="/etc/systemd/system/guanghulab-portal.service"
if [ ! -f "$PORTAL_UNIT" ]; then
cat > "$PORTAL_UNIT" <<EOF
# Portal · 由 PR-4 接管, 当前是占位
# Generated by domain-cn/bootstrap.sh @ $(date -u +%Y-%m-%dT%H:%M:%SZ)
[Unit]
Description=Guanghulab Portal (placeholder until PR-4)
After=network.target
[Service]
Type=simple
WorkingDirectory=$DATA_ROOT/portal
ExecStart=/bin/sh -c 'echo "[portal placeholder] PR-4 will replace this ExecStart"; sleep 86400'
Restart=on-failure
MemoryHigh=${SYSTEMD_PORTAL_HIGH_MB}M
MemoryMax=${SYSTEMD_PORTAL_MAX_MB}M
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
# 不主动 enable/start, 避免占位脚本占资源. PR-4 自己 enable.
report "- portal systemd unit 占位写入: \`$PORTAL_UNIT\` (not enabled)"
fi
# ─── 8.5 启动 secrets-vault (PR-5) ───────────────────────────
# 前提: deploy-domain-server.yml 已把以下两个目录 rsync 到位
# $DEPLOY_ROOT/secrets-vault/ (server/secrets-vault 全部源码)
# $DEPLOY_ROOT/secrets-vault-frontend/ (frontend/secrets-vault 全部源码)
VAULT_SRC="$DEPLOY_ROOT/secrets-vault"
VAULT_FE="$DEPLOY_ROOT/secrets-vault-frontend"
if [ -d "$VAULT_SRC" ] && [ -f "$VAULT_SRC/server.js" ]; then
report ""
report "## ⑤ 启动 secrets-vault (PR-5)"
echo "[8.5] 启动 secrets-vault ..."
# 装 vault npm 依赖 (只 express, 体量小)
if [ ! -d "$VAULT_SRC/node_modules" ]; then
( cd "$VAULT_SRC" && npm config set registry https://registry.npmmirror.com && npm install --omit=dev --silent ) || {
report "❌ secrets-vault npm install 失败"
exit 1
}
fi
# 用 pm2 启 (跟 portal 同一个 pm2 daemon)
export VAULT_DIR="${VAULT_DIR:-$DATA_ROOT/secrets-vault}"
export PORTAL_DATA_DIR="${PORTAL_DATA_DIR:-$DATA_ROOT/portal/data}"
export VAULT_STATIC_DIR="${VAULT_STATIC_DIR:-$VAULT_FE}"
export VAULT_PORT="${VAULT_PORT:-8080}"
export VAULT_HOST="127.0.0.1"
export VAULT_MANIFEST_PATH="${VAULT_MANIFEST_PATH:-$DEPLOY_ROOT/_active/scripts/preflight/secrets-manifest.json}"
# 没在 _active 那就退化到 vault 自带的副本 (deploy 时 rsync 整 scripts/preflight)
if [ ! -f "$VAULT_MANIFEST_PATH" ]; then
VAULT_MANIFEST_PATH="$VAULT_SRC/secrets-manifest.json"
fi
mkdir -p "$DATA_ROOT/_logs"
if pm2 describe guanghulab-vault >/dev/null 2>&1; then
pm2 reload guanghulab-vault --update-env >/dev/null 2>&1 \
&& report "- guanghulab-vault: pm2 reload OK" \
|| report "⚠️ guanghulab-vault pm2 reload 失败, 用 restart 兜底"
pm2 describe guanghulab-vault >/dev/null 2>&1 || pm2 start "$VAULT_SRC/ecosystem.config.js" --only guanghulab-vault
else
pm2 start "$VAULT_SRC/ecosystem.config.js" --only guanghulab-vault \
&& report "- guanghulab-vault: pm2 start OK (127.0.0.1:$VAULT_PORT)" \
|| { report "❌ guanghulab-vault pm2 start 失败"; exit 1; }
fi
pm2 save >/dev/null 2>&1 || true
report "- vault 数据根: \`$VAULT_DIR\` · 主密钥首启自动生成 \`.master\`"
else
report ""
report "## ⑤ secrets-vault 未就位 (跳过)"
report "- \`$VAULT_SRC\` 不存在 — deploy-domain-server.yml 把 server/secrets-vault/ rsync 上来后再跑一次 bootstrap 即可激活"
fi
# ─── 收尾回执 ─────────────────────────────────────────────────
BOOTSTRAP_OK="true"
cat > "$DEPLOY_ROOT/_logs/bootstrap-$RECEIPT_TS.json" <<EOF
{
"_sovereign": "TCS-0002∞ | $SERVER_ID",
"_copyright": "国作登字-2026-A-00037559",
"template_version": "$TEMPLATE_VERSION",
"stage": "$STAGE",
"completed_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"data_root": "$DATA_ROOT",
"deploy_root": "$DEPLOY_ROOT",
"domain": "$DOMAIN",
"size_tier": "$SIZE_TIER",
"portal_instances": $PORTAL_INSTANCES,
"nginx_worker_conn": $NGINX_WORKER_CONN,
"forgejo_enabled": $FORGEJO_ENABLED,
"lfs_enabled": $LFS_ENABLED,
"use_ssl": ${USE_SSL:-0},
"certbot_timer": "$CERTBOT_TIMER_ACTIVE",
"pre_snapshot": "$PRE_SNAP",
"deploy_report": "$DEPLOY_REPORT"
}
EOF
report ""
report "## ✅ 本次 $STAGE 完成"
report ""
report "- 服务器: $SERVER_ID"
report "- 域名: $DOMAIN (HTTPS=${USE_SSL:-0})"
report "- 档位: $SIZE_TIER"
report "- 部署根: \`$DEPLOY_ROOT\`"
report "- 数据根: \`$DATA_ROOT\`"
report "- 跑前快照: \`$PRE_SNAP\`"
report "- 完成时间: $(date '+%Y-%m-%d %H:%M:%S %Z')"
report ""
report "**给冰朔的人话**: 域名机基础环境已就位. nginx + node 20 + pm2 + certbot 都装好了."
report "下一步: 等 PR-4 填 portal 业务代码 (本仓库后续棒)."
echo
echo "═══════════════════════════════════════════════════════════"
echo " [OK] $SERVER_ID 域名机 $STAGE 完成"
echo "═══════════════════════════════════════════════════════════"
echo " 域名: https://$DOMAIN/"
echo " 数据根: $DATA_ROOT/"
echo " 中文回执: $DEPLOY_REPORT"
echo " 快照: $PRE_SNAP"
echo " 下一步: PR-4 填 portal 业务代码"
echo