278 lines
12 KiB
Bash
Executable File
278 lines
12 KiB
Bash
Executable File
#!/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 <run-id> --rc-commit <40hex> --output <report.json>\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"
|