#!/usr/bin/env bash # 一键部署/运维脚本(在本机 Git Bash 里跑,依赖 pnpm、ssh、scp,已配置免密登录)。 # # 用法: # bash deploy.sh # = web:构建并发布前端 # bash deploy.sh web # pnpm build → 上传 dist → 切换站点目录(保留 data/)→ 同步 .env # bash deploy.sh cookie # 交互式粘贴新的百炼 Cookie,写入服务器并立即试跑探针 # bash deploy.sh cookie 'cna=…' # 直接以参数提供 Cookie(会出现在 shell 历史,优先用交互方式) # bash deploy.sh probe # 更新探针脚本与 systemd 单元,立即试跑 # bash deploy.sh nginx # 安装/刷新 nginx 站点配置并 reload # bash deploy.sh auth # 交互式设置 Basic Auth 用户名/密码(不回显)并开启整站认证 # bash deploy.sh auth user pwd # 非交互设置(密码会进 shell 历史,优先用交互方式) # bash deploy.sh auth off # 关闭 Basic Auth(删除 htpasswd 并 reload) # bash deploy.sh status # 查看定时器、最新快照与 HTTP 自检 # bash deploy.sh all # nginx + web + probe(不动 Cookie) # # 可用环境变量覆盖默认值: # DEPLOY_HOST=root@10.1.192.225 REMOTE_DIR=/usr/dcits/token-list-share SITE_PORT=18081 set -euo pipefail REMOTE_HOST="${DEPLOY_HOST:-root@10.1.192.225}" REMOTE_DIR="${REMOTE_DIR:-/usr/dcits/token-list-share}" SITE_PORT="${SITE_PORT:-18081}" SUB2API_UPSTREAM="${SUB2API_UPSTREAM:-http://127.0.0.1:18080}" PROBE_ENV=/etc/bailian-probe.env PROBE_DIR="$REMOTE_DIR/probe" PROBE_BIN="$PROBE_DIR/bailian-probe.py" RESET_CARD_BIN="$PROBE_DIR/reset-card-server.py" RESET_CARD_PORT="${RESET_CARD_PORT:-18082}" SNAPSHOT="$REMOTE_DIR/www/data/bailian.json" HTPASSWD=/etc/nginx/token-list.htpasswd SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" log() { printf '\033[1;36m▶ %s\033[0m\n' "$*"; } ok() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; } die() { printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2; exit 1; } ssh_r() { ssh -o BatchMode=yes "$REMOTE_HOST" "$@"; } scp_r() { scp -o BatchMode=yes "$@"; } # ---------- web ---------- cmd_web() { command -v pnpm >/dev/null || die "找不到 pnpm" log "本地构建" (cd "$SCRIPT_DIR" && pnpm build) local tmp_tgz; tmp_tgz="$(mktemp -t token-dist-XXXXXX.tar.gz)" tar -czf "$tmp_tgz" -C "$SCRIPT_DIR/dist" . log "上传并发布到 $REMOTE_HOST:$REMOTE_DIR" scp_r -q "$tmp_tgz" "$REMOTE_HOST:/tmp/token-list-dist.tar.gz" # .env 仅作服务器侧备份(key 已构建进 JS),不入 Web 根目录 scp_r -q "$SCRIPT_DIR/.env" "$REMOTE_HOST:$REMOTE_DIR/.env.new" ssh_r 'bash -s' \ "$REMOTE_DIR" "$SNAPSHOT" <<'REMOTE' set -euo pipefail DIR="$1" mkdir -p "$DIR" chmod 600 "$DIR/.env.new" mv "$DIR/.env.new" "$DIR/.env" mkdir -p "$DIR/www.new" tar -xzf /tmp/token-list-dist.tar.gz -C "$DIR/www.new" rm -f /tmp/token-list-dist.tar.gz # 探针快照目录在站点根内,发版必须保留 if [ -d "$DIR/www/data" ]; then mkdir -p "$DIR/www.new/data" cp -a "$DIR/www/data/." "$DIR/www.new/data/" fi rm -rf "$DIR/www.old" mv "$DIR/www" "$DIR/www.old" mv "$DIR/www.new" "$DIR/www" rm -rf "$DIR/www.old" REMOTE rm -f "$tmp_tgz" ok "前端已发布(data/ 快照目录已保留)" } # ---------- cookie ---------- read_cookie() { # 显式传参(含空字符串,用于清除)优先;其次 BAILIAN_COOKIE 环境变量;最后交互输入 if [ "$#" -ge 2 ]; then printf '%s' "$2" return fi if [ -n "${BAILIAN_COOKIE:-}" ]; then printf '%s' "$BAILIAN_COOKIE" return fi printf '粘贴百炼控制台 Cookie(一整行,粘贴后回车;输入留空则清除):\n' >&2 local line IFS= read -r line "$tmp" scp_r -q "$tmp" "$REMOTE_HOST:/run/.bailian-cookie.in" rm -f "$tmp" log "更新 $PROBE_ENV 并试跑探针" ssh_r 'bash -s' "$PROBE_ENV" "$SNAPSHOT" <<'REMOTE' set -euo pipefail ENV_FILE="$1"; SNAPSHOT="$2" ENV_FILE="$ENV_FILE" python3 - <<'PY' import os, pathlib p = pathlib.Path(os.environ["ENV_FILE"]) p.parent.mkdir(parents=True, exist_ok=True) cookie = pathlib.Path("/run/.bailian-cookie.in").read_text(encoding="utf-8").strip() lines = p.read_text().splitlines() if p.exists() else [] out, found = [], False for ln in lines: if ln.startswith("BAILIAN_COOKIE="): out.append("BAILIAN_COOKIE=" + cookie); found = True else: out.append(ln) if not found: out.append("BAILIAN_COOKIE=" + cookie) p.write_text("\n".join(out) + "\n", encoding="utf-8") PY chmod 600 "$ENV_FILE" rm -f /run/.bailian-cookie.in systemctl reset-failed bailian-probe.service >/dev/null 2>&1 || true # 凭证失效时 oneshot 退出码为 1,快照仍会写出,属预期 systemctl start bailian-probe.service >/dev/null 2>&1 || true SNAPSHOT="$SNAPSHOT" python3 - <<'PY' import json, os, pathlib snap = pathlib.Path(os.environ["SNAPSHOT"]) s = json.loads(snap.read_text(encoding="utf-8")) if s.get("ok"): print("✓ 探针成功:%s 个用量窗口,probed_at=%s" % (len(s.get("windows", [])), s.get("probed_at"))) else: print("✗ 探针未取到数据(%s):%s" % (s.get("error_kind"), s.get("last_error"))) PY REMOTE } # ---------- probe ---------- cmd_probe() { log "上传探针脚本与 systemd 单元" scp_r -q "$SCRIPT_DIR/deploy/bailian-probe.py" "$REMOTE_HOST:/tmp/bailian-probe.py" scp_r -q "$SCRIPT_DIR/deploy/reset-card-server.py" "$REMOTE_HOST:/tmp/reset-card-server.py" scp_r -q "$SCRIPT_DIR/deploy/bailian-probe.timer" "$REMOTE_HOST:/tmp/bailian-probe.timer" ssh_r 'bash -s' "$PROBE_ENV" "$SNAPSHOT" "$PROBE_DIR" "$RESET_CARD_PORT" "$REMOTE_DIR" <<'REMOTE' set -euo pipefail ENV_FILE="$1"; SNAPSHOT="$2"; PROBE_DIR="$3"; CARD_PORT="$4"; APP_DIR="$5" mkdir -p "$PROBE_DIR" "$(dirname "$SNAPSHOT")" install -m 755 /tmp/bailian-probe.py "$PROBE_DIR/bailian-probe.py" install -m 755 /tmp/reset-card-server.py "$PROBE_DIR/reset-card-server.py" install -m 644 /tmp/bailian-probe.timer /etc/systemd/system/bailian-probe.timer # 用卡助手:常驻本地服务(只绑 127.0.0.1),用卡前后由 nginx /bailian-admin/ 鉴权转发 cat > /etc/systemd/system/reset-card-server.service < /etc/systemd/system/bailian-probe.service < "$ENV_FILE" </dev/null systemctl enable --now reset-card-server.service >/dev/null 2>&1 || true systemctl reset-failed bailian-probe.service >/dev/null 2>&1 || true systemctl start bailian-probe.service >/dev/null 2>&1 || true SNAPSHOT="$SNAPSHOT" python3 - <<'PY' import json, os, pathlib s = json.loads(pathlib.Path(os.environ["SNAPSHOT"]).read_text(encoding="utf-8")) print("快照:ok=%s stale=%s windows=%d err=%s" % (s.get("ok"), s.get("stale"), len(s.get("windows", [])), s.get("last_error"))) PY REMOTE ok "探针已更新,定时器每 5 分钟跑一次" } # ---------- auth ---------- # 在远端用 openssl apr1 生成 htpasswd 行(Debian/Ubumtu 的 openssl 带 -apr1) cmd_auth() { if [ "${2:-}" = "off" ]; then log "关闭 Basic Auth" ssh_r "rm -f '$HTPASSWD'" cmd_nginx ok "Basic Auth 已关闭" return fi local user password if [ "$#" -ge 3 ]; then user="$2"; password="$3" else printf 'Basic Auth 用户名:' >&2 IFS= read -r user &2 IFS= read -r -s password &2 fi [ -n "$user" ] || die "用户名不能为空" [ -n "$password" ] || die "密码不能为空" # 密码经 600 临时文件透传,不出现在远端命令行/进程列表 local tmp; tmp="$(mktemp -t token-htpasswd-XXXXXX)" chmod 600 "$tmp" printf '%s' "$password" >"$tmp" scp_r -q "$tmp" "$REMOTE_HOST:/run/.token-htpasswd.in" rm -f "$tmp" log "写入 $HTPASSWD 并刷新 nginx" # stdout 输出除本次用户外仍保留的账号,用于提示旧账号仍可登录 local others others="$(ssh_r 'bash -s' "$HTPASSWD" "$user" <<'REMOTE' set -euo pipefail HTPASSWD="$1"; USER_NAME="$2" command -v openssl >/dev/null || { echo "✗ 远端没有 openssl,无法生成 apr1 哈希" >&2; exit 1; } [ -s /run/.token-htpasswd.in ] || { echo "✗ 密码临时文件缺失" >&2; exit 1; } hash=$(openssl passwd -apr1 -in /run/.token-htpasswd.in) rm -f /run/.token-htpasswd.in mkdir -p "$(dirname "$HTPASSWD")" # 同名用户覆盖更新,其余用户保留 if [ -f "$HTPASSWD" ]; then grep -v "^${USER_NAME}:" "$HTPASSWD" > "$HTPASSWD.new" || true else : > "$HTPASSWD.new" fi printf '%s:%s\n' "$USER_NAME" "$hash" >> "$HTPASSWD.new" install -m 640 "$HTPASSWD.new" "$HTPASSWD" rm -f "$HTPASSWD.new" chown root:www-data "$HTPASSWD" 2>/dev/null || chown root:nginx "$HTPASSWD" 2>/dev/null || true cut -d: -f1 "$HTPASSWD" | grep -v "^${USER_NAME}$" || true REMOTE )" cmd_nginx >/dev/null ok "Basic Auth 已开启:用户 $user(浏览器访问整站时输入)" if [ -n "$others" ]; then printf '\033[1;33m! htpasswd 里还有其他账号仍可登录:%s\n' "$(printf '%s' "$others" | tr '\n' ' ')" >&2 printf ' 如要停用旧账号:ssh %s "sed -i \x27/^<用户名>:/d\x27 %s"\033[0m\n' "$REMOTE_HOST" "$HTPASSWD" >&2 fi } # ---------- nginx ---------- cmd_nginx() { log "安装 nginx 站点(端口 $SITE_PORT,反代 $SUB2API_UPSTREAM)" # 先看远端 htpasswd 是否存在:存在就在整站 server 块开 Basic Auth local auth_block="" if ssh_r "test -s '$HTPASSWD'"; then auth_block="$(printf ' auth_basic "token-list-share";\n auth_basic_user_file %s;\n' "$HTPASSWD")" log "检测到 $HTPASSWD,开启 Basic Auth" fi local tmp; tmp="$(mktemp -t token-nginx-XXXXXX.conf)" cat >"$tmp" <