#!/usr/bin/env bash # 在 mini-infra 校验离线 emit SQL,并通过 Tailscale 受控导入 mini-desktop staging MySQL。 # SQL 仅经 stdin 传输;日志与证据只保留哈希、用户名和聚合计数,不输出 token。 set -euo pipefail readonly EXPECTED_ACK='IMPORT_NEWAPI_POOL_TO_STAGING' readonly EXPECTED_SOURCE_HOST='mini-infra' readonly EXPECTED_SOURCE_RUNTIME_HOST='minione-ubuntu-infra' readonly EXPECTED_TARGET_HOST='root@100.64.0.7' readonly MYSQL_CONTAINER='game-staging-mysql' readonly MYSQL_DATABASE='ruoyi-vue-pro' readonly POOL_TABLE='newapi_quota_pool' NEWAPI_POOL_IMPORT_ACK="${NEWAPI_POOL_IMPORT_ACK:-}" NEWAPI_POOL_SQL_FILE="${NEWAPI_POOL_SQL_FILE:-}" NEWAPI_POOL_IMPORT_HOST="${NEWAPI_POOL_IMPORT_HOST:-$EXPECTED_TARGET_HOST}" NEWAPI_POOL_IMPORT_EVIDENCE="${NEWAPI_POOL_IMPORT_EVIDENCE:-./newapi-pool-import.evidence}" HOSTNAME_BIN="${HOSTNAME_BIN:-hostname}" SSH_BIN="${SSH_BIN:-ssh}" die() { printf 'NEWAPI_POOL_IMPORT_FAIL %s\n' "$*" >&2; exit 1; } mode_of() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; } [ "$NEWAPI_POOL_IMPORT_ACK" = "$EXPECTED_ACK" ] || \ die "NEWAPI_POOL_IMPORT_ACK 必须精确设为 $EXPECTED_ACK" [ -f "$NEWAPI_POOL_SQL_FILE" ] && [ ! -L "$NEWAPI_POOL_SQL_FILE" ] || \ die 'NEWAPI_POOL_SQL_FILE 必须是本机普通文件' case "$(mode_of "$NEWAPI_POOL_SQL_FILE")" in 400|600) ;; *) die 'emit SQL 含 token,文件权限必须是 400 或 600' ;; esac source_hostname="$("$HOSTNAME_BIN" -s 2>/dev/null || "$HOSTNAME_BIN")" source_hostname="${source_hostname%%.*}" case "$source_hostname" in "$EXPECTED_SOURCE_HOST"|"$EXPECTED_SOURCE_RUNTIME_HOST") ;; *) die "只允许在 mini-infra 离线执行,actual=$source_hostname" ;; esac [ "$NEWAPI_POOL_IMPORT_HOST" = "$EXPECTED_TARGET_HOST" ] || \ die "导入目标只允许 $EXPECTED_TARGET_HOST" [ ! -e "$NEWAPI_POOL_IMPORT_EVIDENCE" ] || \ die "证据文件已存在,拒绝覆盖:$NEWAPI_POOL_IMPORT_EVIDENCE" umask 077 validation_file="$(mktemp)" remote_output_file="$(mktemp)" cleanup() { rm -f "$validation_file" "$remote_output_file"; } trap cleanup EXIT # 只接受预置工具生成的四条幂等 INSERT;解析仅输出非敏感 username/user id。 python3 - "$NEWAPI_POOL_SQL_FILE" >"$validation_file" <<'PY' import re import sys path = sys.argv[1] comment_re = re.compile(r"^-- pool_entry username=(neice_[0-9]{3}) newapi_user_id=([0-9]+)$") insert_re = re.compile( r"^INSERT INTO newapi_quota_pool " r"\(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, " r"quota_per_unit_snapshot, usd_rate_snapshot, status\) VALUES " r"\(([0-9]+), [0-9]+, '[A-Za-z0-9_-]{16,128}', [0-9]+, [0-9]+, " r"[0-9]+(?:\.[0-9]+)?, 'FREE'\) ON DUPLICATE KEY UPDATE " r"newapi_token_id=VALUES\(newapi_token_id\), " r"newapi_token_key=VALUES\(newapi_token_key\), " r"grant_quota=VALUES\(grant_quota\), " r"quota_per_unit_snapshot=VALUES\(quota_per_unit_snapshot\), " r"usd_rate_snapshot=VALUES\(usd_rate_snapshot\);$" ) entries = [] pending = None with open(path, encoding="utf-8") as handle: for line_number, raw in enumerate(handle, 1): line = raw.strip() if not line: continue comment = comment_re.fullmatch(line) if comment: if pending is not None: raise SystemExit(f"第 {line_number} 行前一条 pool_entry 缺 INSERT") pending = (comment.group(1), int(comment.group(2))) continue if line.startswith("--"): continue insert = insert_re.fullmatch(line) if not insert or pending is None: raise SystemExit(f"第 {line_number} 行不是受控额度池 INSERT") sql_user_id = int(insert.group(1)) if sql_user_id != pending[1]: raise SystemExit(f"第 {line_number} 行 user id 与 pool_entry 不一致") entries.append(pending) pending = None if pending is not None: raise SystemExit("最后一条 pool_entry 缺 INSERT") expected_names = [f"neice_{number:03d}" for number in range(51, 55)] names = [entry[0] for entry in entries] user_ids = [entry[1] for entry in entries] if names != expected_names: raise SystemExit(f"只允许导入 neice_051..054,actual={names}") if len(set(user_ids)) != 4: raise SystemExit("四条 pool_entry 的 newapi_user_id 必须唯一") print("4\t" + ",".join(str(user_id) for user_id in user_ids)) PY IFS=$'\t' read -r source_row_count target_user_ids <"$validation_file" [ "$source_row_count" = 4 ] && [[ "$target_user_ids" =~ ^[0-9]+(,[0-9]+){3}$ ]] || \ die 'emit SQL 校验结果非法' source_sql_sha256="$(sha256sum "$NEWAPI_POOL_SQL_FILE" | awk '{print $1}')" # 远端脚本固定容器、数据库和表;SQL 由 stdin 送入临时 mode600 文件,事务执行后立即销毁。 remote_script="$(cat <<'REMOTE' set -euo pipefail expected_hash="$1" target_user_ids="$2" sql_file="$(mktemp)" mysql_error='' cleanup_remote() { rm -f "$sql_file" [ -z "$mysql_error" ] || rm -f "$mysql_error" } trap cleanup_remote EXIT umask 077 cat >"$sql_file" chmod 600 "$sql_file" [ "$(sha256sum "$sql_file" | awk '{print $1}')" = "$expected_hash" ] || { printf '远端 SQL 哈希不匹配\n' >&2 exit 4 } docker inspect game-staging-mysql >/dev/null aggregate() { local query query="SELECT COUNT(*), COALESCE(SUM(status='FREE'),0), COALESCE(SUM(status='CLAIMED'),0), COALESCE(SUM(status='INVALID'),0), (SELECT COUNT(*) FROM newapi_quota_pool WHERE status='FREE' AND deleted=b'0') FROM newapi_quota_pool WHERE newapi_user_id IN (${target_user_ids}) AND deleted=b'0';" docker exec game-staging-mysql sh -lc \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B "$1" -e "$2"' \ sh 'ruoyi-vue-pro' "$query" } before="$(aggregate)" mysql_error="$(mktemp)" set +e { printf 'START TRANSACTION;\n' cat "$sql_file" printf '\nCOMMIT;\n' } | docker exec -i game-staging-mysql sh -lc \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B "$1"' sh 'ruoyi-vue-pro' \ >/dev/null 2>"$mysql_error" mysql_rc="${PIPESTATUS[1]}" set -e if [ "$mysql_rc" -ne 0 ]; then # mysql stderr 可能回显含 token 的 SQL,仅保留返回码并让临时错误文件随 EXIT 清理。 printf 'staging MySQL 事务导入失败 rc=%s\n' "$mysql_rc" >&2 exit "$mysql_rc" fi rm -f "$mysql_error" mysql_error='' after="$(aggregate)" printf 'BEFORE\t%s\n' "$before" printf 'AFTER\t%s\n' "$after" REMOTE )" remote_script_base64="$(printf '%s' "$remote_script" | base64 | tr -d '\n')" remote_command="bash -c 'script_b64=\$1; shift; eval \"\$(printf %s \"\$script_b64\" | base64 -d)\"' _ '$remote_script_base64' '$source_sql_sha256' '$target_user_ids'" if ! "$SSH_BIN" -o ProxyCommand=none "$NEWAPI_POOL_IMPORT_HOST" "$remote_command" \ <"$NEWAPI_POOL_SQL_FILE" >"$remote_output_file"; then die '远端事务导入失败;未签发通过证据' fi before_line="$(awk -F '\t' '$1=="BEFORE" {print; count++} END {exit count!=1}' "$remote_output_file")" || \ die '远端缺唯一 BEFORE 聚合' after_line="$(awk -F '\t' '$1=="AFTER" {print; count++} END {exit count!=1}' "$remote_output_file")" || \ die '远端缺唯一 AFTER 聚合' IFS=$'\t' read -r _ before_total before_free before_claimed before_invalid before_global_free <<<"$before_line" IFS=$'\t' read -r _ after_total after_free after_claimed after_invalid after_global_free <<<"$after_line" for value in "$before_total" "$before_free" "$before_claimed" "$before_invalid" "$before_global_free" \ "$after_total" "$after_free" "$after_claimed" "$after_invalid" "$after_global_free"; do [[ "$value" =~ ^[0-9]+$ ]] || die '远端聚合包含非整数' done [ "$after_total" -eq 4 ] || die "导入后四个目标 user id 未全部存在,actual=$after_total" [ $((after_free + after_claimed + after_invalid)) -eq 4 ] || die '导入后目标状态聚合不守恒' [ "$before_total" -le "$after_total" ] || die '导入后目标行数倒退' mkdir -p "$(dirname "$NEWAPI_POOL_IMPORT_EVIDENCE")" { printf 'schema=newapi-pool-import/1\n' printf 'captured_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf 'source_host=%s\n' "$source_hostname" printf 'target_host=100.64.0.7\n' printf 'database=%s\n' "$MYSQL_DATABASE" printf 'table=%s\n' "$POOL_TABLE" printf 'source_sql_sha256=%s\n' "$source_sql_sha256" printf 'source_row_count=%s\n' "$source_row_count" printf 'source_usernames=neice_051,neice_052,neice_053,neice_054\n' printf 'before_target_total=%s\n' "$before_total" printf 'before_target_free=%s\n' "$before_free" printf 'before_target_claimed=%s\n' "$before_claimed" printf 'before_target_invalid=%s\n' "$before_invalid" printf 'before_global_free=%s\n' "$before_global_free" printf 'after_target_total=%s\n' "$after_total" printf 'after_target_free=%s\n' "$after_free" printf 'after_target_claimed=%s\n' "$after_claimed" printf 'after_target_invalid=%s\n' "$after_invalid" printf 'after_global_free=%s\n' "$after_global_free" printf 'inserted_target_rows=%s\n' "$((after_total - before_total))" printf 'status=PASS\n' } >"$NEWAPI_POOL_IMPORT_EVIDENCE" chmod 400 "$NEWAPI_POOL_IMPORT_EVIDENCE" printf 'NEWAPI_POOL_IMPORT_PASS rows=4 sql_sha256=%s before=%s after=%s evidence=%s\n' \ "$source_sql_sha256" "$before_total" "$after_total" "$NEWAPI_POOL_IMPORT_EVIDENCE"