#!/usr/bin/env bash # 在 mini-infra 信任边界内采集 staging 账号与额度池快照,并生成 Ed25519 签名证据。 set -euo pipefail umask 077 readonly EXPECTED_SIGNER_HOST='minione-ubuntu-infra' readonly SSH_TARGET='root@100.64.0.7' readonly SIGNER_ISSUER='r1-mini-infra' readonly SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem' readonly REPORT_SCHEMA='dogfood-account-database-report/2' readonly QUERY_ID='dogfood-account-readiness-v2' readonly QUERY_SCHEMA='dogfood-account-readiness-row/2' readonly NEWAPI_PG_CONTAINER='infra-postgres' readonly NEWAPI_PG_DATABASE='new-api' readonly NEWAPI_PG_USER='root' die() { printf 'ACCOUNT_REPORT_REFUSED %s\n' "$*" >&2 exit 1 } usage() { printf '用法:%s --activation-run-id --rc-commit <40hex> --output \n' "$0" >&2 exit 2 } activation_run_id='' rc_commit='' output='' while [ "$#" -gt 0 ]; do case "$1" in --activation-run-id) [ "$#" -ge 2 ] || usage; activation_run_id="$2"; shift 2 ;; --rc-commit) [ "$#" -ge 2 ] || usage; rc_commit="$2"; shift 2 ;; --output) [ "$#" -ge 2 ] || usage; output="$2"; shift 2 ;; *) usage ;; esac done [[ "$activation_run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die 'activation run-id 非法' [[ "$rc_commit" =~ ^[0-9a-f]{40}$ ]] || die 'rcCommit 必须是完整 40 位小写哈希' [ -n "$output" ] || usage [ ! -L "$output" ] && [ ! -e "$output" ] || die '输出报告已存在或是符号链接,拒绝覆盖' [ ! -L "$output.sig" ] && [ ! -e "$output.sig" ] || die '输出签名已存在或是符号链接,拒绝覆盖' output_dir="$(cd "$(dirname "$output")" && pwd -P)" || die '输出目录不存在' output="$output_dir/$(basename "$output")" test_mode="${DOGFOOD_COLLECTOR_TEST_MODE:-0}" if [ "$test_mode" = 1 ]; then # 测试钩子只替换进程和私钥,SSH 目标、查询、issuer 与报告 schema 仍由生产常量固定。 ssh_bin="${DOGFOOD_COLLECTOR_TEST_SSH_BIN:?}" signer_key="${DOGFOOD_COLLECTOR_TEST_SIGNER_KEY:?}" openssl_bin="${DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN:?}" docker_bin="${DOGFOOD_COLLECTOR_TEST_DOCKER_BIN:?}" else [ "$(id -u)" = 0 ] || die '采集器必须以 mini-infra root signer 身份运行' [ "$(hostname -s)" = "$EXPECTED_SIGNER_HOST" ] || die '采集器只能在固定 mini-infra signer 主机运行' ssh_bin='/usr/bin/ssh' signer_key="$SIGNER_PRIVATE_KEY" openssl_bin='/usr/bin/openssl' docker_bin='/usr/bin/docker' fi [ -x "$ssh_bin" ] || die '固定 SSH 客户端不可执行' [ -x "$openssl_bin" ] || die 'OpenSSL 客户端不可执行' [ -x "$docker_bin" ] || die '固定 Docker 客户端不可执行' [ -f "$signer_key" ] && [ ! -L "$signer_key" ] || die '固定 signer 私钥缺失或不是普通文件' [ "$(stat -c '%a' "$signer_key" 2>/dev/null || stat -f '%Lp' "$signer_key")" = 600 ] || \ die 'signer 私钥权限必须为 0600' if [ "$test_mode" != 1 ]; then [ "$(realpath "$signer_key")" = "$SIGNER_PRIVATE_KEY" ] || die 'signer 私钥 canonical 路径漂移' [ "$(stat -c '%u' "$signer_key")" = 0 ] || die 'signer 私钥必须属于 root' fi # 查询只读取 staging MySQL 的账号和池表;不选择 newapi_token_key、手机号、密码或 OAuth token。 readonly FIXED_QUERY="$(cat <<'SQL' SET SESSION TRANSACTION READ ONLY; START TRANSACTION READ ONLY; SELECT 'ACCOUNT', p.id, p.username, IF(p.creator_flag=1,'creator','player'), IF(p.status=0,'ACTIVE','DISABLED'), q.id, q.status, q.grant_quota, q.newapi_user_id, q.newapi_token_id FROM game_player p JOIN newapi_quota_pool q ON q.claimed_by_game_player_id=p.id AND q.deleted=b'0' WHERE p.username IN ( 'dogfoodc01','dogfoodc02','dogfoodc03','dogfoodc04','dogfoodc05', 'dogfoodc06','dogfoodc07','dogfoodc08','dogfoodc09','dogfoodc10', 'dogfoodp01','dogfoodp02','dogfoodp03','dogfoodp04','dogfoodp05', 'dogfoodp06','dogfoodp07','dogfoodp08','dogfoodp09','dogfoodp10', 'dogfoodp11','dogfoodp12','dogfoodp13','dogfoodp14','dogfoodp15', 'dogfoodp16','dogfoodp17','dogfoodp18','dogfoodp19','dogfoodp20', 'dogfoodp21','dogfoodp22','dogfoodp23','dogfoodp24','dogfoodp25', 'dogfoodp26','dogfoodp27','dogfoodp28','dogfoodp29','dogfoodp30' ) AND p.deleted=b'0' UNION ALL SELECT 'RESERVE', q.id, q.newapi_user_id, q.newapi_token_id, q.status, q.grant_quota, '', '', '', '' FROM newapi_quota_pool q WHERE q.status='FREE' AND q.deleted=b'0' ORDER BY 1, 3; COMMIT; SQL )" # new-api PostgreSQL 是 token 可用性权威源;固定查询不读取 token key、access token 或密码。 readonly FIXED_NEWAPI_QUERY="$(cat <<'SQL' BEGIN TRANSACTION READ ONLY; SELECT 'TOKEN', u.id, u.username, t.id, COALESCE(t.status, 0), CASE WHEN t.unlimited_quota IS FALSE THEN 0 WHEN t.unlimited_quota IS TRUE THEN 1 ELSE -1 END, COALESCE(t.expired_time, 0), COALESCE(t.remain_quota, 0) FROM users u JOIN tokens t ON t.user_id=u.id WHERE u.username ~ '^neice_[0-9]{3}$' ORDER BY u.id, t.id; COMMIT; SQL )" tmp_rows="$output_dir/.account-report-rows.$$" tmp_newapi_rows="$output_dir/.account-report-newapi-rows.$$" tmp_report="$output_dir/.account-report.$$" tmp_signature="$output_dir/.account-report-signature.$$" trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$tmp_signature"' EXIT remote_mysql_command='set -euo pipefail; . /root/huijing-dev.env; : "${MYSQL_ROOT_PASSWORD:?}"; docker exec -i game-staging-mysql sh -lc '\''MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro'\''' printf '%s\n' "$FIXED_QUERY" | "$ssh_bin" \ -o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \ "$SSH_TARGET" "$remote_mysql_command" >"$tmp_rows" || die '固定 staging MySQL 只读查询失败' chmod 600 "$tmp_rows" printf '%s\n' "$FIXED_NEWAPI_QUERY" | "$docker_bin" exec -i "$NEWAPI_PG_CONTAINER" sh -lc \ 'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -U root -d new-api -q -t -A -F "|" -v ON_ERROR_STOP=1' \ >"$tmp_newapi_rows" || die '固定 new-api PostgreSQL 只读查询失败' chmod 600 "$tmp_newapi_rows" captured_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$activation_run_id" "$rc_commit" "$captured_at" \ "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" <<'PY' import json import re import sys from pathlib import Path rows_path, newapi_rows_path, report_path = map(Path, sys.argv[1:4]) activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema = sys.argv[4:] expected_roles = { **{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)}, **{f"dogfoodp{index:02d}": "player" for index in range(1, 31)}, } accounts = [] reserves = [] for line_number, raw in enumerate(rows_path.read_text(encoding="utf-8").splitlines(), 1): fields = raw.split("\t") if len(fields) != 10: raise SystemExit(f"MySQL 固定查询第 {line_number} 行字段数不是 10") if fields[0] == "ACCOUNT": _, account_id, username, role, account_status, pool_id, quota_status, grant, newapi_user, newapi_token = fields accounts.append({ "id": int(account_id), "username": username, "role": role, "accountStatus": account_status, "quotaPoolId": int(pool_id), "quotaStatus": quota_status, "grantQuota": int(grant), "newapiUserId": int(newapi_user), "newapiTokenId": int(newapi_token), }) elif fields[0] == "RESERVE": _, pool_id, newapi_user, newapi_token, quota_status, grant, *_ = fields reserves.append({ "poolId": int(pool_id), "newapiUserId": int(newapi_user), "newapiTokenId": int(newapi_token), "quotaStatus": quota_status, "grantQuota": int(grant), }) else: raise SystemExit(f"MySQL 固定查询第 {line_number} 行类型非法") by_username = {item["username"]: item for item in accounts} if len(accounts) != 40 or set(by_username) != set(expected_roles): raise SystemExit("固定查询必须精确返回 40 个批准账号") if any( item["role"] != expected_roles[item["username"]] or item["accountStatus"] != "ACTIVE" or item["quotaStatus"] != "CLAIMED" or min( item["id"], item["quotaPoolId"], item["grantQuota"], item["newapiUserId"], item["newapiTokenId"], ) <= 0 for item in accounts ): raise SystemExit("40 个批准账号的角色、状态或 CLAIMED 额度非法") if len(reserves) != 12 or any( item["quotaStatus"] != "FREE" or min( item["poolId"], item["grantQuota"], item["newapiUserId"], item["newapiTokenId"], ) <= 0 for item in reserves ): raise SystemExit("固定查询必须精确返回 12 条可用 FREE 预留额度") pool_ids = [item["quotaPoolId"] for item in accounts] + [item["poolId"] for item in reserves] account_ids = [item["id"] for item in accounts] newapi_user_ids = [item["newapiUserId"] for item in accounts + reserves] newapi_token_ids = [item["newapiTokenId"] for item in accounts + reserves] if len(account_ids) != len(set(account_ids)) or len(pool_ids) != len(set(pool_ids)) \ or len(newapi_user_ids) != len(set(newapi_user_ids)) \ or len(newapi_token_ids) != len(set(newapi_token_ids)): raise SystemExit("账号与预留额度存在重复身份") # 以 staging 池表中的 user/token 双 ID 为锚,要求 PostgreSQL 权威记录一对一命中。 token_rows = [] for line_number, raw in enumerate(newapi_rows_path.read_text(encoding="utf-8").splitlines(), 1): fields = raw.split("|") if len(fields) != 8 or fields[0] != "TOKEN": raise SystemExit(f"new-api 固定查询第 {line_number} 行 schema 非法") _, user_id, username, token_id, status, unlimited, expired, remain = fields token_rows.append({ "newapiUserId": int(user_id), "newapiUsername": username, "newapiTokenId": int(token_id), "status": int(status), "unlimited": int(unlimited), "expired": int(expired), "remain": int(remain), }) expected_items = accounts + reserves expected_user_ids = {item["newapiUserId"] for item in expected_items} expected_token_ids = {item["newapiTokenId"] for item in expected_items} for item in expected_items: user_matches = [row for row in token_rows if row["newapiUserId"] == item["newapiUserId"]] token_matches = [row for row in token_rows if row["newapiTokenId"] == item["newapiTokenId"]] if len(user_matches) != 1 or len(token_matches) != 1 or user_matches[0] is not token_matches[0]: raise SystemExit("staging 池表与 new-api user/token IDs 未唯一匹配") token = user_matches[0] if not re.fullmatch(r"neice_[0-9]{3}", token["newapiUsername"]): raise SystemExit("new-api 用户名不符合 canonical neice_NNN") if token["status"] != 1 or token["unlimited"] != 0 \ or token["expired"] != -1 or token["remain"] <= 0: raise SystemExit("new-api token 当前不可用") item.update({ "newapiUsername": token["newapiUsername"], "tokenStatus": "ENABLED", "tokenRemainQuota": token["remain"], "tokenUnlimitedQuota": False, "tokenExpiredTime": -1, }) relevant_rows = [ row for row in token_rows if row["newapiUserId"] in expected_user_ids or row["newapiTokenId"] in expected_token_ids ] if len(relevant_rows) != len(expected_items) \ or len({row["newapiUsername"] for row in relevant_rows}) != len(expected_items): raise SystemExit("new-api 权威查询存在重复 user/token 行或用户名") report = { "schema": schema, "issuer": issuer, "activationRunId": activation_run_id, "rcCommit": rc_commit, "capturedAt": captured_at, "queryId": query_id, "querySchema": query_schema, "accounts": sorted(accounts, key=lambda item: item["username"]), "reserves": sorted(reserves, key=lambda item: item["poolId"]), } Path(report_path).write_text( json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) PY chmod 600 "$tmp_report" "$openssl_bin" pkeyutl -sign -inkey "$signer_key" -rawin \ -in "$tmp_report" -out "$tmp_signature" >/dev/null 2>&1 || die '账号报告签名失败' chmod 600 "$tmp_signature" # 先放签名、最后原子发布报告;消费者看见报告时,配套签名已经存在。 mv "$tmp_signature" "$output.sig" mv "$tmp_report" "$output" printf 'ACCOUNT_REPORT_CREATED report=%s signature=%s issuer=%s\n' "$output" "$output.sig" "$SIGNER_ISSUER"