deploy.sh
Rama main · réplica de lectura del repositorio original.
#!/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"