#!/usr/bin/env bash # update-db 的唯一执行通道:对生产库的一切操作经它走 ssh(在远端本机回环上调 API / 做快照)。 # # 用法: # api.sh api METHOD PATH [JSON] # 调管理接口;PATH 以 /api/ 或 /sub/ 开头;JSON 作第三参 # api.sh api METHOD PATH --body-file FILE # 含凭据或复杂 JSON 优先用权限 600 的文件,避免放入 argv # api.sh backup # 变更前快照 → data/backups/before--<时间戳>.db # api.sh backups # 列出最近 20 个备份 # api.sh counts # 各表行数,变更前后对比用 # api.sh health # systemd 状态 + /api/health # api.sh restore --confirm # 整体回滚:停服换库再拉起(仅在用户明确要求时执行) # # 可用环境变量覆盖:DEPLOY_HOST(默认 la)、DEPLOY_SERVICE(默认 proxy-station)、 # PS_DATA_DIR(默认 /opt/proxy-station/data)、PS_ENV_FILE(默认 /etc/proxy-station/env) set -euo pipefail # 会话注入的 HTTP 代理只供模型 API 使用,ssh 一律直连(同 deploy.sh) unset HTTPS_PROXY HTTP_PROXY https_proxy http_proxy ALL_PROXY all_proxy 2>/dev/null || true HOST="${DEPLOY_HOST:-la}" SERVICE="${DEPLOY_SERVICE:-proxy-station}" DATA_DIR="${PS_DATA_DIR:-/opt/proxy-station/data}" DB_FILE="$DATA_DIR/proxy-station.db" BACKUP_DIR="$DATA_DIR/backups" ENV_FILE="${PS_ENV_FILE:-/etc/proxy-station/env}" SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST") die() { echo "错误:$*" >&2; exit 1; } [ $# -ge 1 ] || die "缺少子命令(api/backup/backups/counts/health/restore)" cmd="$1"; shift case "$cmd" in api) [ $# -ge 2 ] || die "用法:api.sh api METHOD PATH [JSON]" METHOD="$1"; APIPATH="$2"; BODY="${3:-}"; BODY_FILE="" if [ "$BODY" = "--body-file" ]; then BODY_FILE="${4:-}"; BODY="" [ -n "$BODY_FILE" ] && [ -f "$BODY_FILE" ] || die "--body-file 需要可读的 JSON 文件" fi case "$METHOD" in GET|POST|PUT|PATCH|DELETE) ;; *) die "METHOD 只能是 GET/POST/PUT/PATCH/DELETE";; esac case "$APIPATH" in /api/*|/sub/*) ;; *) die "PATH 必须以 /api/ 或 /sub/ 开头";; esac case "$APIPATH" in *[!a-zA-Z0-9_./?=%\&~:+-]*) die "PATH 含不安全字符,请先做 URL 编码";; esac # 单引号段内的 $ADMIN_TOKEN 与 ${PORT:-3000} 留给远端 shell 展开:token 不经本地 shell、不落本地磁盘 RCMD='. '"$ENV_FILE"' 2>/dev/null; curl -sS -m 60 -X '"$METHOD"' -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" -w "\n__HTTP_STATUS:%{http_code}__" "http://127.0.0.1:${PORT:-3000}'"$APIPATH"'"' if [ -n "$BODY_FILE" ]; then "${SSH[@]}" "$RCMD --data-binary @-" < "$BODY_FILE" elif [ -n "$BODY" ]; then printf '%s' "$BODY" | "${SSH[@]}" "$RCMD --data-binary @-" else "${SSH[@]}" "$RCMD" fi ;; backup) CTX="${1:-}"; [ -n "$CTX" ] || die "用法:api.sh backup (小写字母/数字/连字符,如 add-jp-hy2)" case "$CTX" in *[!a-z0-9-]*) die "context 只允许小写字母、数字与连字符";; esac # .backup 是 SQLite 在线备份,WAL 下安全,产出独立完整快照(无需 wal/shm) "${SSH[@]}" 'set -e; ts=$(date +%Y%m%d-%H%M%S); f='"$BACKUP_DIR"'/before-'"$CTX"'-$ts.db; sqlite3 '"$DB_FILE"' ".backup $f"; if [ -s "$f" ]; then ls -la "$f"; else echo "备份为空" >&2; exit 1; fi' ;; backups) "${SSH[@]}" "ls -la '$BACKUP_DIR' | tail -20" ;; counts) # 表清单来自 BOOTSTRAP_SQL;新增表时应同步更新这里 SQL="SELECT 'nodes='||COUNT(*) FROM nodes" for t in policy_groups group_members rules rulesets ruleset_cache sub_tokens settings; do SQL="$SQL UNION ALL SELECT '$t='||COUNT(*) FROM $t" done "${SSH[@]}" "sqlite3 '$DB_FILE' \"$SQL\"" ;; health) "${SSH[@]}" "systemctl is-active $SERVICE; curl -sS -m 5 http://127.0.0.1:3000/api/health; echo" ;; restore) FILE="${1:-}"; [ -n "$FILE" ] || die "用法:api.sh restore <备份文件> --confirm" [ "${2:-}" = "--confirm" ] || die "restore 会停服换库(订阅中断约 10–20 秒),必须由用户明确要求后追加 --confirm 执行" case "$FILE" in "$BACKUP_DIR"/*.db) ;; *) die "备份文件必须是 $BACKUP_DIR 下的 .db 快照";; esac "${SSH[@]}" 'set -e; f='"$FILE"'; [ -f "$f" ] || { echo "备份不存在:$f" >&2; exit 1; } systemctl stop '"$SERVICE"' cp "$f" '"$DB_FILE"'.new && mv '"$DB_FILE"'.new '"$DB_FILE"' rm -f '"$DB_FILE"'-wal '"$DB_FILE"'-shm chown '"$SERVICE"':'"$SERVICE"' '"$DB_FILE"' systemctl start '"$SERVICE"' for i in $(seq 1 20); do curl -sf -m 2 http://127.0.0.1:3000/api/health >/dev/null && break; sleep 1; done systemctl is-active '"$SERVICE"' && curl -s http://127.0.0.1:3000/api/health && echo && echo "已回滚自 $(basename "$f")"' ;; *) die "未知子命令:$cmd(可用:api/backup/backups/counts/health/restore)" ;; esac