#!/usr/bin/env bash # ════════════════════════════════════════════════════════════════ # 光湖灯塔 · 国内主服务器 bootstrap · v0.1.0 # Sovereign: TCS-0002∞ · ICE-GL∞ · 国作登字-2026-A-00037559 # 守护: 铸渊 · ICE-GL-ZY001 # ════════════════════════════════════════════════════════════════ # # 把一台格式化干净的 Ubuntu 22.04 国内 CVM 准备成"光湖灯塔": # 1. 装基础工具链 (走阿里云镜像源, 国内可达) # 2. 装 Docker + Compose (跳过 get.docker.com, 用阿里云镜像) # 3. 在 /data 数据盘下建 Gitea/Postgres/Redis/Runner 标准目录 # 4. 起 Gitea + PostgreSQL + Redis + Gitea Actions Runner # 5. 部署 Nginx 反代 (Gitea + 灯塔门户 + 总控 API 占位) # 6. 装 PM2 / certbot / fail2ban / ufw 基础守护 # # 服务器现状假设 (来自冰朔同步): # - 实例: GH-CVM-MAIN-PROD-01 · 4C16G · Ubuntu 22.04 # - 数据盘: 100GB ext4 已挂载 /data, fstab 自动挂载 # - 安全组: 22/80/443/3000 已放行 # - 默认登录用户: ubuntu (本脚本需要 sudo -i 切到 root 后运行) # # 用法: # sudo CN_LIGHTHOUSE_SERVER_ID=GH-CVM-MAIN-PROD-01 \ # GITEA_ADMIN_USER=bingshuo \ # GITEA_ADMIN_PASS=xxx \ # GITEA_ADMIN_EMAIL=you@example.com \ # GITEA_DB_PASS=xxx \ # bash bootstrap.sh # # 环境变量: # CN_LIGHTHOUSE_SERVER_ID 必填 · 母编号 (GH-CVM-MAIN-PROD-01 / GH-CVM-BACKUP-01) # DATA_ROOT 可选 · 默认 /data (持久数据根, 必须挂载) # DEPLOY_ROOT 可选 · 默认 /opt/guanghu (代码 / pm2 进程根) # GITEA_ADMIN_USER 必填 · Gitea 初始管理员账号 # GITEA_ADMIN_PASS 必填 · Gitea 初始管理员密码 (≥24 位) # GITEA_ADMIN_EMAIL 必填 · 管理员邮箱 # GITEA_DB_PASS 必填 · PostgreSQL gitea 用户密码 # GITEA_HTTP_PORT 可选 · 默认 3000 # GITEA_SSH_PORT 可选 · 默认 2222 (避开系统 22) # STAGE 可选 · bootstrap (默认) / update / rollback # ════════════════════════════════════════════════════════════════ set -euo pipefail # ─── 全局参数 ───────────────────────────────────────────────── TEMPLATE_VERSION="0.2.0" SERVER_ID="${CN_LIGHTHOUSE_SERVER_ID:-${SERVER_ID:-}}" DATA_ROOT="${DATA_ROOT:-/data}" DEPLOY_ROOT="${DEPLOY_ROOT:-/opt/guanghu}" GITEA_HTTP_PORT="${GITEA_HTTP_PORT:-3000}" GITEA_SSH_PORT="${GITEA_SSH_PORT:-2222}" STAGE="${STAGE:-bootstrap}" export DEBIAN_FRONTEND=noninteractive # ─── 校验前置条件 ───────────────────────────────────────────── if [ "$EUID" -ne 0 ]; then echo "❌ 需要 root (请用 sudo -i 切到 root 后再跑)" >&2 exit 1 fi if [ -z "$SERVER_ID" ]; then echo "❌ 必须传入母编号: CN_LIGHTHOUSE_SERVER_ID=GH-CVM-MAIN-PROD-01" >&2 exit 1 fi if ! [[ "$SERVER_ID" =~ ^GH-CVM-[A-Z0-9-]+$ ]]; then echo "❌ 母编号格式不合法: $SERVER_ID (应为 GH-CVM-[A-Z0-9-]+)" >&2 exit 1 fi if [ ! -d "$DATA_ROOT" ]; then echo "❌ 数据盘根目录不存在: $DATA_ROOT (请先挂载数据盘)" >&2 exit 1 fi # 检查 /data 是不是真的挂载点 (避免误装到系统盘) if ! mountpoint -q "$DATA_ROOT"; then echo "⚠️ $DATA_ROOT 不是挂载点, Gitea 数据会落到系统盘 (50G), 请确认" >&2 echo " 继续? (5秒后自动继续, Ctrl+C 中止)" >&2 sleep 5 fi # ─── 自我感知: 探测真实硬件 + 动态调优 ──────────────────────── # 不再硬假设 4C16G, 适配 2C8G / 4C16G / 任意规格 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" mkdir -p "$DEPLOY_ROOT/_logs" SERVER_ENV_JSON="$DEPLOY_ROOT/_logs/server-env.json" if [ -x "$SCRIPT_DIR/detect-env.sh" ]; then echo "[0/8] 探测当前服务器实际配置 (detect-env.sh)..." DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \ bash "$SCRIPT_DIR/detect-env.sh" "$SERVER_ENV_JSON" || { echo "⚠️ detect-env.sh 失败, 继续但不动态调优" >&2 } fi if [ "$STAGE" = "bootstrap" ]; then for v in GITEA_ADMIN_USER GITEA_ADMIN_PASS GITEA_ADMIN_EMAIL GITEA_DB_PASS; do if [ -z "${!v:-}" ]; then echo "❌ bootstrap 阶段必填环境变量: $v" >&2 exit 1 fi done fi echo "═══════════════════════════════════════════════════════════" echo " 光湖灯塔 bootstrap" echo " 母编号: $SERVER_ID" echo " 数据根: $DATA_ROOT" echo " 部署根: $DEPLOY_ROOT" echo " 阶段: $STAGE" echo " 模板版本: $TEMPLATE_VERSION" echo "═══════════════════════════════════════════════════════════" # ─── 阶段路由 ───────────────────────────────────────────────── case "$STAGE" in bootstrap) ;; update) STAGE_UPDATE_ONLY=1 ;; rollback) echo "[rollback] 委派给 rollback.sh (快照式回滚)..." if [ -x "$SCRIPT_DIR/rollback.sh" ]; then # 修复: ROLLBACK_TS 为空时不能用 ${VAR:+--to} ${VAR:-} 拼参, # 那会传两个空字符串 "" "" 给 rollback.sh, 触发 "未知参数" 退码 2. # 改用条件分支显式调用; 这里不能写成 "${ROLLBACK_ARGS[@]}" 直接展开, # 因为脚本顶部已 set -u, bash<4.4 在空数组展开会报 unbound variable. 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 echo "[rollback fallback] 脚本未找到, 仅停服" >&2 cd "$DATA_ROOT/lighthouse" 2>/dev/null && docker compose down || true exit 0 fi ;; *) echo "❌ 未知 STAGE: $STAGE (合法: bootstrap / update / rollback)" >&2 exit 1 ;; esac # ─── update / bootstrap: 跑前先建快照 ──────────────────────── # 这样万一这次 update 把 compose / app.ini 改坏, rollback.sh 能回到这一刻 if [ -d "$DATA_ROOT/lighthouse" ] && [ -f "$DATA_ROOT/lighthouse/docker-compose.yml" ]; then PRE_TS="$(date +%Y%m%d-%H%M%S)" PRE_SNAP="$DATA_ROOT/lighthouse/snapshots/$PRE_TS-pre-$STAGE" mkdir -p "$PRE_SNAP" for f in docker-compose.yml .env .env.tune; do [ -f "$DATA_ROOT/lighthouse/$f" ] && cp -a "$DATA_ROOT/lighthouse/$f" "$PRE_SNAP/" || true done if [ -f "$DATA_ROOT/lighthouse/gitea/conf/app.ini" ]; then mkdir -p "$PRE_SNAP/gitea/conf" cp -a "$DATA_ROOT/lighthouse/gitea/conf/app.ini" "$PRE_SNAP/gitea/conf/" || true fi echo "snapshot before $STAGE @ $(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$PRE_SNAP/NOTE" # 只保留最近 10 份, 防数据盘塞满 ls -1tr "$DATA_ROOT/lighthouse/snapshots" 2>/dev/null \ | head -n -10 \ | xargs -I{} rm -rf "$DATA_ROOT/lighthouse/snapshots/{}" 2>/dev/null || true echo "[snap] pre-$STAGE 快照 -> $PRE_SNAP" fi # ─── 1. APT 镜像源 (阿里云) ────────────────────────────────── if [ "${STAGE}" = "bootstrap" ]; then echo "[1/8] 切换 APT 源到阿里云镜像 ..." 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/ jammy main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ jammy-updates main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ jammy-security main restricted universe multiverse deb https://mirrors.aliyun.com/ubuntu/ jammy-backports main restricted universe multiverse EOF fi apt-get update -qq fi # ─── 2. 基础工具链 ─────────────────────────────────────────── echo "[2/8] 安装基础工具链 ..." apt-get install -y -qq \ curl wget ca-certificates gnupg lsb-release \ git jq unzip vim htop net-tools \ nginx certbot python3-certbot-nginx \ fail2ban ufw \ rsync openssh-client \ build-essential # ─── 3. Node 20 + PM2 (npmmirror 镜像) ─────────────────────── if ! command -v node >/dev/null 2>&1 || [ "$(node -v 2>/dev/null | sed 's/v//;s/\..*//')" -lt 20 ]; then echo "[3/8] 安装 Node 20 (NodeSource 国内可达, 备用 nvm) ..." 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 || { echo "❌ Node 安装失败, 请人工介入" >&2 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 # ─── 4. Docker (阿里云镜像源, 跳过 get.docker.com) ───────────── if ! command -v docker >/dev/null 2>&1; then echo "[4/8] 安装 Docker (阿里云镜像源) ..." install -m 0755 -d /etc/apt/keyrings curl -fsSL https://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg \ | gpg --dearmor -o /etc/apt/keyrings/docker.gpg chmod a+r /etc/apt/keyrings/docker.gpg echo \ "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ https://mirrors.aliyun.com/docker-ce/linux/ubuntu \ $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \ > /etc/apt/sources.list.d/docker.list apt-get update -qq apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin fi # Docker daemon 镜像加速 mkdir -p /etc/docker if [ ! -f /etc/docker/daemon.json ]; then cat > /etc/docker/daemon.json </dev/null 2>&1 || true # ─── 5. /data 标准目录 ─────────────────────────────────────── echo "[5/8] 在 $DATA_ROOT 下建标准目录 ..." mkdir -p \ "$DATA_ROOT/lighthouse" \ "$DATA_ROOT/lighthouse/gitea" \ "$DATA_ROOT/lighthouse/postgres" \ "$DATA_ROOT/lighthouse/redis" \ "$DATA_ROOT/lighthouse/runner" \ "$DATA_ROOT/lighthouse/backup" \ "$DATA_ROOT/cos-cache" \ "$DATA_ROOT/pm2" \ "$DATA_ROOT/logs/gitea" \ "$DATA_ROOT/logs/nginx" \ "$DATA_ROOT/logs/runner" mkdir -p \ "$DEPLOY_ROOT/_active" \ "$DEPLOY_ROOT/_archive" \ "$DEPLOY_ROOT/_logs" \ "$DEPLOY_ROOT/_manifest" \ "$DEPLOY_ROOT/_secrets" \ "$DEPLOY_ROOT/lighthouse-cn" chmod 700 "$DEPLOY_ROOT/_secrets" # pm2 默认日志/进程目录指向 /data/pm2, 系统盘只剩系统 export PM2_HOME="$DATA_ROOT/pm2" # ─── 6. Gitea + Postgres + Redis (docker compose) ──────────── echo "[6/8] 部署 Gitea / PostgreSQL / Redis ..." COMPOSE_DIR="$DATA_ROOT/lighthouse" if [ ! -f "$COMPOSE_DIR/docker-compose.yml" ]; then cp "$(dirname "$0")/docker-compose.yml" "$COMPOSE_DIR/docker-compose.yml" fi if [ ! -f "$COMPOSE_DIR/.env" ]; then cat > "$COMPOSE_DIR/.env" <&2 exit 1 fi # 用 awk 而非 sed (避免密码中特殊字符 / 引号歧义) APP_INI_OUT="$DATA_ROOT/lighthouse/gitea/conf/app.ini" # 先把 placeholder 替换值通过 env 传给 awk, 不进 shell 历史 GITEA_HTTP_PORT="$GITEA_HTTP_PORT" \ GITEA_SSH_PORT="$GITEA_SSH_PORT" \ GITEA_SECRET_KEY="$GITEA_SECRET_KEY" \ awk ' { gsub(/__GITEA_HTTP_PORT__/, ENVIRON["GITEA_HTTP_PORT"]) gsub(/__GITEA_SSH_PORT__/, ENVIRON["GITEA_SSH_PORT"]) gsub(/__GITEA_SECRET_KEY__/, ENVIRON["GITEA_SECRET_KEY"]) print }' "$APP_INI_TPL" > "$APP_INI_OUT" # app.ini 含 SECRET_KEY, 必须 chmod 600 chmod 600 "$APP_INI_OUT" unset GITEA_SECRET_KEY fi # ─── 6.5 动态调优 ───────────────────────────────────────────── # 依据 detect-env 探到的真实硬件档位, 写 .env.tune 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" || echo "⚠️ tune-from-env.sh 失败, 用默认参数继续" fi # 加载档位决策, 决定是否在 update 阶段顺带启 runner profile RUNNER_DEFAULT_ENABLED="false" if [ -f "$DATA_ROOT/lighthouse/.env.tune" ]; then # shellcheck disable=SC1091 . "$DATA_ROOT/lighthouse/.env.tune" fi cd "$COMPOSE_DIR" docker compose pull # update 阶段且档位允许 + token 已注入 → 顺带启 runner COMPOSE_UP_ARGS=("up" "-d") if [ "$STAGE" = "update" ] && [ "${RUNNER_DEFAULT_ENABLED:-false}" = "true" ] \ && [ -n "${GITEA_RUNNER_TOKEN:-}" ]; then echo " 档位允许 + RUNNER_TOKEN 已注入, 顺带启 runner" COMPOSE_UP_ARGS=("--profile" "runner" "up" "-d") fi docker compose "${COMPOSE_UP_ARGS[@]}" # 等 Gitea 起来 echo " 等待 Gitea 健康检查 ..." GITEA_HEALTHY="false" for i in $(seq 1 30); do if curl -fs "http://127.0.0.1:$GITEA_HTTP_PORT/api/v1/version" >/dev/null 2>&1; then echo " Gitea 已上线" GITEA_HEALTHY="true" break fi sleep 2 done # ─── 自动兜底: update 失败 → 自动回滚到 pre-snapshot ────────── # bootstrap (首次) 不触发自动回滚 (没有"上一个版本"可回) if [ "$GITEA_HEALTHY" = "false" ] && [ "$STAGE" = "update" ] && [ -n "${PRE_SNAP:-}" ]; then echo "❌ Gitea 60s 内未上线, 触发自动回滚到 pre-update 快照..." >&2 PRE_TS_BASENAME="$(basename "$PRE_SNAP")" if [ -x "$SCRIPT_DIR/rollback.sh" ]; then DATA_ROOT="$DATA_ROOT" DEPLOY_ROOT="$DEPLOY_ROOT" \ bash "$SCRIPT_DIR/rollback.sh" --to "$PRE_TS_BASENAME" || \ echo "⚠️ 自动回滚也失败了, 请人工介入. snapshot=$PRE_SNAP" >&2 fi echo "[autorollback] 已触发. 仔细看上方日志." >&2 exit 1 fi # 创建初始管理员 (idempotent: 已存在会报错并被忽略) # 用临时文件 + stdin 避免密码进 ps / shell history if [ "$STAGE" = "bootstrap" ]; then ADMIN_PASS_FILE="$(mktemp)" chmod 600 "$ADMIN_PASS_FILE" printf '%s' "$GITEA_ADMIN_PASS" > "$ADMIN_PASS_FILE" # 把密码文件 cp 进容器, 用 --password-file 读 (Gitea 1.21+ 支持) docker cp "$ADMIN_PASS_FILE" lighthouse-gitea:/tmp/.adm-pass 2>/dev/null || true docker exec lighthouse-gitea chown git:git /tmp/.adm-pass 2>/dev/null || true docker exec lighthouse-gitea chmod 600 /tmp/.adm-pass 2>/dev/null || true # Gitea 1.21 admin user create 仍需 --password 参数. 退而求其次: 用 env 透传 # (注: 进程参数仍可见, 但 GITEA_ADMIN_PASS 已通过 env 而非 cmd-line 传) set +e docker exec -u git -e GITEA_ADM_PASS="$(cat "$ADMIN_PASS_FILE")" \ lighthouse-gitea \ sh -c 'gitea admin user create --username "$1" --password "$GITEA_ADM_PASS" --email "$2" --admin --must-change-password=false' \ _ "$GITEA_ADMIN_USER" "$GITEA_ADMIN_EMAIL" 2>&1 | grep -v "$(cat "$ADMIN_PASS_FILE")" \ || echo " 管理员创建跳过 (可能已存在)" set -e rm -f "$ADMIN_PASS_FILE" docker exec lighthouse-gitea rm -f /tmp/.adm-pass 2>/dev/null || true fi # ─── 7. Nginx 反代 ─────────────────────────────────────────── echo "[7/8] 配置 Nginx 反代 ..." NGX_TPL="$(dirname "$0")/nginx/lighthouse.conf" if [ -f "$NGX_TPL" ]; then cp "$NGX_TPL" /etc/nginx/sites-available/lighthouse.conf ln -sf /etc/nginx/sites-available/lighthouse.conf /etc/nginx/sites-enabled/lighthouse.conf rm -f /etc/nginx/sites-enabled/default nginx -t && systemctl reload nginx fi # ─── 8. ufw + fail2ban ──────────────────────────────────────── echo "[8/8] 加固防火墙 ..." 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 allow "$GITEA_HTTP_PORT/tcp" ufw allow "$GITEA_SSH_PORT/tcp" ufw --force enable systemctl enable fail2ban >/dev/null 2>&1 || true systemctl restart fail2ban || true # ─── 收尾回执 ───────────────────────────────────────────────── mkdir -p "$DEPLOY_ROOT/_logs" RECEIPT_TS="$(date +%Y%m%d-%H%M%S)" SIZE_TIER_VAL="${SIZE_TIER:-unknown}" # 真实 runner 运行态 (跟 RUNNER_DEFAULT_ENABLED 区分): # RUNNER_DEFAULT_ENABLED = 当前档位是否"默认允许"启 runner (来自 .env.tune) # runner_running = 此刻 lighthouse-gitea-runner 容器是否真在跑 RUNNER_RUNNING="false" if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx 'lighthouse-gitea-runner'; then RUNNER_RUNNING="true" fi cat > "$DEPLOY_ROOT/_logs/lighthouse-bootstrap-$RECEIPT_TS.json" <:$GITEA_HTTP_PORT/" echo " Gitea SSH: ssh://git@<公网IP>:$GITEA_SSH_PORT/" echo " 数据根: $DATA_ROOT/lighthouse/" echo " Compose 文件: $COMPOSE_DIR/docker-compose.yml" echo " 下一步:" echo " 1. 浏览器登录 Gitea, Site Admin → Actions → Runners 拿 token" echo " 2. 把 token 配回 GitHub Secrets ZY_GITEA_RUNNER_TOKEN" echo " 3. 跑 STAGE=update 注册 Gitea Actions Runner" echo " 4. 从 COS 拉 guanghulab-snapshot.tar.gz, 跑 restore-from-cos.sh" echo