#!/usr/bin/env bash set -euo pipefail umask 077 ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INSTALL_DIR="$ROOT" DATA_DIR="" HOST="127.0.0.1" PORT="3210" HOST_SET=false PORT_SET=false SERVICE="qq-agent-linux" DEPLOY_REPOSITORY="" DEPLOY_BRANCH="" IMPORT_BRIDGE="" CREDENTIAL_FILE="" NODE_BIN="${NODE_BIN:-}" NODE_VERSION="${QQ_AGENT_NODE_VERSION:-22.23.3}" BACKUP_ENABLED=true ALLOW_PATH_CHANGE=false usage() { cat <<'EOF' Usage: bash deploy.sh [options] Options: --install-dir PATH Application directory (default: repository directory) --data-dir PATH Persistent data directory (default: INSTALL_DIR/data) --host ADDRESS Console bind address (default: 127.0.0.1; on updates the address already recorded in config.json is reused) --port PORT Console port (default: 3210; on updates the port already recorded in config.json is reused) --service NAME systemd user service name (default: qq-agent-linux) --node PATH Existing Node.js >=22.19 binary --import-bridge PATH Import legacy Bridge config on first install --repository URL Expected origin repository (auto-update passes it; compared against .deployment.json when provided) --branch NAME Expected source branch (compared when provided) --credential-file PATH Import DEEPSEEK_API_KEY on first install --no-backup Skip the pre-deployment code snapshot --allow-path-change Allow the persistent data directory to move (requires QQ_AGENT_ALLOW_PATH_CHANGE=1 in the environment) — both conditions are checked before anything changes -h, --help Show this help EOF } require_value() { (($# >= 2)) || { printf 'Missing value for %s\n' "$1" >&2; exit 2; } } while (($#)); do case "$1" in --install-dir) require_value "$@"; INSTALL_DIR="$2"; shift 2 ;; --data-dir) require_value "$@"; DATA_DIR="$2"; shift 2 ;; --host) require_value "$@"; HOST="$2"; HOST_SET=true; shift 2 ;; --port) require_value "$@"; PORT="$2"; PORT_SET=true; shift 2 ;; --service) require_value "$@"; SERVICE="$2"; shift 2 ;; --repository) require_value "$@"; DEPLOY_REPOSITORY="$2"; shift 2 ;; --branch) require_value "$@"; DEPLOY_BRANCH="$2"; shift 2 ;; --node) require_value "$@"; NODE_BIN="$2"; shift 2 ;; --import-bridge) require_value "$@"; IMPORT_BRIDGE="$2"; shift 2 ;; --credential-file) require_value "$@"; CREDENTIAL_FILE="$2"; shift 2 ;; --no-backup) BACKUP_ENABLED=false; shift ;; # 数据目录迁移的逃生开关:校验器要求「本参数 + 环境变量 QQ_AGENT_ALLOW_PATH_CHANGE=1」双条件。 # 此前本脚本没收这个参数,校验器却让用户加它 → 迁移永远做不成、提示还指向不存在的开关 # (2026-09-30 审查 P1)。 --allow-path-change) ALLOW_PATH_CHANGE=true; shift ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; esac done [[ "$(uname -s)" == Linux ]] || { printf 'Linux only\n' >&2; exit 1; } if ((EUID == 0)); then printf 'Warning: running deploy.sh as root is not recommended; the service will run as root. Prefer the service user or install.sh.\n' >&2 fi [[ "$SERVICE" =~ ^[a-zA-Z0-9_-]+$ ]] || exit 2 [[ "$PORT" =~ ^[0-9]+$ ]] && ((PORT >= 1 && PORT <= 65535)) || { printf 'Port must be an integer from 1 to 65535\n' >&2 exit 2 } [[ "$INSTALL_DIR" = /* ]] || { printf 'Use an absolute installation path\n' >&2; exit 2; } DATA_DIR="${DATA_DIR:-$INSTALL_DIR/data}" [[ "$DATA_DIR" = /* ]] || { printf 'Use an absolute data path\n' >&2; exit 2; } for p in "$INSTALL_DIR" "$DATA_DIR"; do [[ "$p" != *[[:space:]%\"]* ]] || { printf 'Deployment paths must not contain whitespace, %% or quotes: %s\n' "$p" >&2 exit 2 } done # 两个方向都要拒:安装目录在源码仓库里 → rsync --delete 会删掉仓库里的文件; # 源码仓库在安装目录里 → --delete 会把 data/、.runtime/、node_modules/ 连源码目录一起清掉。 # 2026-10-01 审查:原先只查了前一个方向。 # 判定前先归一化:`/srv/app` 与 `/srv//app`、`/srv/x/../app`、以及经符号链接指向同一处的写法 # 是同一个目录,只做未归一化的字符串前缀比较会漏判(另一条同类审查意见)。 # 归一化失败**不退回原串**(2026-10-01 第六轮审查):退回等于守卫静默失效 —— 带 `//`、`..` # 或符号链接的等价写法又能绕过两条嵌套判定。realpath 属 coreutils,本脚本本来就要求 # Linux + systemd + rsync + node,这里 fail-closed:调用点失败即停(见下面两条)。 canon_path() { realpath -m -- "$1" } ROOT_CANON="$(canon_path "$ROOT")" || { printf 'Cannot normalize the source path: %s\n' "$ROOT" >&2; exit 2; } INSTALL_CANON="$(canon_path "$INSTALL_DIR")" || { printf 'Cannot normalize the installation path: %s\n' "$INSTALL_DIR" >&2; exit 2; } # 共享目录判定放在归一化之后:`/srv/`、`/srv/../srv` 或指向 /srv 的符号链接 # 归一化后都会命中;只比较未归一化的原始字符串会被这些等价写法绕过。 case "$INSTALL_CANON" in /|/bin|/boot|/dev|/etc|/home|/lib|/lib64|/media|/mnt|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var) printf 'Refusing to deploy into a shared system directory: %s\n' "$INSTALL_DIR" >&2 exit 2 ;; esac if [[ "$ROOT_CANON" != "$INSTALL_CANON" && "$INSTALL_CANON" == "$ROOT_CANON/"* ]]; then printf 'Installation path must not be nested inside the source repository\n' >&2 exit 2 fi if [[ "$ROOT_CANON" != "$INSTALL_CANON" && "$ROOT_CANON" == "$INSTALL_CANON/"* ]]; then printf 'The source repository must not be nested inside the installation path\n' >&2 exit 2 fi # 数据目录 = 安装目录时,下面按相对路径排除数据的逻辑会退化成只排除 /data/,而源码树里没有 # data/ —— 第二次部署的 `rsync -a --delete` 会把 config.json / SQLite / 会话全删掉。 # 这是"传了两个参数看起来都合理"的组合,必须在任何改动之前拒绝(与 rsync --delete 同一口径)。 DATA_CANON="$(canon_path "$DATA_DIR")" || { printf 'Cannot normalize the data path: %s\n' "$DATA_DIR" >&2; exit 2; } if [[ "$DATA_CANON" == "$INSTALL_CANON" ]]; then printf 'The data directory must be different from the installation directory: rsync --delete would wipe it\n' >&2 exit 2 fi # 网页终端 / su / sudo -u 等非登录会话不设置 XDG_RUNTIME_DIR:systemctl --user 会报 # "Failed to connect to bus",哪怕用户管理器在跑(2026-10-12 报道在生产机复现:只差这一个 # 环境变量)。目录存在就补上;用户自己的值不动。控制台触发的部署由 ops.js 注入,不受影响。 if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "/run/user/$(id -u)" ]]; then export XDG_RUNTIME_DIR="/run/user/$(id -u)" fi command -v systemctl >/dev/null || { printf 'systemctl is required: deploy on a Linux host with systemd\n' >&2; exit 1; } systemctl --user show-environment >/dev/null || { printf 'Cannot reach the systemd user manager for %s: non-login shells (web terminals, su) need XDG_RUNTIME_DIR — run this script from a normal login session (for example over SSH), or export XDG_RUNTIME_DIR=/run/user/$(id -u) and retry; if that directory does not exist, run "sudo loginctl enable-linger %s" first\n' "$(id -un)" "$(id -un)" >&2; exit 1; } command -v rsync >/dev/null || { printf 'rsync is required: install it first (Debian/Ubuntu: apt install -y rsync)\n' >&2; exit 1; } mkdir -p "$INSTALL_DIR" "$DATA_DIR" LOCK_DIR="$DATA_DIR/.deploy.lock" TAKEOVER_DIR="$LOCK_DIR.takeover" # TMP_DIR 提前声明:Node 下载失败时 set -e 直接退出,EXIT trap 里的清理要能引用到它(set -u 下未定义会报错)。 TMP_DIR="" LOCK_OWNED=false TAKEOVER_OWNED=false # trap 必须在**抢锁之前**挂上:抢锁窗口里被打断(下面那几条 exit 1、SIGTERM)也要把互斥目录收掉。 # 2026-10-01 审查 P1:原来 trap 挂在抢锁之后,于是"删锁重建失败 → exit 1"会留下互斥目录, # 而互斥没有陈旧兜底时,此后每次接管都死在"retry in a moment"——把锁修成了新的死锁。 # SIGKILL/OOM/掉电不执行 trap,那条路由下面互斥目录的 5 分钟陈旧宽限兜底。 cleanup_lock() { if [[ "$TAKEOVER_OWNED" == true ]]; then rm -rf -- "$TAKEOVER_DIR"; fi if [[ "$LOCK_OWNED" == true ]]; then rm -rf -- "$LOCK_DIR"; fi if [[ -n "$TMP_DIR" ]]; then rm -rf -- "$TMP_DIR"; fi return 0 } trap cleanup_lock EXIT # 锁的属主写成 pid + 开始时间:SIGKILL(systemd 超时补杀、OOM、掉电)不会执行 EXIT trap, # 锁目录会永久留下,无人值守的自动更新从此每次都死在第一条检查上,只能人工 rm。 # 接管条件取严:属主进程不存在 **且** 锁已超过 5 分钟 —— 刚 mkdir 还没写 pid 的锁不能算陈旧, # 否则一次并发部署会被"抢锁"。仍不满足就报出 pid/时间,让人确认后再删(2026-09-29 审查 P2)。 lock_owner_alive() { local pid pid="$(cat "$LOCK_DIR/pid" 2>/dev/null || true)" [[ -n "$pid" ]] || return 1 kill -0 "$pid" 2>/dev/null } # find 的 -mmin +5 命中时才打印路径:判"是否超过 5 分钟"要的是**输出非空**(-n)。 # 2026-10-01 审查:原先写成 -z,语义整个反过来 —— 属主已死且确实过期的锁走 else 直接 exit 1 # (SIGKILL 之后无人值守的更新就永久卡死),而"刚 mkdir 还没写 pid"的并发锁反被抢走 rm -rf。 stale_enough() { [[ -n "$(find "$1" -maxdepth 0 -mmin +5 2>/dev/null)" ]] } lock_is_stale() { ! lock_owner_alive && stale_enough "$LOCK_DIR" } # 抢到互斥之后才真正删锁重建。返回非 0 = 不该抢,调用方退出(trap 会清掉互斥)。 take_over_lock() { # 拿到互斥后**再判一次**:判定与抢互斥之间可能被调度挂起,期间别的进程可能已经建好新锁 # (它先判定、再抢到互斥、跑完释放),照删就会把人家正在用的锁删掉(2026-10-01 审查 P2)。 if ! lock_is_stale; then printf 'Another deployment is running (the lock was refreshed while taking over).\n' >&2 return 1 fi printf 'Stale deployment lock (owner process is gone for over 5 minutes): taking it over\n' >&2 rm -rf -- "$LOCK_DIR" mkdir "$LOCK_DIR" 2>/dev/null || { printf 'Another deployment may be running.\n' >&2; return 1; } LOCK_OWNED=true return 0 } if ! mkdir "$LOCK_DIR" 2>/dev/null; then if ! lock_is_stale; then printf 'Another deployment is running (pid %s, started %s).\n' \ "$(cat "$LOCK_DIR/pid" 2>/dev/null || printf '?')" "$(cat "$LOCK_DIR/started" 2>/dev/null || printf '?')" >&2 printf 'If that process is really gone, remove the lock and retry: rm -rf -- %s\n' "$LOCK_DIR" >&2 exit 1 fi # 抢锁要过一道互斥(mkdir 原子):两个并发部署同时判定"陈旧"时,若都直接 rm -rf + mkdir, # 后者会把前者刚建好的新锁删掉,两个进程同时往下走。只有一个能进这段,另一个退出。 if ! mkdir "$TAKEOVER_DIR" 2>/dev/null; then # 互斥本身也可能被 SIGKILL 遗留(trap 不执行):过 5 分钟就清掉重来, # 否则自动更新会被永久钉在"有人正在接管"上 —— 和当初那把陈旧锁是同一个坑。 if ! stale_enough "$TAKEOVER_DIR"; then printf 'Another deployment is taking over a stale lock; retry in a moment.\n' >&2 exit 1 fi printf 'Leftover takeover lock (older than 5 minutes): removing it\n' >&2 rm -rf -- "$TAKEOVER_DIR" mkdir "$TAKEOVER_DIR" 2>/dev/null || { printf 'Another deployment is taking over a stale lock; retry in a moment.\n' >&2; exit 1; } fi TAKEOVER_OWNED=true take_over_lock || exit 1 rmdir "$TAKEOVER_DIR" 2>/dev/null || true TAKEOVER_OWNED=false fi printf '%s\n' "$$" > "$LOCK_DIR/pid" printf '%s\n' "$(date -Is 2>/dev/null || date)" > "$LOCK_DIR/started" LOCK_OWNED=true node_ready() { [[ -n "$1" && -x "$1" ]] || return 1 # 低限 22.19:运行期依赖 undici 8 声明 engines >=22.19(2026-10-01 升级, # Dependabot 的 undici 6→8);deploy.sh 自己下载的 .runtime 是 22.23.3,不受影响。 "$1" --input-type=module -e ' const [major,minor]=process.versions.node.split(".").map(Number); if (major<22 || (major===22 && minor<19)) process.exit(1); const {DatabaseSync}=await import("node:sqlite"); new DatabaseSync(":memory:").close(); ' >/dev/null 2>&1 } if [[ -z "$NODE_BIN" ]]; then NODE_BIN="$(command -v node || true)" fi # 2026-10-06 复审 P2:无系统 Node 时先复用 $INSTALL_DIR/.runtime 里已装好的运行时。 # 原先只探测 `command -v node`,服务器重跑部署(deploy-all 的镜像拉取失败提示就是 # "重跑 deploy-all.sh",而它调 deploy.sh 不带 --node)会每次都从 nodejs.org 全量重下, # nodejs.org 不可达时整个部署失败 —— 哪怕磁盘上就有校验过的可用 node。 RUNTIME_DIR="$INSTALL_DIR/.runtime" NODE_ARCH='' case "$(uname -m)" in x86_64) NODE_ARCH=x64 ;; aarch64|arm64) NODE_ARCH=arm64 ;; *) NODE_ARCH='' ;; esac if [[ -n "$NODE_ARCH" ]] && ! node_ready "$NODE_BIN"; then CANDIDATE_NODE="$RUNTIME_DIR/node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node" if node_ready "$CANDIDATE_NODE"; then NODE_BIN="$CANDIDATE_NODE" printf 'Reusing existing Node.js runtime at %s\n' "$CANDIDATE_NODE" fi fi # 精确补丁版没命中时,先认上次部署记下的运行时,再退到 .runtime 里任意一个合格版本: # 升级 NODE_VERSION(22.23.2 → 22.23.3)不该让离线机器因为"版本号不同"重新下载。 if ! node_ready "$NODE_BIN" && [[ -r "$INSTALL_DIR/.deployment-node" ]]; then RECORDED_NODE="$(tr -d '\r\n' < "$INSTALL_DIR/.deployment-node" 2>/dev/null || true)" if node_ready "$RECORDED_NODE"; then NODE_BIN="$RECORDED_NODE" printf 'Reusing recorded Node.js runtime at %s\n' "$NODE_BIN" fi fi if ! node_ready "$NODE_BIN" && [[ -n "$NODE_ARCH" && -d "$RUNTIME_DIR" ]]; then while IFS= read -r CANDIDATE_NODE; do [[ -n "$CANDIDATE_NODE" ]] || continue if node_ready "$CANDIDATE_NODE"; then NODE_BIN="$CANDIDATE_NODE" printf 'Reusing existing Node.js runtime at %s\n' "$NODE_BIN" break fi done < <(find "$RUNTIME_DIR" -maxdepth 4 -type f \ -path "*/node-v*-linux-${NODE_ARCH}/bin/node" -print 2>/dev/null | sort -rV) fi if ! node_ready "$NODE_BIN"; then command -v curl >/dev/null command -v sha256sum >/dev/null command -v tar >/dev/null command -v xz >/dev/null || { printf 'xz is required to unpack the Node.js archive (install xz-utils)\n' >&2; exit 1; } if [[ -z "$NODE_ARCH" ]]; then printf 'Unsupported CPU architecture: %s\n' "$(uname -m)" >&2; exit 1 fi ARCHIVE="node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" TMP_DIR="$(mktemp -d)" printf 'Installing Node.js v%s for %s...\n' "$NODE_VERSION" "$NODE_ARCH" curl -fsSLo "$TMP_DIR/$ARCHIVE" "https://nodejs.org/dist/v${NODE_VERSION}/$ARCHIVE" curl -fsSLo "$TMP_DIR/SHASUMS256.txt" "https://nodejs.org/dist/v${NODE_VERSION}/SHASUMS256.txt" (cd "$TMP_DIR" && grep " $ARCHIVE$" SHASUMS256.txt | sha256sum -c -) mkdir -p "$RUNTIME_DIR" tar -xJf "$TMP_DIR/$ARCHIVE" -C "$RUNTIME_DIR" rm -rf "$TMP_DIR" NODE_BIN="$RUNTIME_DIR/node-v${NODE_VERSION}-linux-${NODE_ARCH}/bin/node" fi node_ready "$NODE_BIN" || { printf 'Node.js >=22.19 with node:sqlite is required\n' >&2; exit 1; } NODE_BIN="$("$NODE_BIN" -p 'process.execPath')" [[ "$NODE_BIN" != *[[:space:]%\"]* ]] || { printf 'Node path contains unsupported characters\n' >&2; exit 2; } export PATH="$(dirname "$NODE_BIN"):$PATH" # 更新已有安装时,没有显式给出的 --host/--port 沿用 config.json 里的现值:用默认值覆盖会让一次 # 普通更新把控制台从"所有网卡"或原来的地址悄悄改成只监听本机。 if [[ "$HOST_SET" != true || "$PORT_SET" != true ]] && [[ -f "$DATA_DIR/config.json" ]]; then mapfile -t PREVIOUS_ENDPOINT < <("$NODE_BIN" -e ' const fs = require("node:fs"); const out = ["", ""]; try { const cfg = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); if (typeof cfg?.server?.host === "string") out[0] = cfg.server.host; if (Number.isInteger(cfg?.server?.port)) out[1] = String(cfg.server.port); } catch { /* 配置损坏时按新装处理,交给 configure-linux 报错 */ } process.stdout.write(`${out[0]}\n${out[1]}\n`); ' "$DATA_DIR/config.json" 2>/dev/null) if [[ "$HOST_SET" != true && -n "${PREVIOUS_ENDPOINT[0]:-}" ]]; then HOST="${PREVIOUS_ENDPOINT[0]}" fi if [[ "$PORT_SET" != true && -n "${PREVIOUS_ENDPOINT[1]:-}" ]]; then PORT="${PREVIOUS_ENDPOINT[1]}" fi [[ "$PORT" =~ ^[0-9]+$ ]] && ((PORT >= 1 && PORT <= 65535)) || { printf 'Port from config.json must be an integer from 1 to 65535, got: %s\n' "$PORT" >&2 exit 2 } [[ "$HOST" != *[[:space:]%\"]* ]] || { printf 'Listen address must not contain whitespace, %% or quotes: %s\n' "$HOST" >&2 exit 2 } [[ "$HOST_SET" == true && "$PORT_SET" == true ]] \ || printf 'Reusing the recorded listen endpoint %s:%s (pass --host/--port to change it)\n' "$HOST" "$PORT" fi # 部署目标与 .deployment.json 记录的一致性强校验(改进方案 C7/#3):install-dir/data/ # service/repository/branch 与记录不符时,在部署真正开始前拒绝退出 —— 此处 ERR trap # 尚未挂载,拒绝即"部署未开始":服务未停、未 rsync、.deploy-in-progress 未落,不需要 # 也不会走回滚。host/port 的真相源是 config.json(上方沿用逻辑保证不覆盖现值),与记录 # 不一致仅提示漂移不拒绝,否则"控制台改监听地址后自动更新"会被误拒。 # 调用点放在"沿用端点"之后:这样 --host/--port 传的是本次真正会用的值,漂移提示才有意义 # (2026-09-30 审查:放在沿用之前会拿默认值误报漂移,或干脆不传导致提示成死分支)。 VERIFY_ARGS=(--install-dir "$INSTALL_DIR" --data-dir "$DATA_DIR" --service "$SERVICE" --host "$HOST" --port "$PORT" --source-root "$ROOT") [[ -n "$DEPLOY_REPOSITORY" ]] && VERIFY_ARGS+=(--repository "$DEPLOY_REPOSITORY") [[ -n "$DEPLOY_BRANCH" ]] && VERIFY_ARGS+=(--branch "$DEPLOY_BRANCH") [[ "$ALLOW_PATH_CHANGE" == true ]] && VERIFY_ARGS+=(--allow-path-change) if ! "$NODE_BIN" "$ROOT/scripts/verify-deployment-target.mjs" "${VERIFY_ARGS[@]}"; then exit 2 fi for required in package.json package-lock.json src/server.js src/auto-update.js scripts/auto-update.mjs scripts/configure-linux.mjs scripts/install-service.mjs scripts/manage.mjs manage.sh; do [[ -f "$ROOT/$required" ]] || { printf 'Source repository is incomplete: missing %s\n' "$required" >&2 exit 1 } done "$NODE_BIN" --check "$ROOT/src/server.js" "$NODE_BIN" --check "$ROOT/scripts/configure-linux.mjs" "$NODE_BIN" --check "$ROOT/scripts/install-service.mjs" bash -n "$ROOT/manage.sh" RSYNC_PRESERVE=( --exclude=/.git/ --exclude=/.runtime/ --exclude=/.deployment.json --exclude=/.deployment-node --exclude=/.dbg/ --exclude='/debug-*.md' --exclude=/.env --exclude='/.env.*' --exclude='*.log' # 更新器跑测试时会在 checkout 根建这两个目录(runner 会在 rsync 前删掉,这里再兜一层: # rsync 是 `-a --delete` 从 checkout 根同步的,根目录多什么就会被部署什么) --exclude='/.auto-update-test-*/' # Docker 全栈是另一种部署方式,不是 systemd 安装树的一部分。不铺进安装目录, # 既是避免用户误跑 root docker-compose,也是保护安装目录里用户自己的同名文件 #(排除规则同时挡住 rsync --delete)。 --exclude=/docker-compose.yml --exclude='/docker-compose.*.yml' --exclude=/Dockerfile --exclude=/.dockerignore --exclude=/docker/ --exclude='/scripts/docker-*.mjs' --exclude='/scripts/docker-*.sh' ) # 用**规范化**后的路径算相对路径:原始写法带 `/./`、`/x/../`、尾斜杠或符号链接时, # 字符串前缀比较会失败 → 排除规则匹配不到真实目录 → `rsync --delete` 把数据删掉 #(2026-10-19 复审 P1)。DATA_CANON/INSTALL_CANON 在上面的路径守卫里已算好。 # # 原始条目单独保护:数据目录可能是安装目录里指向别处的**符号链接**(数据盘独立), # 此时 DATA_CANON 落在安装目录之外,下面的 canonical 分支保护不到这个链接本身。 # 规则**不能带尾斜杠**:rsync 的 `dir/` 只匹配目录,符号链接在删扫时按普通文件分类, # 带斜杠的规则匹配不到它(2026-10-19 复核实测语义)。同时排除条目与内容。 # rsync 的 exclude 是模式语言:数据目录名里出现 * ? [ 时会被当成通配符, # 例如 data[1] 的规则会匹配 data1/,真正的 data[1]/ 反而没被排除、被 --delete 删掉。 # 路径本身仍然合法,所以这里转义而不是拒绝。 rsync_escape() { local s="$1" s="${s//\\/\\\\}" s="${s//\*/\\*}" s="${s//\?/\\?}" s="${s//\[/\\[}" printf '%s' "$s" } DATA_REL="" if [[ "$DATA_CANON" == "$INSTALL_CANON/"* ]]; then DATA_REL="${DATA_CANON#"$INSTALL_CANON"/}" DATA_REL_ESC="$(rsync_escape "$DATA_REL")" RSYNC_PRESERVE+=(--exclude="/$DATA_REL_ESC/") else RSYNC_PRESERVE+=(--exclude=/data/) fi # 原始条目单独保护:数据目录可能是安装目录里指向别处的符号链接(数据盘独立), # 此时 DATA_CANON 落在安装目录之外,上面的 canonical 分支保护不到这个链接本身。 # 规则不能带尾斜杠(rsync 的 `dir/` 只匹配目录,符号链接按普通文件分类)。 DATA_TRIMMED="$DATA_DIR" while [[ "$DATA_TRIMMED" == */ ]]; do DATA_TRIMMED="${DATA_TRIMMED%/}"; done # 用 realpath -ms 做**不跟随符号链接**的规范化:它会把 /./、/x/../、重复斜杠和 # 尾斜杠收干净,但保留最后一段(以及路径中间)的链接名字。旧写法只归一化父目录, # 遇到「父目录本身是符号链接」时后半段原始路径直接落到安装目录之外,保护整段失效。 if command -v realpath >/dev/null 2>&1; then DATA_TRIMMED="$(realpath -ms -- "$DATA_TRIMMED")" else DATA_PARENT="$(canon_path "$(dirname -- "$DATA_TRIMMED")")" DATA_TRIMMED="${DATA_PARENT%/}/$(basename -- "$DATA_TRIMMED")" fi INSTALL_TRIMMED="$(canon_path "$INSTALL_DIR")" if [[ "$DATA_TRIMMED" == "$INSTALL_TRIMMED/"* ]]; then RAW_REL="${DATA_TRIMMED#"$INSTALL_TRIMMED"/}" # 祖先里的符号链接也要保护:数据目录 /srv/app/storage/data、storage 是链接时, # 只排除最后一段 data 仍然会让 rsync --delete 把 storage 这个链接删掉。 RAW_PREFIX="" IFS='/' read -r -a RAW_PARTS <<< "$RAW_REL" for RAW_PART in "${RAW_PARTS[@]}"; do [[ -n "$RAW_PART" ]] || continue RAW_PREFIX="${RAW_PREFIX:+$RAW_PREFIX/}$RAW_PART" if [[ -L "$INSTALL_DIR/$RAW_PREFIX" ]]; then RAW_PREFIX_ESC="$(rsync_escape "$RAW_PREFIX")" RSYNC_PRESERVE+=(--exclude="/$RAW_PREFIX_ESC" --exclude="/$RAW_PREFIX_ESC/**") break fi done if [[ -n "$RAW_REL" && ( -z "$DATA_REL" || "$RAW_REL/" != "$DATA_REL/" ) ]]; then RAW_REL_ESC="$(rsync_escape "$RAW_REL")" RSYNC_PRESERVE+=(--exclude="/$RAW_REL_ESC" --exclude="/$RAW_REL_ESC/**") fi fi RSYNC_SOURCE=("${RSYNC_PRESERVE[@]}" --exclude=/node_modules/) WAS_ACTIVE=false WAS_ENABLED=false if systemctl --user is-active --quiet "$SERVICE.service"; then WAS_ACTIVE=true fi if systemctl --user is-enabled --quiet "$SERVICE.service" 2>/dev/null; then WAS_ENABLED=true fi ROLLBACK_DIR="" if [[ "$BACKUP_ENABLED" == true && -f "$INSTALL_DIR/package.json" ]]; then BACKUP_ROOT="$DATA_DIR/deploy-backups" ROLLBACK_DIR="$BACKUP_ROOT/$(date -u +%Y%m%dT%H%M%SZ)-$$" mkdir -p "$ROLLBACK_DIR/app" rsync -a "${RSYNC_PRESERVE[@]}" "$INSTALL_DIR/" "$ROLLBACK_DIR/app/" printf 'Created rollback snapshot: %s\n' "$ROLLBACK_DIR" # 快照上限 3 份在"创建时"就维护,而不是部署成功之后:连续失败的更新每次都会 # 留一份完整快照(含 node_modules),不在创建时轮转会把数据盘慢慢占满 # (自动更新无人值守场景)。刚创建的这份最新,必然保留。 kept=0 pruned=0 while IFS= read -r stale; do kept=$((kept + 1)) if ((kept > 3)); then rm -rf -- "$stale" pruned=$((pruned + 1)) fi done < <(ls -1dt "$BACKUP_ROOT"/*/ 2>/dev/null) ((pruned == 0)) || printf 'Pruned %s old snapshot(s), keeping the 3 newest\n' "$pruned" fi UNIT_FILE="$HOME/.config/systemd/user/$SERVICE.service" UPDATE_SERVICE="${SERVICE}-update" UPDATE_UNIT_FILE="$HOME/.config/systemd/user/$UPDATE_SERVICE.service" UPDATE_TIMER_FILE="$HOME/.config/systemd/user/$UPDATE_SERVICE.timer" mkdir -p "$LOCK_DIR/state" HAD_CONFIG=false HAD_ACCESS_FILE=false HAD_UNIT=false HAD_DEPLOYMENT_JSON=false HAD_DEPLOYMENT_NODE=false HAD_UPDATE_UNIT=false HAD_UPDATE_TIMER=false WAS_UPDATE_TIMER_ENABLED=false WAS_UPDATE_TIMER_ACTIVE=false if [[ -f "$DATA_DIR/config.json" ]]; then HAD_CONFIG=true cp -p "$DATA_DIR/config.json" "$LOCK_DIR/state/config.json" fi if [[ -f "$DATA_DIR/console-access.txt" ]]; then HAD_ACCESS_FILE=true cp -p "$DATA_DIR/console-access.txt" "$LOCK_DIR/state/console-access.txt" fi if [[ -f "$UNIT_FILE" ]]; then HAD_UNIT=true cp -p "$UNIT_FILE" "$LOCK_DIR/state/service.unit" fi if [[ -f "$UPDATE_UNIT_FILE" ]]; then HAD_UPDATE_UNIT=true cp -p "$UPDATE_UNIT_FILE" "$LOCK_DIR/state/update.service" fi if [[ -f "$UPDATE_TIMER_FILE" ]]; then HAD_UPDATE_TIMER=true cp -p "$UPDATE_TIMER_FILE" "$LOCK_DIR/state/update.timer" fi if systemctl --user is-enabled --quiet "$UPDATE_SERVICE.timer" 2>/dev/null; then WAS_UPDATE_TIMER_ENABLED=true fi if systemctl --user is-active --quiet "$UPDATE_SERVICE.timer" 2>/dev/null; then WAS_UPDATE_TIMER_ACTIVE=true fi if [[ -f "$INSTALL_DIR/.deployment.json" ]]; then HAD_DEPLOYMENT_JSON=true cp -p "$INSTALL_DIR/.deployment.json" "$LOCK_DIR/state/deployment.json" fi if [[ -f "$INSTALL_DIR/.deployment-node" ]]; then HAD_DEPLOYMENT_NODE=true cp -p "$INSTALL_DIR/.deployment-node" "$LOCK_DIR/state/deployment-node" fi # 部署进行中标记:停服务 → rsync --delete → npm ci → 起服务 → 健康检查这段窗口里, # 一旦被 SIGKILL/OOM/掉电打断,trap 不会执行 —— 服务停着、代码可能是半拷贝的, # 而下次开机 systemd 会把半更新的树当成正常代码拉起来(比崩掉更难查)。 # 标记落在数据目录(安装目录之外,rsync 不会碰它),应用启动时会读它并告警。 IN_PROGRESS_MARKER="$DATA_DIR/.deploy-in-progress" mark_deploy_in_progress() { ( umask 077 printf '{"pid":%s,"startedAt":"%s","installDir":"%s","dataDir":"%s","service":"%s","snapshot":"%s"}\n' \ "$$" "$(date -Is 2>/dev/null || date)" "$INSTALL_DIR" "$DATA_DIR" "$SERVICE" "${ROLLBACK_DIR:-}" \ > "$IN_PROGRESS_MARKER" ) 2>/dev/null || true } clear_deploy_marker() { rm -f -- "$IN_PROGRESS_MARKER" 2>/dev/null || true } rollback_deployment() { local status=$? # 显式调用点(npm 缺失、健康检查失败)是在 printf 之后进来的,此时 $? 已经是 0; # 那种情况必须按失败退出,否则调用方(自动更新按退出码判定)会把回滚过的部署当成成功。 ((status != 0)) || status=1 trap - ERR INT TERM HUP set +e ROLLBACK_FAILED=false # 注意:清标记必须放在**恢复动作之后**(原来放在这里): # 恢复过程中再失败(磁盘满正是常见原因)或被 SIGKILL 时,标记已经没了, # 机器停在"半新半旧"却不再报 deploy-interrupted(2026-10-13 全项目审查)。 printf '\nDeployment failed; restoring the previous installation...\n' >&2 systemctl --user stop "$SERVICE.service" >/dev/null 2>&1 systemctl --user disable --now "$UPDATE_SERVICE.timer" >/dev/null 2>&1 if [[ -n "$ROLLBACK_DIR" && -d "$ROLLBACK_DIR/app" ]]; then rsync -a --delete "${RSYNC_PRESERVE[@]}" "$ROLLBACK_DIR/app/" "$INSTALL_DIR/" || ROLLBACK_FAILED=true fi if [[ "$HAD_CONFIG" == true ]]; then cp -p "$LOCK_DIR/state/config.json" "$DATA_DIR/config.json" || ROLLBACK_FAILED=true else rm -f -- "$DATA_DIR/config.json" fi if [[ "$HAD_ACCESS_FILE" == true ]]; then cp -p "$LOCK_DIR/state/console-access.txt" "$DATA_DIR/console-access.txt" || ROLLBACK_FAILED=true else rm -f -- "$DATA_DIR/console-access.txt" fi if [[ "$HAD_UNIT" == true ]]; then mkdir -p "$(dirname "$UNIT_FILE")" cp -p "$LOCK_DIR/state/service.unit" "$UNIT_FILE" || ROLLBACK_FAILED=true else rm -f -- "$UNIT_FILE" fi if [[ "$HAD_UPDATE_UNIT" == true ]]; then cp -p "$LOCK_DIR/state/update.service" "$UPDATE_UNIT_FILE" || ROLLBACK_FAILED=true else rm -f -- "$UPDATE_UNIT_FILE" fi if [[ "$HAD_UPDATE_TIMER" == true ]]; then cp -p "$LOCK_DIR/state/update.timer" "$UPDATE_TIMER_FILE" || ROLLBACK_FAILED=true else rm -f -- "$UPDATE_TIMER_FILE" fi if [[ "$HAD_DEPLOYMENT_JSON" == true ]]; then cp -p "$LOCK_DIR/state/deployment.json" "$INSTALL_DIR/.deployment.json" || ROLLBACK_FAILED=true else rm -f -- "$INSTALL_DIR/.deployment.json" fi if [[ "$HAD_DEPLOYMENT_NODE" == true ]]; then cp -p "$LOCK_DIR/state/deployment-node" "$INSTALL_DIR/.deployment-node" || ROLLBACK_FAILED=true else rm -f -- "$INSTALL_DIR/.deployment-node" fi systemctl --user daemon-reload if [[ "$WAS_UPDATE_TIMER_ENABLED" == true ]]; then # 只恢复开机自启;原来 enabled 但被手动 stop 的,不要用 --now 擅自启动。 systemctl --user enable "$UPDATE_SERVICE.timer" >/dev/null 2>&1 fi if [[ "$WAS_UPDATE_TIMER_ACTIVE" == true ]]; then systemctl --user start "$UPDATE_SERVICE.timer" >/dev/null 2>&1 fi if [[ "$WAS_ACTIVE" == true ]]; then if [[ "$ROLLBACK_FAILED" != true ]]; then if ! systemctl --user start "$SERVICE.service"; then ROLLBACK_FAILED=true else # Type=simple 的 start 只代表 fork/exec 成功;等两秒再确认进程没立刻退出。 sleep 2 systemctl --user is-active --quiet "$SERVICE.service" || ROLLBACK_FAILED=true fi fi elif [[ "$WAS_ENABLED" == true ]]; then # 原来是 enabled 但被手动 stop:回滚只恢复开机自启,不擅自启动。 systemctl --user enable "$SERVICE.service" >/dev/null 2>&1 else systemctl --user disable --now "$SERVICE.service" >/dev/null 2>&1 fi if [[ "$ROLLBACK_FAILED" == true ]]; then # EXIT trap 会删掉整个 .deploy.lock(含 state/ 里的部署前 config/unit 备份)。 # 回滚失败时先把它们复制到数据目录外的恢复目录,否则提示里的“手动恢复”没有材料。 if [[ -d "$LOCK_DIR/state" ]]; then RECOVERY_STATE_DIR="$DATA_DIR/deploy-recovery-$(date +%Y%m%dT%H%M%S)" if mkdir -p "$RECOVERY_STATE_DIR" && cp -a "$LOCK_DIR/state/." "$RECOVERY_STATE_DIR/"; then printf 'Pre-deploy config/unit backups kept at %s\n' "$RECOVERY_STATE_DIR" >&2 else printf 'Could not copy pre-deploy state to %s; check disk/permissions.\n' "$RECOVERY_STATE_DIR" >&2 fi fi printf 'Rollback incomplete: keeping %s for manual recovery. Do not start the old service blindly.\n' "$IN_PROGRESS_MARKER" >&2 exit "$status" fi # 恢复与重启都做完,才算这次部署收尾 clear_deploy_marker exit "$status" } trap rollback_deployment ERR INT TERM HUP # 从这里到健康检查通过是"服务可能停着、代码可能半拷贝"的窗口:先落标记。 # 即使服务原本是 stopped,也必须标记:SIGKILL/掉电后下一次启动要能发现半更新。 mark_deploy_in_progress if [[ "$WAS_ACTIVE" == true ]]; then systemctl --user stop "$SERVICE.service" fi if [[ "$ROOT" != "$INSTALL_DIR" ]]; then rsync -a --delete "${RSYNC_SOURCE[@]}" "$ROOT/" "$INSTALL_DIR/" fi cd "$INSTALL_DIR" NPM_BIN="$(dirname "$NODE_BIN")/npm" [[ -x "$NPM_BIN" ]] || NPM_BIN="$(command -v npm || true)" [[ -n "$NPM_BIN" && -x "$NPM_BIN" ]] || { printf 'npm is required\n' >&2; rollback_deployment; exit 1; } "$NPM_BIN" ci --omit=dev --ignore-scripts # Do not silently choose another port on a server. "$NODE_BIN" --input-type=module -e ' import net from "node:net"; const s=net.createServer(); s.on("error",e=>{console.error(e.message);process.exit(1)}); s.listen(Number(process.argv[1]), process.argv[2], ()=>s.close()); ' "$PORT" "$HOST" ARGS=(--data-dir "$DATA_DIR" --host "$HOST" --port "$PORT") [[ -z "$IMPORT_BRIDGE" ]] || ARGS+=(--import-bridge "$IMPORT_BRIDGE") [[ -z "$CREDENTIAL_FILE" ]] || ARGS+=(--credential-file "$CREDENTIAL_FILE") "$NODE_BIN" scripts/configure-linux.mjs "${ARGS[@]}" export QQ_INSTALL_DIR="$INSTALL_DIR" QQ_DATA_DIR="$DATA_DIR" QQ_NODE="$NODE_BIN" QQ_SERVICE="$SERVICE" QQ_HOST="$HOST" QQ_PORT="$PORT" "$NODE_BIN" scripts/install-service.mjs systemd-analyze --user verify "$HOME/.config/systemd/user/$SERVICE.service" systemd-analyze --user verify "$UPDATE_UNIT_FILE" systemd-analyze --user verify "$UPDATE_TIMER_FILE" systemctl --user daemon-reload systemctl --user enable --now "$SERVICE.service" systemctl --user enable --now "$UPDATE_SERVICE.timer" if [[ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null || true)" != yes ]]; then # Issue #15:unit 带 NoNewPrivileges(docs/BAOTA.md 推荐的加固)时,重启后由 # systemd 拉起的进程跑不了 sudo,这一步曾让整个更新回滚。linger 同时决定两件事: # 会话结束后用户管理器是否存活(没开的话,本登录会话一断,logind 就连同服务一起停掉) # 和开机是否自动拉起——后果比"不自启"大,但这一步仍不该一票否决本次部署:预检 + 尽力 # 而为,失败只警告(2026-10-12 修正文案:原说法只提了"下次开机不会自启")。 if grep -q 'NoNewPrivs:[[:space:]]*1' /proc/self/status 2>/dev/null; then printf '警告:服务被 NoNewPrivileges 加固,无法代为开启 linger;本次部署不受影响,但当前登录会话一断开,systemd 就会连同用户管理器一起停掉服务(机器人掉线),服务下次开机也不会自启。\n请在服务账号的交互终端执行一次:sudo loginctl enable-linger %s(执行后立即生效,之后退出终端服务不受影响)\n' "$USER" >&2 elif ! sudo -n loginctl enable-linger "$USER" 2>/dev/null; then printf '警告:启用 linger 失败(sudo 不可用或被拒绝);本次部署不受影响,但当前登录会话一断开,systemd 就会连同用户管理器一起停掉服务(机器人掉线),服务下次开机也不会自启。\n请在服务账号的交互终端执行一次:sudo loginctl enable-linger %s(执行后立即生效,之后退出终端服务不受影响)\n' "$USER" >&2 fi fi # 健康检查要拼 URL,IPv6 字面量必须加方括号(http://::1:3210 不是合法 URL,fetch 会直接 reject, # 于是 90 次探测全失败 → 回滚 → "这类机器每次更新都回滚")。'::1' 是配置层明确支持的监听地址, # 别的 IPv6 字面量同理;只有已带方括号的才原样用。 case "$HOST" in 0.0.0.0) HEALTH_HOST=127.0.0.1 ;; ::|\[::\]) HEALTH_HOST='[::1]' ;; \[*\]) HEALTH_HOST="$HOST" ;; *:*) HEALTH_HOST="[$HOST]" ;; *) HEALTH_HOST="$HOST" ;; esac HEALTHY=false # 90 秒窗口:慢机器冷启动(大库迁移、慢磁盘)可能超过旧版 50×0.2s≈10-20 秒, # 被误判失败会触发回滚,形成"每次更新都回滚"的怪圈;deploy-all 对同服务给的是 # 30-90 秒,这里对齐同一量级。 HEALTH_DEADLINE=$((SECONDS + 90)) for _ in {1..90}; do # 单次 fetch 必须有硬超时:服务接受连接但不响应时,undici 默认可能拖几百秒, # 90 次循环就不再是 90 秒窗口,更新 unit 会先被杀掉、回滚也跑不到。 if "$NODE_BIN" -e 'fetch(process.argv[1], { signal: AbortSignal.timeout(2000) }).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))' \ "http://$HEALTH_HOST:$PORT/healthz"; then HEALTHY=true break fi (( SECONDS < HEALTH_DEADLINE )) || break sleep 1 done [[ "$HEALTHY" == true ]] || { printf 'Service health check failed\n' >&2; rollback_deployment; exit 1; } trap - ERR INT TERM HUP clear_deploy_marker # 每份快照是整个安装目录(含 node_modules),而自动更新会无人值守地反复部署: # 不清理会把数据盘慢慢填满。保留最近的 3 份。 if [[ "${QQ_AGENT_SOURCE_REVISION:-}" =~ ^[0-9a-f]{40}$ ]]; then REVISION="$QQ_AGENT_SOURCE_REVISION" elif command -v git >/dev/null && git -C "$ROOT" rev-parse --verify HEAD >/dev/null 2>&1; then REVISION="$(git -C "$ROOT" rev-parse HEAD)" if ! git -C "$ROOT" diff --quiet --ignore-submodules HEAD --; then REVISION="${REVISION}-dirty" fi else REVISION="source-$(date -u +%Y%m%dT%H%M%SZ)" fi # 以下都是"部署已成功(健康检查通过)之后的收尾":任何一步失败都不该把退出码变成非 0 —— # 更新器按退出码判定成败,尾部失败会被记成"部署失败"并触发 disableOnFailure 自停 + 告警, # 而实际服务已经跑在新版本上(2026-10-07 复审 P3)。全部尽力而为。 if printf '%s\n' "$REVISION" > "$DATA_DIR/deployed-revision" 2>/dev/null; then chmod 600 "$DATA_DIR/deployed-revision" 2>/dev/null || true else printf 'Warning: could not record deployed revision in %s\n' "$DATA_DIR/deployed-revision" >&2 fi # 自动更新驱动(QQ_AGENT_SOURCE_REVISION 有值)且按控制台设置换了仓库/分支时,把安装记录 # 对齐到实际来源 —— 否则下次校验仍按旧记录判"不一致"(见 verify-deployment-target.mjs)。 if [[ -n "${QQ_AGENT_SOURCE_REVISION:-}" && -f "$INSTALL_DIR/.deployment.json" && ( -n "${QQ_AGENT_REPOSITORY:-}" || -n "${QQ_AGENT_BRANCH:-}" ) ]]; then "$NODE_BIN" -e ' const fs = require("fs"); const [file, repository, branch] = process.argv.slice(1); const meta = JSON.parse(fs.readFileSync(file, "utf8")); if (repository) meta.repository = repository; if (branch) meta.branch = branch; fs.writeFileSync(file, JSON.stringify(meta, null, 2)); ' "$INSTALL_DIR/.deployment.json" "${QQ_AGENT_REPOSITORY:-}" "${QQ_AGENT_BRANCH:-}" || printf 'Warning: could not sync .deployment.json source record\n' >&2 chmod 600 "$INSTALL_DIR/.deployment.json" 2>/dev/null || true fi systemctl --user --no-pager status "$SERVICE.service" || true MODE="$("$NODE_BIN" -e 'const c=require(process.argv[1]);process.stdout.write(c.runtime.mode)' "$DATA_DIR/config.json" 2>/dev/null)" || MODE="unknown" # ── docker 组自愈:控制台的「更新协议端」按钮靠服务进程直连 /var/run/docker.sock ────────── # 背景(Issue #30):服务进程的补充组在 user manager 启动那一刻冻结;而标准部署从不碰 # docker 组成员关系 —— 那个按钮从第一天起就会报 permission denied。收尾做两件事(幂等、尽力而为): # ① 把部署用户加进 docker 组(getent / usermod 是 systemd 家族各发行版 —— Ubuntu/Debian/ # CentOS/Fedora/Arch —— 的通用件;没有 docker 组的环境静默跳过); # ② user manager 若还带着旧组快照就重建它(重建后 linger 自动拉起服务,新进程带上新组)。 # ⚠️ 重建必须 stop → sleep 3 → start,**不能用 systemctl restart**:2026-10-09 实测(systemd 249) # 用 restart 时新管理器会因旧实例 cgroup 未回收而以 status=219/CGROUP 启动失败,且失败后 # 不会自动回来(服务停摆到人工 start);停、留回收间隔、再单独起,是实测可靠的姿势。 # 脚本自身不受影响:ssh 会话在独立的 session scope(实测 cgroup 为 session-*.scope), # 重建只动 user@.service 的 cgroup。 # ⚠️ 自动更新(QQ_AGENT_SOURCE_REVISION 有值)场景**跳过重建**:无人值守时不做停服动作, # 只加组(等下次交互部署或机器重启时自然生效)。 # ⚠️ 下面几处 sudo 一律带 `-n`(非交互):这些调用的 stdout/stderr 都被丢弃了,一旦 sudo 要 # 密码,提示会被吞掉、部署看起来像卡死(用户只能盲输或 Ctrl+C)。`-n` 让它立刻失败并走 # "手工执行"那句提示 —— 与运行时 `sudo -n docker` 是同一纪律(2026-10-09 复核)。 # ⚠️ 代价须知:把账号加进 docker 组等于给它 root 等价权限(docker 组可以挂载宿主文件系统)。 # 这条自愈是「控制台里更新协议端」这个功能的前提,但它也把"控制台被拿下"的后果从应用级 # 抬到了宿主 root —— 权衡见 docs/LINUX.md(2026-10-09 复核)。 if getent group docker >/dev/null 2>&1; then # 收尾段仍在 set -e 之下:每个可能失败的赋值都要自带保护,否则会把退出码弄坏 DOCKER_GID="$(getent group docker 2>/dev/null | cut -d: -f3 || true)" if [[ -n "$DOCKER_GID" ]]; then DOCKER_MEMBER=false if id -nG "$(id -un)" | tr ' ' '\n' | grep -qx docker; then DOCKER_MEMBER=true elif [[ "${QQ_AGENT_ALLOW_DOCKER_GROUP:-}" == "1" ]]; then # 显式 opt-in:docker 组等于 root 等价权限,不能默认替用户加。 if sudo -n usermod -aG docker "$(id -un)" >/dev/null 2>&1; then DOCKER_MEMBER=true printf '已把 %s 加入 docker 组(控制台「更新协议端」需要;可用 QQ_AGENT_ALLOW_DOCKER_GROUP=0 关闭)\n' "$(id -un)" else printf '警告:把自己加入 docker 组失败;控制台「更新协议端」会报权限不足(可手工执行:sudo usermod -aG docker %s)\n' "$(id -un)" >&2 fi else printf '跳过 docker 组自动加入(需要显式设置 QQ_AGENT_ALLOW_DOCKER_GROUP=1;控制台「更新协议端」可能报权限不足)\n' fi if [[ "$DOCKER_MEMBER" == true && -z "${QQ_AGENT_SOURCE_REVISION:-}" ]]; then MANAGER_UNIT="user@$(id -u).service" SVC_PID="$(systemctl --user show "$SERVICE.service" -p MainPID --value 2>/dev/null || true)" SVC_GROUPS="" if [[ "$SVC_PID" =~ ^[0-9]+$ && -r "/proc/$SVC_PID/status" ]]; then SVC_GROUPS=" $(grep '^Groups:' "/proc/$SVC_PID/status" 2>/dev/null | cut -d: -f2 | tr '\t' ' ' || true) " fi if [[ -n "$SVC_GROUPS" && "$SVC_GROUPS" != *" $DOCKER_GID "* ]]; then printf '重建 user manager 让 docker 组生效(服务会停约 10 秒后自动恢复)……\n' if sudo -n systemctl stop "$MANAGER_UNIT" 2>/dev/null; then sleep 3 if ! sudo -n systemctl start "$MANAGER_UNIT" 2>/dev/null; then # ⚠️ 重试前必须再等一次:这次失败通常正是 219/CGROUP(旧实例 cgroup 还没回收完), # 紧接着重试会撞同一个竞态、白试一次。多留 3 秒,重试才有意义(2026-10-09 复核)。 sleep 3 sudo -n systemctl start "$MANAGER_UNIT" 2>/dev/null \ || printf '警告:user manager 没能启动;请执行:sudo systemctl start %s\n' "$MANAGER_UNIT" >&2 fi for _ in {1..10}; do systemctl --user is-active --quiet "$SERVICE.service" 2>/dev/null && break sleep 1 done if systemctl --user is-active --quiet "$SERVICE.service" 2>/dev/null; then printf '服务已随新管理器恢复(docker 组已生效,控制台可用)\n' else printf '服务未能随新管理器恢复:%s。部署按失败处理,请检查:sudo systemctl start %s\n' \ "$SERVICE.service" "$MANAGER_UNIT" >&2 exit 1 fi else printf '警告:停 user manager 失败;请手工执行:sudo systemctl stop %s && sleep 3 && sudo systemctl start %s\n' "$MANAGER_UNIT" "$MANAGER_UNIT" >&2 fi fi fi fi fi printf '\nConsole: http://%s:%s (%s mode)\nToken: %s/manage.sh token\n' "$HEALTH_HOST" "$PORT" "$MODE" "$INSTALL_DIR" [[ -z "$ROLLBACK_DIR" ]] || printf 'Rollback snapshot: %s\n' "$ROLLBACK_DIR"