366 lines
16 KiB
Bash
Executable File
366 lines
16 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 STAGING_RELEASES_ROOT='/root/game-staging/releases'
|
|
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
|
|
# 测试钩子只替换进程、私钥和本地 fixture 根;生产 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:?}"
|
|
runtime_releases_root="${DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT:?}"
|
|
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'
|
|
runtime_releases_root="$STAGING_RELEASES_ROOT"
|
|
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_runtime_state_before="$output_dir/.account-report-runtime-before.$$"
|
|
tmp_runtime_state_after="$output_dir/.account-report-runtime-after.$$"
|
|
tmp_report="$output_dir/.account-report.$$"
|
|
tmp_signature="$output_dir/.account-report-signature.$$"
|
|
trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_runtime_state_before" "$tmp_runtime_state_after" "$tmp_report" "$tmp_signature"' EXIT
|
|
|
|
collect_runtime_state() {
|
|
local destination="$1" quoted_root remote_command
|
|
printf -v quoted_root '%q' "$runtime_releases_root"
|
|
remote_command="$(cat <<EOF
|
|
set -euo pipefail
|
|
releases_root=$quoted_root
|
|
active_pointer="\$releases_root/active"
|
|
|
|
# active 必须是指向固定 releases 根下 40 位 commit 目录的符号链接。
|
|
[ -L "\$active_pointer" ]
|
|
canonical_root="\$(readlink -f -- "\$releases_root")"
|
|
[ "\$canonical_root" = "\$releases_root" ]
|
|
release="\$(readlink -f -- "\$active_pointer")"
|
|
[ -d "\$release" ]
|
|
commit="\${release##*/}"
|
|
printf '%s' "\$commit" | grep -Eq '^[0-9a-f]{40}\$'
|
|
[ "\$release" = "\$releases_root/\$commit" ]
|
|
|
|
# RC_STATUS 只能是一条 active 终态,并且 commit、evidence 都必须回指当前 canonical release。
|
|
status_file="\$release/RC_STATUS"
|
|
[ -f "\$status_file" ] && [ ! -L "\$status_file" ]
|
|
[ "\$(awk 'NF { count++ } END { print count + 0 }' "\$status_file")" -eq 1 ]
|
|
IFS= read -r status_line <"\$status_file"
|
|
set -- \$status_line
|
|
[ "\$#" -eq 4 ] && [ "\$1" = active ]
|
|
status_commit="\${2#commit=}"
|
|
completed_at="\${3#at=}"
|
|
evidence="\${4#evidence=}"
|
|
[ "\$2" = "commit=\$status_commit" ] && [ "\$status_commit" = "\$commit" ]
|
|
[ "\$3" = "at=\$completed_at" ]
|
|
printf '%s' "\$completed_at" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\$'
|
|
[ "\$4" = "evidence=\$evidence" ]
|
|
|
|
evidence_name="\${evidence##*/}"
|
|
run_id="\${evidence_name#activation-}"
|
|
[ "\$evidence_name" = "activation-\$run_id" ]
|
|
printf '%s' "\$run_id" | grep -Eq '^[0-9]{8}T[0-9]{6}Z\$'
|
|
[ "\${evidence%/*}" = "\$release/evidence" ]
|
|
[ -d "\$evidence" ] && [ ! -L "\$evidence" ]
|
|
[ "\$(readlink -f -- "\$evidence")" = "\$evidence" ]
|
|
|
|
# activation.log 最后两个非空记录必须是该 release 的 PASS 与同一 evidence 回执。
|
|
activation_log="\$evidence/activation.log"
|
|
[ -f "\$activation_log" ] && [ ! -L "\$activation_log" ]
|
|
terminal_lines="\$(awk 'NF { previous=last; last=\$0 } END { print previous; print last }' "\$activation_log")"
|
|
pass_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '1p')"
|
|
evidence_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '2p')"
|
|
printf '%s\n' "\$pass_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
|
|
printf '%s\n' "\$evidence_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
|
|
[ "\${pass_line#*] }" = "RC_ACTIVATION_PASS commit=\$commit active=\$release" ]
|
|
[ "\${evidence_line#*] }" = "ACTIVATION_EVIDENCE=\$evidence" ]
|
|
|
|
printf 'DOGFOOD_RUNTIME_STATE_V1\t%s\t%s\t%s\t%s\t%s\n' \
|
|
"\$commit" "\$run_id" "\$completed_at" "\$release" "\$evidence"
|
|
EOF
|
|
)"
|
|
"$ssh_bin" \
|
|
-o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \
|
|
"$SSH_TARGET" "$remote_command" >"$destination" || die '固定 staging active/RC_STATUS/activation evidence 校验失败'
|
|
chmod 600 "$destination"
|
|
}
|
|
|
|
collect_runtime_state "$tmp_runtime_state_before"
|
|
if [ "$(awk 'END { print NR + 0 }' "$tmp_runtime_state_before")" -ne 1 ]; then
|
|
die 'staging 运行态观测输出行数非法'
|
|
fi
|
|
IFS=$'\t' read -r state_schema observed_rc_commit observed_activation_run_id \
|
|
observed_completed_at observed_release observed_evidence <"$tmp_runtime_state_before"
|
|
[ "$state_schema" = DOGFOOD_RUNTIME_STATE_V1 ] || die 'staging 运行态观测 schema 非法'
|
|
[ "$observed_rc_commit" = "$rc_commit" ] || die 'expected rcCommit 与 staging active commit 漂移'
|
|
[ "$observed_activation_run_id" = "$activation_run_id" ] || die 'expected activation run-id 与 staging 事实漂移'
|
|
|
|
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"
|
|
collect_runtime_state "$tmp_runtime_state_after"
|
|
cmp -s "$tmp_runtime_state_before" "$tmp_runtime_state_after" || \
|
|
die '账号采集期间 staging active/activation evidence 发生漂移'
|
|
captured_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
|
|
|
|
python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$observed_activation_run_id" \
|
|
"$observed_rc_commit" "$captured_at" "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" \
|
|
"$observed_completed_at" "$observed_release" "$observed_evidence" <<'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,
|
|
activation_completed_at, active_release, activation_evidence,
|
|
) = 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,
|
|
"activationStatus": "PASS",
|
|
"activationCompletedAt": activation_completed_at,
|
|
"activeReleaseRef": active_release,
|
|
"activationEvidenceRef": activation_evidence,
|
|
"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"
|