#!/usr/bin/env bash set -euo pipefail umask 077 SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="${QQ_AGENT_STACK_DIR:-/mnt/data/qq-agent}" AGENT_PORT="3210" SNOWLUMA_PORT="5099" NOVNC_PORT="6081" ONEBOT_HTTP_PORT="3000" ONEBOT_WS_PORT="3001" # 控制台 / SnowLuma WebUI / noVNC 的宿主监听地址。 # 默认只绑回环:远程访问走 SSH 隧道;只有显式 --publish 才对外开放(安全组仍需自行收紧)。 PUBLISH_HOST="127.0.0.1" PUBLISH_EXPLICIT=false AGENT_PORT_SET=false SNOWLUMA_PORT_SET=false NOVNC_PORT_SET=false ONEBOT_HTTP_PORT_SET=false ONEBOT_WS_PORT_SET=false SERVICE="qq-agent-linux" SERVICE_SET=false IMAGE="${SNOWLUMA_IMAGE:-motricseven7/snowluma:v1.14.22}" IMAGE_SET=false IMAGE_MIRROR_ARG="" # 这两个是用户会按机器情况调的旋钮(显存紧张时改 SNOWLUMA_SCREEN、排查时改日志级别), # 重跑时从 .env 回读,避免手工改动被默认值覆盖。 SNOWLUMA_SCREEN="1920x1080x24" SNOWLUMA_LOG_LEVEL="info" ASSUME_YES=false CHECK_ONLY=false ROTATE_CREDENTIALS=false INSTALL_DOCKER=true AGENT_TOKEN="" ONEBOT_TOKEN="" SNOWLUMA_PASSWORD="" VNC_PASSWORD="" SNOWLUMA_CURRENT_PASSWORD="" SNOWLUMA_TOTP="" MODEL_BASE_URL="${QQ_AGENT_MODEL_BASE_URL:-}" MODEL_API_KEY="${QQ_AGENT_MODEL_API_KEY:-}" MODEL_NAME="${QQ_AGENT_MODEL:-}" ALLOW_GROUPS="${QQ_AGENT_ALLOW_GROUPS:-}" ALLOW_PRIVATE="${QQ_AGENT_ALLOW_PRIVATE:-}" SKIP_MODEL_CONFIG=false # 网页终端(宝塔等面板的终端)、su、sudo -u 这类非登录会话不设置 XDG_RUNTIME_DIR: # systemctl --user 会直接报 "Failed to connect to bus ... $XDG_RUNTIME_DIR not defined", # 即使用户管理器好好地在跑(2026-10-12 报道复现:同一台机器只差这一个环境变量)。 # 目录存在就按 systemd 的口径补上;用户自己设过的值不动。 if [[ -z "${XDG_RUNTIME_DIR:-}" && -d "/run/user/$(id -u)" ]]; then export XDG_RUNTIME_DIR="/run/user/$(id -u)" fi usage() { cat <<'EOF' Usage: bash deploy-all.sh [options] Interactive full-stack installer for QQ Agent + SnowLuma/OneBot. Options: --root-dir PATH Stack root (default: /mnt/data/qq-agent) --agent-port PORT QQ Agent console port (default: 3210) --snowluma-port PORT SnowLuma WebUI port (default: 5099) --novnc-port PORT QQ login/noVNC port (default: 6081) --onebot-http-port PORT Local-only OneBot HTTP port (default: 3000) --onebot-ws-port PORT Local-only OneBot WebSocket port (default: 3001) --service NAME systemd user service (default: qq-agent-linux) --image IMAGE SnowLuma image (default: tested v1.14.22) --image-mirror HOST Try this registry mirror (yours first) when Docker Hub is unreachable (mainland China), e.g. --image-mirror docker.m.daocloud.io (also: QQ_AGENT_IMAGE_MIRROR, comma-separated); built-in public mirrors are tried after yours --agent-token TOKEN Set the QQ Agent console token --onebot-token TOKEN Set the shared OneBot HTTP/WS token --snowluma-password VALUE Set the initial SnowLuma WebUI password --snowluma-current-password VALUE Current WebUI password when rotating it --snowluma-totp CODE Current SnowLuma 2FA code when enabled --vnc-password VALUE Set the noVNC password (8 characters recommended) --model-base-url URL OpenAI-compatible Chat Completions base URL --model-api-key KEY Model provider API key --model NAME Model identifier --allow-groups IDS Comma-separated QQ group allowlist --allow-private IDS Comma-separated QQ private-chat allowlist --skip-model-config Leave model settings for the management console --rotate-credentials Replace stored credentials during an update --no-install-docker Fail instead of installing Docker when missing --check-only Inspect ownership and ports without changing anything --publish Bind console / SnowLuma WebUI / noVNC to 0.0.0.0 (default is 127.0.0.1; requires a tightened security group) --loopback Explicitly bind them to 127.0.0.1 (default for new installs) -y, --yes Accept defaults; suitable for non-interactive use -h, --help Show this help The installer never asks for a LAN IP. By default the console, SnowLuma WebUI and noVNC bind to 127.0.0.1 only; use an SSH tunnel (console-tunnel.bat or `ops console`) or pass --publish to expose them. OneBot ports always bind to 127.0.0.1. Existing installations keep their current bind address unless --publish/--loopback is passed explicitly. Existing installations not owned by this installer are never adopted. Use deploy.sh with the existing data directory and endpoints to update Agent only. EOF } require_value() { (($# >= 2)) || { printf 'Missing value for %s\n' "$1" >&2; exit 2; } } while (($#)); do case "$1" in --root-dir) require_value "$@"; ROOT_DIR="$2"; shift 2 ;; --root-dir=*) ROOT_DIR="${1#*=}"; shift ;; --agent-port) require_value "$@"; AGENT_PORT="$2"; AGENT_PORT_SET=true; shift 2 ;; --snowluma-port) require_value "$@"; SNOWLUMA_PORT="$2"; SNOWLUMA_PORT_SET=true; shift 2 ;; --novnc-port) require_value "$@"; NOVNC_PORT="$2"; NOVNC_PORT_SET=true; shift 2 ;; --onebot-http-port) require_value "$@"; ONEBOT_HTTP_PORT="$2"; ONEBOT_HTTP_PORT_SET=true; shift 2 ;; --onebot-ws-port) require_value "$@"; ONEBOT_WS_PORT="$2"; ONEBOT_WS_PORT_SET=true; shift 2 ;; --service) require_value "$@"; SERVICE="$2"; SERVICE_SET=true; shift 2 ;; --image) require_value "$@"; IMAGE="$2"; IMAGE_SET=true; shift 2 ;; --image-mirror) require_value "$@"; IMAGE_MIRROR_ARG="$2"; shift 2 ;; --agent-token) require_value "$@"; AGENT_TOKEN="$2"; shift 2 ;; --onebot-token) require_value "$@"; ONEBOT_TOKEN="$2"; shift 2 ;; --snowluma-password) require_value "$@"; SNOWLUMA_PASSWORD="$2"; shift 2 ;; --snowluma-current-password) require_value "$@"; SNOWLUMA_CURRENT_PASSWORD="$2"; shift 2 ;; --snowluma-totp) require_value "$@"; SNOWLUMA_TOTP="$2"; shift 2 ;; --vnc-password) require_value "$@"; VNC_PASSWORD="$2"; shift 2 ;; --model-base-url) require_value "$@"; MODEL_BASE_URL="$2"; shift 2 ;; --model-api-key) require_value "$@"; MODEL_API_KEY="$2"; shift 2 ;; --model) require_value "$@"; MODEL_NAME="$2"; shift 2 ;; --allow-groups) require_value "$@"; ALLOW_GROUPS="$2"; shift 2 ;; --allow-private) require_value "$@"; ALLOW_PRIVATE="$2"; shift 2 ;; --skip-model-config) SKIP_MODEL_CONFIG=true; shift ;; --rotate-credentials) ROTATE_CREDENTIALS=true; shift ;; --no-install-docker) INSTALL_DOCKER=false; shift ;; --check-only) CHECK_ONLY=true; ASSUME_YES=true; shift ;; --publish) PUBLISH_HOST="0.0.0.0"; PUBLISH_EXPLICIT=true; shift ;; --loopback) PUBLISH_HOST="127.0.0.1"; PUBLISH_EXPLICIT=true; shift ;; -y|--yes) ASSUME_YES=true; shift ;; -h|--help) usage; exit 0 ;; *) printf 'Unknown option: %s\n\n' "$1" >&2; usage >&2; exit 2 ;; esac done # 凭据的环境变量回退:命令行参数会出现在 /proc//cmdline(对本机所有用户 # 可读),无人值守/CI 场景请优先用环境变量传凭据。命令行显式传值时优先于环境变量。 AGENT_TOKEN="${AGENT_TOKEN:-${QQ_AGENT_AGENT_TOKEN:-}}" ONEBOT_TOKEN="${ONEBOT_TOKEN:-${QQ_AGENT_ONEBOT_TOKEN:-}}" SNOWLUMA_PASSWORD="${SNOWLUMA_PASSWORD:-${QQ_AGENT_SNOWLUMA_PASSWORD:-}}" SNOWLUMA_TOTP="${SNOWLUMA_TOTP:-${QQ_AGENT_SNOWLUMA_TOTP:-}}" # ops deploy 用 0600 临时文件传模型 Key,不走子进程环境;这里要认这个入口。 if [[ -z "$MODEL_API_KEY" && -n "${QQ_AGENT_MODEL_KEY_FILE:-}" ]]; then [[ -r "$QQ_AGENT_MODEL_KEY_FILE" ]] || { printf 'Error: QQ_AGENT_MODEL_KEY_FILE 不存在或不可读:%s\n' "$QQ_AGENT_MODEL_KEY_FILE" >&2 exit 1 } MODEL_API_KEY="$(tr -d '\r\n' < "$QQ_AGENT_MODEL_KEY_FILE")" fi VNC_PASSWORD="${VNC_PASSWORD:-${QQ_AGENT_VNC_PASSWORD:-}}" die() { printf 'Error: %s\n' "$*" >&2 exit 1 } # 全新安装中途失败时清掉本次写下的 .env / compose 与空目录:否则重跑会被 # check_local_ownership 判成"不完整的受管安装"而拒绝 —— 而拉镜像失败的提示恰恰让操作者重跑。 # 容器可能已经写过东西,所以目录只用 rmdir:非空会失败,正好保留现场。 FRESH_STACK_CLEANUP=false STACK_LOCK_DIR="" STACK_LOCK_OWNED=false cleanup_fresh_stack() { [[ "$FRESH_STACK_CLEANUP" == true ]] || return 0 printf '安装未完成:已清理本次写入的栈文件与空目录,可以直接重跑 deploy-all.sh。\n' >&2 rm -f "$ENV_FILE" "$ENV_FILE.tmp" "$COMPOSE_FILE" "$COMPOSE_FILE.tmp" rmdir "$SNOWLUMA_DATA_DIR/config" "$SNOWLUMA_DIR/client-config" "$SNOWLUMA_DIR/client-data" \ "$SNOWLUMA_DATA_DIR" "$SNOWLUMA_DIR" "$AGENT_DATA_DIR" 2>/dev/null || true } # 2026-10-06 复审 P2/P3:统一的退出钩子(bash 只有一个 EXIT trap,原先分散的 # `trap cleanup_fresh_stack EXIT` 与 `trap 'rm …' EXIT` 互相覆盖,谁后挂谁生效)。 # - fresh:清掉半成品栈文件与空目录。 # - existing + 轮换凭据:.env 在部署成功前就已重写成新凭据,而 config.json 还是旧令牌 # (deploy.sh 的回滚只管 config.json)—— 此后 check-stack-update 比对必然 refuse_existing, # 脚本自提示的"重跑带 --rotate-credentials"也无效(预检在写凭据之前跑)。失败退出回拷 # 部署前的 .env,让重跑路径保持可用。 # - 模型 Key 临时文件兜底删除(原先只在设置了 MODEL_API_KEY 时才挂 trap,且会顶掉清理钩子)。 deploy_all_exit() { local status=$? [[ -n "${MODEL_KEY_FILE:-}" ]] && rm -f "$MODEL_KEY_FILE" if [[ "${ENV_REWRITTEN:-false}" == true && "${DEPLOY_COMPLETED:-false}" != true && "${KEEP_ENV_ON_ROTATE_FAILURE:-false}" != true && -f "$ENV_FILE.pre-deploy" ]]; then cp -p "$ENV_FILE.pre-deploy" "$ENV_FILE" printf '部署未完成:.env 已回滚为部署前内容(备份保留在 %s.pre-deploy),可直接重跑 deploy-all.sh。\n' "$ENV_FILE" >&2 fi # 清理必须在仍持锁时做:先放锁再删 .env,新的赢家可能已经写好 .env 又被这里删掉。 cleanup_fresh_stack if [[ "${STACK_LOCK_OWNED:-false}" == true && -n "${STACK_LOCK_DIR:-}" ]]; then rm -rf -- "$STACK_LOCK_DIR" STACK_LOCK_OWNED=false fi exit "$status" } # 栈级互斥:两个 deploy-all 同时跑时,.env/compose 会在 deploy.sh 的锁之前互相覆盖。 # 锁放在部署根目录,覆盖从 .env 写入到 deploy.sh 收尾的全程。 acquire_stack_lock() { local attempt=0 pid="" stale="" takeover="$STACK_LOCK_DIR.takeover" while (( attempt < 3 )); do if mkdir -- "$STACK_LOCK_DIR" 2>/dev/null; then STACK_LOCK_OWNED=true printf '%s\n' "$$" >"$STACK_LOCK_DIR/pid" 2>/dev/null || true date -Is >"$STACK_LOCK_DIR/started" 2>/dev/null || true return 0 fi pid="$(cat "$STACK_LOCK_DIR/pid" 2>/dev/null | tr -d '\r\n' || true)" if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then die "另一个 deploy-all.sh 正在运行(pid $pid,锁 $STACK_LOCK_DIR)" fi stale="$(find "$STACK_LOCK_DIR" -maxdepth 0 -mmin +5 2>/dev/null || true)" if [[ -n "$stale" || -n "$pid" ]]; then # 陈旧锁的接管要互斥:两个实例都判定"陈旧"时,不能都 rm -rf 再 mkdir—— # 后者的 rm 会把前者刚建好的新锁删掉,两个进程同时往下走。 if ! mkdir -- "$takeover" 2>/dev/null; then if [[ -n "$(find "$takeover" -maxdepth 0 -mmin +5 2>/dev/null || true)" ]]; then rm -rf -- "$takeover" mkdir -- "$takeover" 2>/dev/null \ || die "另一个 deploy-all.sh 正在接管陈旧锁,请稍后重试" else die "另一个 deploy-all.sh 正在接管陈旧锁,请稍后重试" fi fi # 二次判定:拿到接管权后锁可能已被别人重建 pid="$(cat "$STACK_LOCK_DIR/pid" 2>/dev/null | tr -d '\r\n' || true)" if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then rmdir -- "$takeover" 2>/dev/null || true die "另一个 deploy-all.sh 正在运行(pid $pid,锁 $STACK_LOCK_DIR)" fi # 原子移走再删:只有真正搬走旧锁的实例才能继续,搬不动的说明已被别人接管。 if mv -- "$STACK_LOCK_DIR" "$takeover/stale" 2>/dev/null; then rm -rf -- "$takeover/stale" fi rmdir -- "$takeover" 2>/dev/null || true attempt=$((attempt + 1)) continue fi die "另一个 deploy-all.sh 正在运行(锁 $STACK_LOCK_DIR)" done die "无法获取部署锁:$STACK_LOCK_DIR" } step() { printf '\n==> %s\n' "$*" } prompt_value() { local label="$1" default="$2" answer="" if [[ "$ASSUME_YES" == true ]]; then printf '%s' "$default" return fi [[ -r /dev/tty ]] || die 'Interactive input is unavailable; use --yes and explicit options' printf '%s [%s]: ' "$label" "$default" >/dev/tty IFS= read -r answer /dev/tty IFS= read -r answer /dev/tty IFS= read -r -s answer /dev/tty printf '%s' "$answer" } random_hex() { od -An -N"$1" -tx1 /dev/urandom | tr -d ' \n' } env_value() { local file="$1" key="$2" [[ -f "$file" ]] || return 0 sed -n "s/^${key}=//p" "$file" | tail -n 1 | tr -d '\r' } validate_port() { [[ "$2" =~ ^[0-9]+$ ]] && ((10#$2 >= 1 && 10#$2 <= 65535)) \ || die "$1 must be an integer from 1 to 65535" } validate_secret() { local label="$1" value="$2" minimum="$3" ((${#value} >= minimum)) || die "$label must contain at least $minimum characters" [[ "$value" =~ ^[A-Za-z0-9._!@%+=:-]+$ ]] \ || die "$label may only contain letters, numbers, and ._!@%+=:-" } run_root() { if ((EUID == 0)); then "$@" elif command -v sudo >/dev/null 2>&1; then sudo "$@" else die "Root permission is required for: $*" fi } docker_ready() { command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1 } sudo_docker_ready() { ((EUID != 0)) && command -v sudo >/dev/null 2>&1 \ && command -v docker >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1 } install_docker() { [[ "$INSTALL_DOCKER" == true ]] || die 'Docker is unavailable and automatic installation is disabled' confirm 'Docker is missing. Install Docker and Compose now?' true \ || die 'Docker is required by SnowLuma' step 'Installing Docker Engine' if command -v apt-get >/dev/null 2>&1; then run_root apt-get update run_root apt-get install -y docker.io || die 'Docker Engine installation failed' elif command -v dnf >/dev/null 2>&1; then run_root dnf install -y docker || run_root dnf install -y moby-engine \ || die 'Docker Engine installation failed' elif command -v yum >/dev/null 2>&1; then run_root yum install -y docker || run_root yum install -y moby-engine \ || die 'Docker Engine installation failed' elif command -v zypper >/dev/null 2>&1; then run_root zypper --non-interactive install -y docker || die 'Docker Engine installation failed' elif command -v pacman >/dev/null 2>&1; then run_root pacman -Sy --noconfirm docker || die 'Docker Engine installation failed' else die 'No supported package manager found; install Docker Engine and Compose manually' fi run_root systemctl enable --now docker install_compose } # Compose 单独装/单独检查:Debian 12 官方仓库没有 docker-compose-v2, # 引擎已存在但 Compose 缺失时也不能因为“docker_ready”跳过。 install_compose() { [[ "$INSTALL_DOCKER" == true ]] \ || die 'Docker Compose v2 is missing and automatic installation is disabled' step 'Installing Docker Compose v2' if command -v apt-get >/dev/null 2>&1; then run_root apt-get install -y docker-compose-v2 \ || run_root apt-get install -y docker-compose-plugin \ || true elif command -v dnf >/dev/null 2>&1; then # Fedora 的 Compose 包名是 docker-compose;Rocky/Alma 9 stock 仓库通常两者都没有。 run_root dnf install -y docker-compose-plugin \ || run_root dnf install -y docker-compose \ || true elif command -v yum >/dev/null 2>&1; then run_root yum install -y docker-compose-plugin \ || run_root yum install -y docker-compose \ || true elif command -v zypper >/dev/null 2>&1; then run_root zypper --non-interactive install -y docker-compose || true elif command -v pacman >/dev/null 2>&1; then run_root pacman -Sy --noconfirm docker-compose || true fi docker_call compose version >/dev/null 2>&1 \ || die 'Docker Engine is installed but Docker Compose v2 is unavailable. Debian 12 / Rocky / Alma 9 stock repos do not provide it; configure the Docker official repository (docs/DOCKER.md) or install docker-compose-plugin manually. Fedora uses the docker-compose package.' } docker_call() { if docker_ready; then docker "$@" elif sudo_docker_ready; then sudo docker "$@" else return 1 fi } wait_http() { local url="$1" attempts="${2:-60}" for ((i = 0; i < attempts; i++)); do if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then return 0; fi sleep 1 done return 1 } onebot_ready() { # Token 经 stdin 喂给 curl(-H @-),不出现在 /proc//cmdline—— # 对本机所有用户可读(凭据退出命令行参数原则)。 printf 'authorization: Bearer %s\n' "$ONEBOT_TOKEN" | \ curl -fsS --max-time 3 -H @- \ -H 'content-type: application/json' \ -d '{}' "http://127.0.0.1:$ONEBOT_HTTP_PORT/get_login_info" \ | grep -Eq '"retcode"[[:space:]]*:[[:space:]]*0' } refuse_existing() { printf 'Existing or incomplete installation detected: %s\n' "$1" >&2 die 'No deployment changes made. Use deploy.sh for the existing Agent; preserve its data directory, bind address and OneBot settings. Automatic takeover is not supported.' } check_local_ownership() { local file directory contents if [[ -e "$ENV_FILE" ]]; then for file in "$ENV_FILE" "$COMPOSE_FILE" "$APP_DIR/.deployment.json" \ "$APP_DIR/.deployment-node" "$AGENT_DATA_DIR/config.json"; do [[ -f "$file" && -r "$file" && ! -L "$file" ]] \ || refuse_existing "incomplete managed stack ($file)" done EXISTING_STACK=true IFS= read -r PREFLIGHT_NODE <"$APP_DIR/.deployment-node" || true [[ -n "$PREFLIGHT_NODE" && -x "$PREFLIGHT_NODE" ]] \ || refuse_existing 'the recorded Node runtime is unavailable' else for file in "$APP_DIR/.deployment.json" "$APP_DIR/.deployment-node" \ "$APP_DIR/config.json" "$ACCESS_FILE"; do [[ ! -e "$file" && ! -L "$file" ]] \ || refuse_existing "$file exists without managed stack metadata" done for directory in "$AGENT_DATA_DIR" "$APP_DIR/data" "$SNOWLUMA_DIR"; do [[ ! -L "$directory" ]] || refuse_existing "unmanaged data symlink $directory" [[ -e "$directory" ]] || continue [[ -d "$directory" && -r "$directory" && -x "$directory" ]] \ || refuse_existing "cannot inspect $directory" contents="$(find "$directory" -mindepth 1 -maxdepth 1 -print -quit)" \ || refuse_existing "cannot inspect $directory" [[ -z "$contents" ]] || refuse_existing "$directory contains data without managed stack metadata" done fi } check_host_ownership() { local load_state working_dir inventory id name image own_id="" docker_ports="" local listeners port agent_running=false old_agent_port="" local docker_command=() command -v systemctl >/dev/null || die 'systemctl is required' systemctl --user show-environment >/dev/null \ || die "Cannot reach the systemd user manager from this shell; no deployment changes made. A web terminal (panel console) or other non-login shell does not set XDG_RUNTIME_DIR even when the manager is running: run 'export XDG_RUNTIME_DIR=/run/user/\$(id -u)' and retry; if that directory does not exist, run 'sudo loginctl enable-linger $(id -un)' first, then retry." if ! load_state="$(systemctl --user show "$SERVICE.service" --property=LoadState --value)"; then [[ "$load_state" == not-found ]] \ || die 'Cannot inspect the selected systemd service; no deployment changes made' fi case "$load_state" in not-found) ;; loaded) [[ "$EXISTING_STACK" == true ]] || refuse_existing "$SERVICE.service already exists" working_dir="$(systemctl --user show "$SERVICE.service" --property=WorkingDirectory --value)" \ || refuse_existing "cannot inspect $SERVICE.service" [[ -n "$working_dir" && "$(realpath -m -- "$working_dir")" == "$APP_DIR" ]] \ || refuse_existing "$SERVICE.service belongs to another application directory" if systemctl --user is-active --quiet "$SERVICE.service"; then agent_running=true; fi ;; *) refuse_existing "cannot establish ownership of $SERVICE.service" ;; esac if [[ "$EXISTING_STACK" == true ]]; then "$PREFLIGHT_NODE" "$SOURCE_DIR/scripts/check-stack-update.mjs" \ --root-dir "$ROOT_DIR" --service "$SERVICE" \ || refuse_existing 'managed Agent configuration does not match the saved stack' old_agent_port="$(env_value "$ENV_FILE" AGENT_PORT)" fi if command -v docker >/dev/null 2>&1; then if docker_ready; then docker_command=(docker) elif command -v sudo >/dev/null 2>&1; then if sudo -n docker info >/dev/null 2>&1; then docker_command=(sudo -n docker) elif [[ "$CHECK_ONLY" != true && "$ASSUME_YES" != true ]] \ && sudo docker info >/dev/null; then docker_command=(sudo docker) fi fi ((${#docker_command[@]} > 0)) \ || refuse_existing 'Docker exists but its containers cannot be inspected (check daemon access or sudo)' inventory="$("${docker_command[@]}" ps -a --format '{{.ID}}|{{.Names}}|{{.Image}}')" \ || refuse_existing 'Docker container inventory is unavailable' while IFS='|' read -r id name image; do [[ -n "$id" ]] || continue image="$(printf '%s' "$image" | tr '[:upper:]' '[:lower:]')" case "$name:$image" in qq-agent-snowluma:*|*[Ss][Nn][Oo][Ww][Ll][Uu][Mm][Aa]*|*[Nn][Aa][Pp][Cc][Aa][Tt]*|*[Ll][Aa][Gg][Rr][Aa][Nn][Gg][Ee]*) [[ "$EXISTING_STACK" == true && "$name" == qq-agent-snowluma ]] \ || refuse_existing "external QQ gateway container $name" own_id="$id" "${docker_command[@]}" inspect "$id" \ | "$PREFLIGHT_NODE" "$SOURCE_DIR/scripts/check-stack-update.mjs" \ --root-dir "$ROOT_DIR" --service "$SERVICE" --input container \ || refuse_existing "container $name does not belong to this stack" ;; esac done <<<"$inventory" if [[ "$EXISTING_STACK" == true ]]; then "${docker_command[@]}" compose --project-directory "$SNOWLUMA_DIR" \ --env-file "$ENV_FILE" -f "$COMPOSE_FILE" config --format json \ | "$PREFLIGHT_NODE" "$SOURCE_DIR/scripts/check-stack-update.mjs" \ --root-dir "$ROOT_DIR" --service "$SERVICE" --input compose \ || refuse_existing 'Compose configuration does not match this stack' fi if [[ -n "$own_id" ]]; then docker_ports="$("${docker_command[@]}" inspect --format \ '{{if .State.Running}}{{range .NetworkSettings.Ports}}{{range .}}{{.HostPort}}{{"\n"}}{{end}}{{end}}{{end}}' "$own_id")" \ || refuse_existing 'cannot inspect managed container ports' fi elif [[ "$EXISTING_STACK" == true ]]; then refuse_existing 'Docker is missing for the existing managed stack' fi command -v ss >/dev/null || die 'ss (iproute2) is required for port checks' listeners="$(ss -H -ltn)" || die 'Cannot inspect listening ports; no deployment changes made' listeners="$(printf '%s\n' "$listeners" | awk '{n=split($4,a,":"); print a[n]}')" for port in "${ports[@]}"; do if printf '%s\n' "$listeners" | grep -Fxq "$port"; then if [[ "$port" == "$AGENT_PORT" && "$port" == "$old_agent_port" && "$agent_running" == true ]]; then continue fi if [[ "$port" != "$AGENT_PORT" ]] && printf '%s\n' "$docker_ports" | grep -Fxq "$port"; then continue fi refuse_existing "selected port $port is already in use outside the managed services" fi done } [[ "$(uname -s)" == Linux ]] || die 'Full-stack deployment is supported on Linux only' ((EUID != 0)) || die 'Run as the service user, not root; sudo is used only for host dependencies' if [[ "${QQ_AGENT_PLATFORM_CHECKED:-}" != "1" ]]; then bash "$SOURCE_DIR/scripts/check-platform.sh" --strict \ || die '当前系统不受支持(原因见上)。推荐 Ubuntu 22.04/24.04、Debian 12、Rocky/Alma 9;openSUSE 等 dnf/zypper 系属于预期可用、未实测。' export QQ_AGENT_PLATFORM_CHECKED=1 fi command -v curl >/dev/null 2>&1 || die 'curl is required' [[ "$SERVICE" =~ ^[A-Za-z0-9_-]+$ ]] || die 'Invalid service name' [[ "$IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || die 'Invalid SnowLuma image reference' if [[ "$ASSUME_YES" != true ]]; then ROOT_DIR="$(prompt_value 'Deployment root' "$ROOT_DIR")" fi [[ "$ROOT_DIR" = /* ]] || die '--root-dir must be an absolute path' [[ "$ROOT_DIR" != *[[:space:]%\"]* ]] || die 'Deployment root contains unsupported characters' command -v realpath >/dev/null || die 'realpath is required' ROOT_DIR="$(realpath -m -- "$ROOT_DIR")" [[ "$ROOT_DIR" != / ]] || die 'The filesystem root cannot be used as the deployment root' APP_DIR="$ROOT_DIR/app" AGENT_DATA_DIR="$ROOT_DIR/data" SNOWLUMA_DIR="$ROOT_DIR/snowluma" SNOWLUMA_DATA_DIR="$SNOWLUMA_DIR/data" ENV_FILE="$SNOWLUMA_DIR/.env" COMPOSE_FILE="$SNOWLUMA_DIR/docker-compose.yml" ACCESS_FILE="$ROOT_DIR/deployment-access.txt" EXISTING_STACK=false PREFLIGHT_NODE="" # 栈级锁必须在“归属判定/凭据读回”之前拿到:两个实例交错时,后启动的那个不能先按 # 全新安装预检、等前一个结束后再拿锁继续(会把受管栈的凭据轮换掉,2026-10 审查 P2)。 # 锁放在临时目录而不是部署根:预检拒绝时不能留下任何部署目录(fresh preflight 的契约)。 STACK_LOCK_DIR="${TMPDIR:-/tmp}/qq-agent-deploy-$(id -u)-$(printf '%s' "$ROOT_DIR" | sha256sum | cut -c1-12).lock" trap deploy_all_exit EXIT acquire_stack_lock check_local_ownership # 存量安装:没有显式 --publish/--loopback 时沿用现有绑定,避免一次更新把远程访问改没。 # 先读 .env 的 PUBLISH_HOST;老版本没有这个键,就从 compose 的 0.0.0.0 绑定反推。 if [[ "$EXISTING_STACK" == true && "$PUBLISH_EXPLICIT" != true ]]; then if [[ -f "$ENV_FILE" ]] && grep -q '^PUBLISH_HOST=' "$ENV_FILE"; then PUBLISH_HOST="$(sed -n 's/^PUBLISH_HOST=//p' "$ENV_FILE" | head -n 1)" elif [[ -f "$COMPOSE_FILE" ]] && grep -q '"0\.0\.0\.0:' "$COMPOSE_FILE"; then PUBLISH_HOST="0.0.0.0" fi [[ "$PUBLISH_HOST" == "0.0.0.0" ]] || PUBLISH_HOST="127.0.0.1" fi if [[ "$EXISTING_STACK" == true ]]; then if [[ "$SERVICE_SET" != true ]]; then SERVICE="$(env_value "$ENV_FILE" QQ_AGENT_SERVICE)"; SERVICE="${SERVICE:-qq-agent-linux}" fi if [[ "$IMAGE_SET" != true ]]; then IMAGE="$(env_value "$ENV_FILE" SNOWLUMA_IMAGE)"; IMAGE="${IMAGE:-motricseven7/snowluma:v1.14.22}" fi if [[ "$AGENT_PORT_SET" != true ]]; then AGENT_PORT="$(env_value "$ENV_FILE" AGENT_PORT)"; AGENT_PORT="${AGENT_PORT:-3210}" fi if [[ "$SNOWLUMA_PORT_SET" != true ]]; then SNOWLUMA_PORT="$(env_value "$ENV_FILE" SNOWLUMA_WEBUI_HOST_PORT)"; SNOWLUMA_PORT="${SNOWLUMA_PORT:-5099}" fi if [[ "$NOVNC_PORT_SET" != true ]]; then NOVNC_PORT="$(env_value "$ENV_FILE" NOVNC_PORT)"; NOVNC_PORT="${NOVNC_PORT:-6081}" fi if [[ "$ONEBOT_HTTP_PORT_SET" != true ]]; then ONEBOT_HTTP_PORT="$(env_value "$ENV_FILE" ONEBOT_HTTP_PORT)"; ONEBOT_HTTP_PORT="${ONEBOT_HTTP_PORT:-3000}" fi if [[ "$ONEBOT_WS_PORT_SET" != true ]]; then ONEBOT_WS_PORT="$(env_value "$ENV_FILE" ONEBOT_WS_PORT)"; ONEBOT_WS_PORT="${ONEBOT_WS_PORT:-3001}" fi SNOWLUMA_SCREEN="$(env_value "$ENV_FILE" SNOWLUMA_SCREEN)"; SNOWLUMA_SCREEN="${SNOWLUMA_SCREEN:-1920x1080x24}" SNOWLUMA_LOG_LEVEL="$(env_value "$ENV_FILE" SNOWLUMA_LOG_LEVEL)"; SNOWLUMA_LOG_LEVEL="${SNOWLUMA_LOG_LEVEL:-info}" fi [[ "$SERVICE" =~ ^[A-Za-z0-9_-]+$ ]] || die 'Invalid service name' [[ "$IMAGE" =~ ^[A-Za-z0-9._/:@-]+$ ]] || die 'Invalid SnowLuma image reference' [[ "$SNOWLUMA_SCREEN" =~ ^[0-9]+x[0-9]+x[0-9]+$ ]] \ || die "Invalid SNOWLUMA_SCREEN, expected WIDTHxHEIGHTxDEPTH: $SNOWLUMA_SCREEN" [[ "$SNOWLUMA_LOG_LEVEL" =~ ^[A-Za-z]+$ ]] || die "Invalid SNOWLUMA_LOG_LEVEL: $SNOWLUMA_LOG_LEVEL" # 镜像站列表 = 用户指定的(QQ_AGENT_IMAGE_MIRROR 逗号分隔 + --image-mirror)在前, # 内置公共加速站兜底在后。直连 Docker Hub 能通时这些一个都用不到。 # 为什么留内置兜底:国内/受限网络直连 registry-1.docker.io 必失败,兜底之前每个新部署 # 都要先撞一次"拉取镜像失败"再手改参数重跑(2026-10-12 群友实测)。内置站是第三方、 # 随时可能失效:要换成或补充自己的,用参数放在前面即可(重复的会去掉)。 DEFAULT_IMAGE_MIRRORS=(docker.1panel.live docker.m.daocloud.io) IMAGE_MIRRORS=() if [[ -n "${QQ_AGENT_IMAGE_MIRROR:-}" ]]; then IFS=',' read -r -a IMAGE_MIRRORS <<<"$QQ_AGENT_IMAGE_MIRROR" fi [[ -z "$IMAGE_MIRROR_ARG" ]] || IMAGE_MIRRORS+=("$IMAGE_MIRROR_ARG") for mirror in "${DEFAULT_IMAGE_MIRRORS[@]}"; do duplicate=false # 空数组展开用 + guard:bash 4.3 及更老(CentOS 7 / Amazon Linux 2)在 set -u 下 # `"${arr[@]}"` 展开空数组会直接 "unbound variable" 退出(2026-10-13 审查)。 for existing in ${IMAGE_MIRRORS[@]+"${IMAGE_MIRRORS[@]}"}; do [[ "$existing" == "$mirror" ]] && { duplicate=true; break; } done [[ "$duplicate" == true ]] || IMAGE_MIRRORS+=("$mirror") done for mirror in ${IMAGE_MIRRORS[@]+"${IMAGE_MIRRORS[@]}"}; do [[ "$mirror" =~ ^[A-Za-z0-9.-]+(:[0-9]+)?$ ]] || die "Invalid image mirror host: $mirror" done if [[ "$ASSUME_YES" != true ]]; then AGENT_PORT="$(prompt_value 'QQ Agent console port' "$AGENT_PORT")" SNOWLUMA_PORT="$(prompt_value 'SnowLuma WebUI port' "$SNOWLUMA_PORT")" NOVNC_PORT="$(prompt_value 'QQ login/noVNC port' "$NOVNC_PORT")" ONEBOT_HTTP_PORT="$(prompt_value 'OneBot HTTP port (localhost only)' "$ONEBOT_HTTP_PORT")" ONEBOT_WS_PORT="$(prompt_value 'OneBot WebSocket port (localhost only)' "$ONEBOT_WS_PORT")" fi for item in \ "agent:$AGENT_PORT" "SnowLuma:$SNOWLUMA_PORT" "noVNC:$NOVNC_PORT" \ "OneBot HTTP:$ONEBOT_HTTP_PORT" "OneBot WebSocket:$ONEBOT_WS_PORT"; do validate_port "${item%%:*} port" "${item##*:}" done ports=("$AGENT_PORT" "$SNOWLUMA_PORT" "$NOVNC_PORT" "$ONEBOT_HTTP_PORT" "$ONEBOT_WS_PORT") [[ "$(printf '%s\n' "${ports[@]}" | sort -u | wc -l | tr -d ' ')" == 5 ]] \ || die 'All five host ports must be different' if [[ "$SOURCE_DIR" != "$APP_DIR" && ( "$APP_DIR" == "$SOURCE_DIR/"* || "$SOURCE_DIR" == "$APP_DIR/"* ) ]]; then die 'Source and application directories cannot contain each other; choose another --root-dir' fi # 显式轮换凭据时,预检不能拿“新 .env 里的令牌必须等于旧 config.json”来拒绝。 if [[ "$ROTATE_CREDENTIALS" == true ]]; then export QQ_AGENT_ALLOW_CREDENTIAL_ROTATION=1 fi check_host_ownership if [[ "$CHECK_ONLY" == true ]]; then if [[ "$EXISTING_STACK" == true ]]; then printf 'Environment: managed stack. Ownership and port checks passed; no deployment changes made.\n' else printf 'Environment: fresh installation. Ownership and port checks passed; no deployment changes made.\n' fi exit 0 fi # 旧值用到时现读,不把凭据长期留在变量里(少一份密钥驻留在进程环境与内存中) stored_value() { env_value "$ENV_FILE" "$1"; } if [[ "$EXISTING_STACK" == true && "$ROTATE_CREDENTIALS" != true && "$ASSUME_YES" != true ]]; then if confirm 'Rotate Agent, OneBot, SnowLuma and noVNC credentials?' false; then ROTATE_CREDENTIALS=true fi fi if [[ "$EXISTING_STACK" == true && "$ROTATE_CREDENTIALS" != true ]]; then AGENT_TOKEN="${AGENT_TOKEN:-$(stored_value QQ_AGENT_CONSOLE_TOKEN)}" ONEBOT_TOKEN="${ONEBOT_TOKEN:-$(stored_value ONEBOT_TOKEN)}" SNOWLUMA_PASSWORD="${SNOWLUMA_PASSWORD:-$(stored_value SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD)}" VNC_PASSWORD="${VNC_PASSWORD:-$(stored_value VNC_PASSWD)}" fi AGENT_TOKEN="${AGENT_TOKEN:-$(random_hex 24)}" ONEBOT_TOKEN="${ONEBOT_TOKEN:-$(random_hex 32)}" SNOWLUMA_PASSWORD="${SNOWLUMA_PASSWORD:-Sl-$(random_hex 12)!Aa}" VNC_PASSWORD="${VNC_PASSWORD:-$(random_hex 4)}" if [[ "$ASSUME_YES" != true ]] && [[ "$EXISTING_STACK" != true || "$ROTATE_CREDENTIALS" == true ]]; then if confirm 'Use custom credentials instead of the generated values?' false; then AGENT_TOKEN="$(prompt_secret 'QQ Agent console token')" ONEBOT_TOKEN="$(prompt_secret 'Shared OneBot token')" SNOWLUMA_PASSWORD="$(prompt_secret 'SnowLuma WebUI password')" VNC_PASSWORD="$(prompt_secret 'noVNC password (8 characters recommended)')" fi fi if [[ "$EXISTING_STACK" == true && "$SNOWLUMA_PASSWORD" != "$(stored_value SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD)" ]]; then SNOWLUMA_CURRENT_PASSWORD="${SNOWLUMA_CURRENT_PASSWORD:-$(stored_value SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD)}" if [[ -z "$SNOWLUMA_CURRENT_PASSWORD" && "$ASSUME_YES" != true ]]; then SNOWLUMA_CURRENT_PASSWORD="$(prompt_secret 'Current SnowLuma WebUI password')" fi [[ -n "$SNOWLUMA_CURRENT_PASSWORD" ]] \ || die 'The current SnowLuma password is required to rotate existing credentials' fi if [[ "$EXISTING_STACK" != true && "$SKIP_MODEL_CONFIG" != true ]]; then if [[ "$ASSUME_YES" != true ]]; then # 模型这一步做成"可以整段跳过":Base URL / 模型名都回车即默认,Key 留空=装完在网页 # 控制台里配(那边有服务预设与连通性测试,对新人反而更好填)。以前 Key 没默认值, # 答不上来直接 die —— 这是小白卡得最多的一步(2026-10-12)。 printf 'Model setup (press Enter everywhere to skip; configure it later in the web console).\n' >/dev/tty MODEL_BASE_URL="$(prompt_value 'Model API base URL' "${MODEL_BASE_URL:-https://api.deepseek.com}")" MODEL_NAME="$(prompt_value 'Model name' "${MODEL_NAME:-deepseek-chat}")" if [[ -z "$MODEL_API_KEY" ]]; then MODEL_API_KEY="$(prompt_secret 'Model API key (blank = configure in the console after install)')" fi ALLOW_GROUPS="$(prompt_value 'Allowed group IDs, comma-separated (blank to configure later)' "$ALLOW_GROUPS")" ALLOW_PRIVATE="$(prompt_value 'Allowed private QQ IDs, comma-separated (blank to configure later)' "$ALLOW_PRIVATE")" # Key 留空 = 跳过模型配置(保留已填的 base URL/模型名,别把显式传来的值抹掉): # 走 SKIP_MODEL_CONFIG 会把"还缺模型"写进结尾清单,控制台里补 Key 即可。 [[ -n "$MODEL_API_KEY" ]] || SKIP_MODEL_CONFIG=true fi [[ "$SKIP_MODEL_CONFIG" == true || ( -n "$MODEL_BASE_URL" && -n "$MODEL_API_KEY" && -n "$MODEL_NAME" ) ]] \ || die 'Fresh non-interactive deployment requires --model-base-url, --model-api-key and --model, or --skip-model-config' fi if [[ -n "$MODEL_BASE_URL" && ! "$MODEL_BASE_URL" =~ ^https?:// ]] ; then die 'Model API base URL must use http:// or https://' fi for list in "$ALLOW_GROUPS" "$ALLOW_PRIVATE"; do [[ -z "$list" || "$list" =~ ^[0-9]+(,[0-9]+)*$ ]] \ || die 'Allowlists must contain comma-separated numeric QQ IDs without spaces' done validate_secret 'QQ Agent console token' "$AGENT_TOKEN" 16 validate_secret 'OneBot token' "$ONEBOT_TOKEN" 16 validate_secret 'SnowLuma WebUI password' "$SNOWLUMA_PASSWORD" 10 [[ "$SNOWLUMA_PASSWORD" =~ [a-z] && "$SNOWLUMA_PASSWORD" =~ [A-Z] \ && "$SNOWLUMA_PASSWORD" =~ [^A-Za-z0-9] ]] \ || die 'SnowLuma WebUI password must contain lowercase, uppercase, and a special character' validate_secret 'noVNC password' "$VNC_PASSWORD" 8 step 'Preparing directories' ROOT_CREATED=false if [[ ! -d "$ROOT_DIR" ]]; then run_root mkdir -p "$ROOT_DIR" run_root chown "$(id -u):$(id -g)" "$ROOT_DIR" ROOT_CREATED=true fi [[ -w "$ROOT_DIR" ]] || die "Deployment root is not writable by $(id -un): $ROOT_DIR" mkdir -p "$APP_DIR" "$AGENT_DATA_DIR" \ "$SNOWLUMA_DATA_DIR/config" "$SNOWLUMA_DIR/client-config" "$SNOWLUMA_DIR/client-data" # 只收紧本次创建的根目录;已存在的父目录可能是共享目录(/opt、/mnt/data), # 不能因为一次部署把别人的目录改成 0700。 [[ "$ROOT_CREATED" != true ]] || chmod 700 "$ROOT_DIR" chmod 700 "$AGENT_DATA_DIR" "$SNOWLUMA_DIR" "$SNOWLUMA_DATA_DIR" \ "$SNOWLUMA_DIR/client-config" "$SNOWLUMA_DIR/client-data" # 退出钩子与栈级锁已在归属判定之前挂好(见上方 EXISTING_STACK 初始化处)。 # 只有拿到锁且确认是 fresh 的实例才允许做清理;抢锁失败的实例退出时不能删赢家的 .env/compose。 if [[ "$EXISTING_STACK" != true ]]; then FRESH_STACK_CLEANUP=true fi # 只比较摘要/长度,不把旧令牌留在变量里(与“现读现用凭据”的纪律一致)。 OLD_ONEBOT_LEN="$(env_value "$ENV_FILE" ONEBOT_TOKEN | wc -c | tr -d ' ')" OLD_ONEBOT_DIGEST="$(env_value "$ENV_FILE" ONEBOT_TOKEN | sha256sum | awk '{print $1}')" NEW_ONEBOT_DIGEST="$(printf '%s' "$ONEBOT_TOKEN" | sha256sum | awk '{print $1}')" if (( OLD_ONEBOT_LEN > 0 )) && [[ "$OLD_ONEBOT_DIGEST" != "$NEW_ONEBOT_DIGEST" ]]; then # compose up 看到配置没变不会重建容器;令牌改了但 SnowLuma 仍读旧 onebot.json # 会一直 401,所以这里显式强制重建。 RECREATE_SNOWLUMA=true else RECREATE_SNOWLUMA=false fi if [[ -f "$ENV_FILE" ]]; then cp -p "$ENV_FILE" "$ENV_FILE.pre-deploy" fi cat >"$ENV_FILE.tmp" <"$COMPOSE_FILE.tmp" <<'EOF' services: snowluma: image: "${SNOWLUMA_IMAGE}" container_name: "${SNOWLUMA_CONTAINER}" restart: unless-stopped logging: driver: json-file options: max-size: "10m" max-file: "3" shm_size: 1gb ulimits: nofile: soft: 65536 hard: 1048576 cap_add: - SYS_PTRACE security_opt: - seccomp=unconfined environment: VNC_PASSWD: "${VNC_PASSWD}" SNOWLUMA_ONEBOT_HOST: "${SNOWLUMA_ONEBOT_HOST}" SNOWLUMA_UID: "${SNOWLUMA_UID}" SNOWLUMA_GID: "${SNOWLUMA_GID}" SNOWLUMA_WEBUI_HOST: "${SNOWLUMA_WEBUI_HOST}" SNOWLUMA_WEBUI_PORT: "${SNOWLUMA_WEBUI_PORT}" SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD: "${SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD}" SNOWLUMA_LOG_LEVEL: "${SNOWLUMA_LOG_LEVEL}" SNOWLUMA_SCREEN: "${SNOWLUMA_SCREEN}" SNOWLUMA_HOOK_AUTOLOAD: "${SNOWLUMA_HOOK_AUTOLOAD}" SNOWLUMA_QQ_FLAGS: --disable-gpu --disable-software-rasterizer --disable-gpu-compositing SNOWLUMA_TELEMETRY: "${SNOWLUMA_TELEMETRY}" ports: - "${PUBLISH_HOST}:${NOVNC_PORT}:6081" - "${PUBLISH_HOST}:${SNOWLUMA_WEBUI_HOST_PORT}:5099" - "127.0.0.1:${ONEBOT_HTTP_PORT}:3000" - "127.0.0.1:${ONEBOT_WS_PORT}:3001" volumes: - ./data:/app/data - ./client-config:/app/.config - ./client-data:/app/.local/share EOF mv "$COMPOSE_FILE.tmp" "$COMPOSE_FILE" chmod 600 "$COMPOSE_FILE" # 清理钩子已提前到 .env 写盘之前挂载(deploy_all_exit,见上);Agent 装好后在下方撤销标志。 if ! docker_ready && ! sudo_docker_ready; then install_docker; fi docker_ready || sudo_docker_ready || die 'Docker installation completed but the daemon is unavailable' docker_call compose version >/dev/null 2>&1 || install_compose docker_call compose version >/dev/null 2>&1 || die 'Docker Compose v2 is required' # 拉一个引用:每个地址最多 3 次,退避 3 / 6 秒 pull_with_retry() { local ref="$1" attempt for attempt in 1 2 3; do if docker_call pull "$ref"; then return 0; fi ((attempt < 3)) || return 1 sleep $((attempt * 3)) done return 1 } # 换成镜像站之后同步 .env:compose 走 --env-file,里面也得是同一个引用;权限保持不变 update_env_image() { local tmp="$ENV_FILE.tmp.$$" awk -v image="$IMAGE" -F= 'BEGIN { OFS="=" } $1 == "SNOWLUMA_IMAGE" { print "SNOWLUMA_IMAGE=" image; next } { print }' \ "$ENV_FILE" >"$tmp" || return 1 chmod --reference="$ENV_FILE" "$tmp" 2>/dev/null || chmod 600 "$tmp" mv "$tmp" "$ENV_FILE" } # 连不上 Docker Hub 时的落地方案(国内服务器常见;内置站已在上面自动试过一轮) image_pull_hint() { cat >&2 <<'HINT' 拉取 SnowLuma 镜像失败。国内/受限网络连不上 Docker Hub(registry-1.docker.io)很常见; 内置的公共加速站已经自动试过一轮(docker.1panel.live、docker.m.daocloud.io),都没拉下来。 下面三种办法任选一种,然后重跑 deploy-all.sh: 1) 换成你信得过的加速器地址重跑(云厂商给的那个通常最稳): QQ_AGENT_IMAGE_MIRROR= bash deploy-all.sh 可以给多个、逗号分隔;你自己的排在前面,内置的仍在其后兜底。 或直接给完整地址: bash deploy-all.sh --image /motricseven7/snowluma:v1.14.22 2) 给 Docker 配全局加速器(配一次,之后所有拉取都走它): sudo tee /etc/docker/daemon.json <<'JSON' { "registry-mirrors": ["https://<你的加速器地址>"] } JSON sudo systemctl restart docker 3) 在能联网的机器上拉好再带过来: docker pull motricseven7/snowluma:v1.14.22 docker save motricseven7/snowluma:v1.14.22 | gzip > snowluma.tgz gunzip -c snowluma.tgz | docker load # 在目标机上执行 镜像站由第三方提供:内置的两家只是兜底,不放心就用 --image-mirror 换成你自己的。 HINT } step "Downloading SnowLuma image $IMAGE" PULLED=false if docker_call image inspect "$IMAGE" >/dev/null 2>&1; then # 离线/内网按文档 docker load 过的镜像:本地已有就不要强制访问 registry。 info "Using locally loaded image: $IMAGE" PULLED=true elif pull_with_retry "$IMAGE"; then PULLED=true else # .env 里存的可能是"上次走通的镜像站前缀 + 基础引用"——默认走内置兜底之后,这是国内新装的 # 常态。不先把认得的前缀剥掉,换站重跑会把旧前缀原样拼给每个镜像站:旧站一挂就永远换不到 # 新站(2026-10-12 审查:hint 首推的 QQ_AGENT_IMAGE_MIRROR= 同样会失效)。 # 只剥"当前列表里认得的"前缀——真正的自定义 registry 引用不猜、照旧原样重试。 BASE_IMAGE="$IMAGE" for known in "${IMAGE_MIRRORS[@]}"; do case "$BASE_IMAGE" in "$known"/*) BASE_IMAGE="${BASE_IMAGE#"$known"/}"; break ;; esac done for mirror in "${IMAGE_MIRRORS[@]}"; do # 只在基础引用还没有 registry host 时加前缀:≥2 个斜杠 = 真正的自定义 registry #(或没剥掉的前缀),保持原样,不能拿第三方前缀去套别人的命名空间。 case "$BASE_IMAGE" in */*/*) candidate="$BASE_IMAGE" ;; *) candidate="${mirror%/}/$BASE_IMAGE" ;; esac # 直连那一步已经试过这个引用(剥离后重拼可能等于原值)就别再试一遍 [[ "$candidate" == "$IMAGE" ]] && continue # 本地已有镜像站前缀的同一镜像时别访问那个可能已下线的站 if docker_call image inspect "$candidate" >/dev/null 2>&1; then info "Using locally loaded image: $candidate" IMAGE="$candidate" update_env_image || true PULLED=true break fi step "Retrying through image mirror $mirror" if pull_with_retry "$candidate"; then IMAGE="$candidate" update_env_image || true PULLED=true break fi done fi if [[ "$PULLED" != true ]]; then image_pull_hint die "Failed to download SnowLuma image ($IMAGE); see the hint above" fi step 'Installing QQ Agent' export QQ_AGENT_CONSOLE_TOKEN="$AGENT_TOKEN" export QQ_AGENT_ONEBOT_TOKEN="$ONEBOT_TOKEN" export QQ_AGENT_ONEBOT_HTTP_TOKEN="$ONEBOT_TOKEN" export QQ_AGENT_ONEBOT_HTTP_URL="http://127.0.0.1:$ONEBOT_HTTP_PORT" export QQ_AGENT_ONEBOT_WS_URL="ws://127.0.0.1:$ONEBOT_WS_PORT" export QQ_SNOWLUMA_WEBUI_URL="http://127.0.0.1:$SNOWLUMA_PORT" # 协议端更新器用这个端口做就绪探测;只在 URL 里带端口不够(自定义 --snowluma-port 时 # 会一直探默认 5099 → 超时回滚,2026-10 审查 P2)。 export QQ_AGENT_WEBUI_PORT="$SNOWLUMA_PORT" [[ -z "$MODEL_BASE_URL" ]] || export QQ_AGENT_MODEL_BASE_URL="$MODEL_BASE_URL" # 模型 Key 不走子进程环境(/proc//environ 里读得到):写 0600 临时文件, # 部署脚本按 QQ_AGENT_MODEL_KEY_FILE 读,脚本退出时删掉。 if [[ -n "$MODEL_API_KEY" ]]; then MODEL_KEY_FILE="$(mktemp "${TMPDIR:-/tmp}/qq-agent-model-key.XXXXXX")" printf '%s' "$MODEL_API_KEY" >"$MODEL_KEY_FILE" chmod 600 "$MODEL_KEY_FILE" export QQ_AGENT_MODEL_KEY_FILE="$MODEL_KEY_FILE" fi [[ -z "$MODEL_NAME" ]] || export QQ_AGENT_MODEL="$MODEL_NAME" [[ -z "$ALLOW_GROUPS" ]] || export QQ_AGENT_ALLOW_GROUPS="$ALLOW_GROUPS" [[ -z "$ALLOW_PRIVATE" ]] || export QQ_AGENT_ALLOW_PRIVATE="$ALLOW_PRIVATE" # 端口每一轮都显式传给 deploy.sh:.env 在重跑时已经写成了新端口,如果 deploy.sh 仍沿用 # config.json 的旧端口,部署会"成功"但随后按新端口做健康检查必然超时,.env 与 config.json # 从此不一致,后续所有预检都会拒绝(2026-10 审查 P1)。 DEPLOY_ARGS=(--install-dir "$APP_DIR" --data-dir "$AGENT_DATA_DIR" --service "$SERVICE" --port "$AGENT_PORT") if [[ "$EXISTING_STACK" != true || "$PUBLISH_EXPLICIT" == true ]]; then # 新装按 --publish/--loopback 落 host;已有安装只有显式传了 --publish/--loopback 才改, # 否则沿用 config.json 里的 host(避免一次普通重跑把用户收紧过的绑定改回去)。 DEPLOY_ARGS+=(--host "$PUBLISH_HOST") fi bash "$SOURCE_DIR/deploy.sh" "${DEPLOY_ARGS[@]}" # Agent 已装好,栈不再是"半成品":撤销失败清理,保留 .env / compose。 FRESH_STACK_CLEANUP=false # .env 的新凭据自此生效(deploy.sh 已完成、config.json 已含配套令牌):此后失败不再回拷。 DEPLOY_COMPLETED=true NODE_BIN="$(tr -d '\r\n' <"$APP_DIR/.deployment-node")" if [[ "$EXISTING_STACK" != true ]]; then "$APP_DIR/manage.sh" observe fi "$NODE_BIN" "$SOURCE_DIR/scripts/configure-snowluma.mjs" \ --data-dir "$SNOWLUMA_DATA_DIR" \ --http-port 3000 \ --ws-port 3001 step 'Starting SnowLuma and OneBot' COMPOSE_UP=(--env-file .env up -d) if [[ "$RECREATE_SNOWLUMA" == true ]]; then COMPOSE_UP+=(--force-recreate snowluma) fi (cd "$SNOWLUMA_DIR" && docker_call compose "${COMPOSE_UP[@]}") wait_http "http://127.0.0.1:$SNOWLUMA_PORT/api/ui/public" 90 \ || die "SnowLuma WebUI did not become ready; run: cd $SNOWLUMA_DIR && docker compose logs" wait_http "http://127.0.0.1:$NOVNC_PORT/" 30 \ || die "noVNC did not become ready; run: cd $SNOWLUMA_DIR && docker compose logs" # 2026-10-06 复审 P3:原判据用 stored_value 读 SNOWLUMA_WEBUI_BOOTSTRAP_PASSWORD,但 .env # 已在 613-636 行把该键改写成新密码,于是「新密码 != stored_value」恒为假,整个轮换分支 # (含 rotate-snowluma-password.mjs)永不执行:容器里还是旧密码,而 .env 与 # deployment-access.txt 记的是新密码,凭据静默失配且无任何报错。这里改用 553-556 行 # 在改写 .env 之前捕获的旧密码 SNOWLUMA_CURRENT_PASSWORD 来比较。 # 另加 -n 守卫:密码未变时 553 行分支不进入,该变量为空,若不判空则空串 != 新密码 会误触发 # 轮换(重跑脚本是常态,不能每次都轮换);真正需要轮换却取不到旧密码时,558 行已提前 die。 if [[ "$EXISTING_STACK" == true && -n "$SNOWLUMA_CURRENT_PASSWORD" \ && "$SNOWLUMA_CURRENT_PASSWORD" != "$SNOWLUMA_PASSWORD" ]]; then # 密码走环境变量而不是命令行参数:/proc//cmdline 对本机所有用户可读。 export QQ_AGENT_SNOWLUMA_CURRENT_PASSWORD="$SNOWLUMA_CURRENT_PASSWORD" export QQ_AGENT_SNOWLUMA_PASSWORD="$SNOWLUMA_PASSWORD" rotate_args=(--url "http://127.0.0.1:$SNOWLUMA_PORT") [[ -z "$SNOWLUMA_TOTP" ]] || rotate_args+=(--totp "$SNOWLUMA_TOTP") if ! "$NODE_BIN" "$SOURCE_DIR/scripts/rotate-snowluma-password.mjs" "${rotate_args[@]}"; then # 2026-10-09 审查:原先这里回拷 .env 是**假回滚** —— 此刻 config.json / compose / 容器 # 都已是新凭据,只退回 .env 会让下次部署被自家预检拒("凭据被外部改过"),而新的控制台 # 令牌又只留在 config.json 里、access 文件还没生成。保持三方一致(都用新凭据)才是可恢复 # 状态:WebUI 密码维持旧值,带上**实际在用**的旧密码重跑即可完成轮换。 KEEP_ENV_ON_ROTATE_FAILURE=true printf '控制台令牌已更新;新令牌见 %s/config.json 的 server.token(或 %s/console-access.txt)。SnowLuma WebUI 密码仍是旧值。\n' \ "$AGENT_DATA_DIR" "$AGENT_DATA_DIR" >&2 die 'SnowLuma password rotation failed. Every other credential was left in place and stays consistent; the WebUI password is unchanged. Retry with --snowluma-current-password set to the password the WebUI actually uses.' fi fi systemctl --user restart "$SERVICE.service" wait_http "http://127.0.0.1:$AGENT_PORT/healthz" 30 \ || die "QQ Agent did not become ready; run: $APP_DIR/manage.sh logs" # 优先 IPv4:IPv6 直接拼 http://240e:... 是非法 URL,install.sh 的端口正则也匹配不到。 HOST_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | head -n 1 || true)" HOST_IP="${HOST_IP:-$(hostname -I 2>/dev/null | awk '{print $1}' || true)}" HOST_IP="${HOST_IP:-127.0.0.1}" if [[ "$PUBLISH_HOST" == "127.0.0.1" ]]; then HOST_IP="127.0.0.1" PUBLISH_NOTE="bound to 127.0.0.1 only — use an SSH tunnel (console-tunnel.bat / ops console) or re-run with --publish" else PUBLISH_NOTE="bound to 0.0.0.0 — restrict the cloud security group / firewall to trusted sources" fi MODE="$("$NODE_BIN" -e 'const c=require(process.argv[1]);process.stdout.write(c.runtime.mode)' \ "$AGENT_DATA_DIR/config.json")" cat >"$ACCESS_FILE.tmp" </dev/tty IFS= read -r _ &2 fi fi