From 5ad0d26daaf6e0bee0e2c57390843f6c8d6c0e32 Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 23 Jul 2026 02:30:36 -0700 Subject: [PATCH] fix(release): pin dogfood trust boundaries --- deploy/activate-staging-rc.sh | 151 +++++++--- deploy/collect-dogfood-account-report.sh | 277 ++++++++++++++++++ deploy/dogfood-ops/README.md | 91 +++++- deploy/dogfood-ops/dogfood_ops.py | 158 ++++++++-- deploy/dogfood-ops/tests/test_dogfood_ops.py | 227 ++++++++++++-- deploy/prepare-staging-rc.sh | 2 + .../test-collect-dogfood-account-report.sh | 169 +++++++++++ deploy/tests/test-staging-rc.sh | 245 +++++++++++++--- deploy/trust/r1-attestation-ed25519.pub | 3 + .../project/api/ProjectVersionApiImpl.java | 2 + .../version/GameVersionServiceImpl.java | 26 +- .../api/ProjectVersionApiImplDogfoodTest.java | 24 +- .../version/GameVersionServiceImplTest.java | 51 +++- 13 files changed, 1280 insertions(+), 146 deletions(-) create mode 100755 deploy/collect-dogfood-account-report.sh create mode 100755 deploy/tests/test-collect-dogfood-account-report.sh create mode 100644 deploy/trust/r1-attestation-ed25519.pub diff --git a/deploy/activate-staging-rc.sh b/deploy/activate-staging-rc.sh index ab63f648..17217e7b 100755 --- a/deploy/activate-staging-rc.sh +++ b/deploy/activate-staging-rc.sh @@ -8,8 +8,6 @@ RC_HOST="${RC_HOST:-root@100.64.0.7}" RC_ROOT="${RC_ROOT:-/root/game-staging/releases}" GENERATION_ATTESTATION_ROOT="${GENERATION_ATTESTATION_ROOT:-/root/game-staging/attestations}" GENERATION_GATE_RUN_ID="${GENERATION_GATE_RUN_ID:-}" -GENERATION_ATTESTATION_EXPECTED_ISSUER="${GENERATION_ATTESTATION_EXPECTED_ISSUER:-}" -GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="${GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE:-}" BOOTSTRAP_ADMIN_TOKEN_FILE="${BOOTSTRAP_ADMIN_TOKEN_FILE:-}" ACTIVATION_TIMEOUT_SEC="${ACTIVATION_TIMEOUT_SEC:-180}" ACTIVATION_MODE="activate" @@ -67,14 +65,6 @@ if [[ ! "$GENERATION_GATE_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then printf 'GENERATION_GATE_RUN_ID 必填,且只能包含安全的 run-id 字符。\n' >&2 exit 2 fi -if [[ ! "$GENERATION_ATTESTATION_EXPECTED_ISSUER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then - printf 'GENERATION_ATTESTATION_EXPECTED_ISSUER 必填,且只能包含安全的签发者标识。\n' >&2 - exit 2 -fi -if [[ ! "$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then - printf 'GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE 必填,且必须是发布机上的安全绝对路径。\n' >&2 - exit 2 -fi if [ "$ACTIVATION_MODE" = activate ] && [[ ! "$BOOTSTRAP_ADMIN_TOKEN_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then printf 'BOOTSTRAP_ADMIN_TOKEN_FILE 必填,且必须是 mini-desktop 上的安全绝对路径。\n' >&2 exit 2 @@ -90,9 +80,7 @@ fi "$SSH_BIN" -o ProxyCommand=none "$RC_HOST" bash -s -- \ "$RC_COMMIT" "$RC_ROOT" "$ACTIVATION_TIMEOUT_SEC" "$STORAGE_AUDIT_REF" \ "$GENERATION_GATE_EVIDENCE_REF" "$GENERATION_ATTESTATION_ROOT" "$ACTIVATION_MODE" \ - "$BOOTSTRAP_ADMIN_TOKEN_FILE" "$GENERATION_GATE_RUN_ID" \ - "$GENERATION_ATTESTATION_EXPECTED_ISSUER" \ - "$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" <<'REMOTE_SCRIPT' + "$BOOTSTRAP_ADMIN_TOKEN_FILE" "$GENERATION_GATE_RUN_ID" <<'REMOTE_SCRIPT' set -Eeuo pipefail commit="$1" @@ -104,8 +92,6 @@ attestation_root="$6" activation_mode="$7" bootstrap_admin_token_file="$8" expected_generation_gate_run_id="$9" -expected_attestation_issuer="${10}" -trusted_attestation_public_key_file="${11}" release="$root/$commit" active_pointer="$root/active" stable_prefix="gda-staging" @@ -128,9 +114,15 @@ account_provision_run_id="${run_id}-dogfood" account_provision_cleanup_manifest="$evidence/account-provision/provision-cleanup.tsv" runtime_cleanup_rc=not-run maintenance_chain="GDA_STAGING_MAINT" +maintenance_release_guard_chain="GDA_STAGING_RELEASE_GUARD" maintenance_ports="48080,4173,4174,8300,9501" maintenance_gate_active=0 IPTABLES_BIN="${IPTABLES_BIN:-iptables}" +# R1 信任根是发布策略常量,调用者和环境变量均不能替换。 +expected_attestation_issuer="r1-mini-infra" +trusted_attestation_public_key_sha256="e3cc17cd86805b053f61450b5c2c6e4e168bb01dbf1e83840124290d1196a304" +trusted_attestation_public_key_file="/root/game-staging/trust/r1-attestation-ed25519.pub" +release_attestation_public_key_file="$release/deploy/trust/r1-attestation-ed25519.pub" umask 077 if [ "$activation_mode" = activate ]; then @@ -171,11 +163,29 @@ unit_name() { } stop_stack() { - local prefix="$1" service unit + local prefix="$1" backend_port="$2" studio_port="$3" admin_port="$4" agent_port="$5" worker_port="$6" + local service unit port for service in backend studio admin cheap-agent cheap-worker; do unit="$(unit_name "$prefix" "$service")" - systemctl stop "$unit" >/dev/null 2>&1 || true - systemctl reset-failed "$unit" >/dev/null 2>&1 || true + if systemctl is-active --quiet "$unit"; then + systemctl stop "$unit" >/dev/null 2>&1 || { + log_step "停止服务失败 unit=$unit" + return 1 + } + fi + done + for service in backend studio admin cheap-agent cheap-worker; do + unit="$(unit_name "$prefix" "$service")" + if systemctl is-active --quiet "$unit"; then + log_step "停栈复核失败,unit 仍 active unit=$unit" + return 1 + fi + done + for port in "$backend_port" "$studio_port" "$admin_port" "$agent_port" "$worker_port"; do + if port_listening "$port"; then + log_step "停栈复核失败,端口仍监听 port=$port" + return 1 + fi done } @@ -185,6 +195,12 @@ maintenance_rule_present() { && "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1 } +maintenance_release_guard_present() { + "$IPTABLES_BIN" -w 5 -C "$maintenance_release_guard_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT >/dev/null 2>&1 \ + && "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_release_guard_chain" >/dev/null 2>&1 +} + install_maintenance_gate() { command -v "$IPTABLES_BIN" >/dev/null 2>&1 || { log_step "缺 iptables,无法封锁正式入口" @@ -203,7 +219,7 @@ install_maintenance_gate() { if ! "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; then "$IPTABLES_BIN" -w 5 -I INPUT 1 ! -i lo -j "$maintenance_chain" || return 1 fi - # INPUT 跳转一旦存在,本次事务就必须负责解除;即使后续复核失败,回滚也不能遗留维护门。 + # INPUT 跳转一旦存在就纳入事务状态;旧栈完整恢复后解除,恢复失败则保留封锁。 maintenance_gate_active=1 maintenance_rule_present || { log_step "正式入口维护门安装后复核失败" @@ -216,19 +232,38 @@ install_maintenance_gate() { release_maintenance_gate() { [ "$maintenance_gate_active" -eq 1 ] || return 0 - maintenance_rule_present || { - log_step "维护门状态漂移,拒绝按成功路径开放入口" - return 1 - } + + # 先安装独立拒绝护栏。此后即使链清理失败,正式端口仍保持封锁。 + if ! maintenance_release_guard_present; then + if ! "$IPTABLES_BIN" -w 5 -nL "$maintenance_release_guard_chain" >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -N "$maintenance_release_guard_chain" || return 1 + fi + if ! "$IPTABLES_BIN" -w 5 -C "$maintenance_release_guard_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -A "$maintenance_release_guard_chain" -p tcp -m multiport \ + --dports "$maintenance_ports" -j REJECT || return 1 + fi + if ! "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_release_guard_chain" >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -I INPUT 1 ! -i lo -j "$maintenance_release_guard_chain" || return 1 + fi + fi + maintenance_release_guard_present || return 1 + while "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; do "$IPTABLES_BIN" -w 5 -D INPUT ! -i lo -j "$maintenance_chain" || return 1 done - "$IPTABLES_BIN" -w 5 -F "$maintenance_chain" || return 1 - "$IPTABLES_BIN" -w 5 -X "$maintenance_chain" || return 1 - maintenance_gate_active=0 - printf 'maintenance_gate=OPENED chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \ + if "$IPTABLES_BIN" -w 5 -nL "$maintenance_chain" >/dev/null 2>&1; then + "$IPTABLES_BIN" -w 5 -F "$maintenance_chain" || return 1 + "$IPTABLES_BIN" -w 5 -X "$maintenance_chain" || return 1 + fi + maintenance_release_guard_present || return 1 + printf 'maintenance_gate=OPENING chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \ >"$evidence/maintenance-gate.status" - log_step "正式入口维护门 OPENED" + + # 最后一条可能失败的命令才真正开放入口;成功后不得再触发数据库补偿。 + "$IPTABLES_BIN" -w 5 -D INPUT ! -i lo -j "$maintenance_release_guard_chain" || return 1 + maintenance_gate_active=0 + return 0 } capture_journals() { @@ -439,7 +474,8 @@ valid_prepared_release() { verify_generation_gate_evidence() { local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash local source_path signature_path canonical_root canonical_source canonical_signature component walk mode - local public_key_mode public_key_mode_decimal canonical_public_key + local public_key_mode canonical_public_key public_key_hash + local trusted_parent trusted_parent_mode trusted_parent_mode_decimal hash_and_path="${ref#sha256:}" expected_hash="${hash_and_path%%:*}" relative_path="${hash_and_path#*:}" @@ -510,11 +546,41 @@ verify_generation_gate_evidence() { } public_key_mode="$(stat -c '%a' "$trusted_attestation_public_key_file" 2>/dev/null \ || stat -f '%Lp' "$trusted_attestation_public_key_file")" - public_key_mode_decimal=$((8#$public_key_mode)) - if (( (public_key_mode_decimal & 022) != 0 )); then - log_step "R1 信任公钥不得允许 group/other 写入 actual=$public_key_mode" + if [ "$public_key_mode" != 644 ]; then + log_step "R1 信任公钥权限必须固定为 0644 actual=$public_key_mode" return 1 fi + [ "$(stat -c '%u' "$trusted_attestation_public_key_file" 2>/dev/null \ + || stat -f '%u' "$trusted_attestation_public_key_file")" = 0 ] || { + log_step "R1 信任公钥必须由 root 持有" + return 1 + } + trusted_parent="$(dirname "$trusted_attestation_public_key_file")" + [ -d "$trusted_parent" ] && [ ! -L "$trusted_parent" ] || return 1 + [ "$(realpath "$trusted_parent")" = "$trusted_parent" ] || return 1 + [ "$(stat -c '%u' "$trusted_parent" 2>/dev/null || stat -f '%u' "$trusted_parent")" = 0 ] || { + log_step "R1 信任公钥父目录必须由 root 持有" + return 1 + } + trusted_parent_mode="$(stat -c '%a' "$trusted_parent" 2>/dev/null || stat -f '%Lp' "$trusted_parent")" + trusted_parent_mode_decimal=$((8#$trusted_parent_mode)) + if (( (trusted_parent_mode_decimal & 022) != 0 )); then + log_step "R1 信任公钥父目录不得允许 group/other 写入 actual=$trusted_parent_mode" + return 1 + fi + [ -f "$release_attestation_public_key_file" ] && [ ! -L "$release_attestation_public_key_file" ] || { + log_step "commit-pinned RC 缺仓内 R1 信任公钥" + return 1 + } + public_key_hash="$(sha256sum "$trusted_attestation_public_key_file" | awk '{print $1}')" + [ "$public_key_hash" = "$trusted_attestation_public_key_sha256" ] || { + log_step "R1 信任公钥 SHA-256 与固定信任根不一致" + return 1 + } + cmp -s "$trusted_attestation_public_key_file" "$release_attestation_public_key_file" || { + log_step "远端 R1 信任公钥与 commit-pinned RC 仓内公钥不一致" + return 1 + } openssl pkeyutl -verify -pubin -inkey "$trusted_attestation_public_key_file" -rawin \ -in "$source_path" -sigfile "$signature_path" >/dev/null 2>&1 || { log_step "生成门 detached signature 验证失败" @@ -649,7 +715,7 @@ JAVA restore_database_snapshot() { local dump="$evidence/pre-migration-ruoyi-vue-pro.sql.gz" [ -s "$dump" ] || return 1 - stop_stack "$stable_prefix" + stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 || return 1 docker exec game-staging-mysql sh -lc \ 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -e "DROP DATABASE IF EXISTS `ruoyi-vue-pro`"' || return 1 gzip -dc "$dump" | docker exec -i game-staging-mysql sh -lc \ @@ -677,7 +743,7 @@ failure_handler() { log_step "ACTIVATION FAIL rc=${rc},开始保留证据并恢复旧栈" capture_journals "$candidate_prefix" candidate-failed capture_journals "$stable_prefix" stable-failed - stop_stack "$candidate_prefix" + stop_stack "$candidate_prefix" 18080 14173 14174 18300 19501 cleanup_dogfood_provision_runtime runtime_cleanup_rc=$? printf 'runtime_cleanup_rc=%s\n' "$runtime_cleanup_rc" >"$evidence/account-provision-runtime-cleanup.status" @@ -693,7 +759,7 @@ failure_handler() { fi fi if [ "$switch_started" -eq 1 ]; then - stop_stack "$stable_prefix" + stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then log_step "恢复旧版本服务 target=$previous" rollback_rc=0 @@ -717,6 +783,9 @@ failure_handler() { if [ "$pointer_changed" -eq 1 ]; then restore_active_pointer "$previous" fi + if [ "$switch_started" -eq 0 ] && [ "$maintenance_gate_active" -eq 1 ]; then + release_maintenance_gate || rc=74 + fi if [ "$bootstrap_token_validated" -eq 1 ]; then log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销" fi @@ -807,15 +876,13 @@ done start_candidate_probe "$release" candidate "$candidate_prefix" 14173 14174 18300 19501 capture_journals "$candidate_prefix" candidate-ready -log_step "候选非数据面探针全绿,停止旧栈后执行独立 Flyway 迁移" -switch_started=1 +log_step "候选非数据面探针全绿,先封锁入口,再严格停止旧栈" capture_journals "$stable_prefix" pre-switch-old -stop_stack "$stable_prefix" - -# 从停写快照前到账号事务、审核轮换和 smoke 全部完成,正式端口只允许本机 loopback 访问。 install_maintenance_gate +switch_started=1 +stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 -# 旧栈停写后再生成补偿快照,避免候选探针期间的新写入在失败恢复时丢失。 +# 维护门已封锁外部写入,五服务与五端口也已严格停净,才允许生成补偿快照。 log_step "迁移前数据库快照" docker inspect game-staging-mysql >/dev/null docker exec game-staging-mysql sh -lc \ @@ -970,7 +1037,7 @@ pointer_changed=1 printf 'active commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$evidence" \ >"$release/RC_STATUS" -stop_stack "$candidate_prefix" +stop_stack "$candidate_prefix" 18080 14173 14174 18300 19501 rm -f -- "$account_provision_cleanup_manifest" chmod -R go-rwx "$evidence" "$release/RC_STATUS" delete_bootstrap_token diff --git a/deploy/collect-dogfood-account-report.sh b/deploy/collect-dogfood-account-report.sh new file mode 100755 index 00000000..96439aa9 --- /dev/null +++ b/deploy/collect-dogfood-account-report.sh @@ -0,0 +1,277 @@ +#!/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" diff --git a/deploy/dogfood-ops/README.md b/deploy/dogfood-ops/README.md index 5a6ecaea..e0e2eded 100644 --- a/deploy/dogfood-ops/README.md +++ b/deploy/dogfood-ops/README.md @@ -12,7 +12,10 @@ 初始化只接受这两个名称。`startedAt` 由工具读取 UTC 当前时间写入,CLI 不提供回填参数。重复初始化会校验既有 `manifest.json` 后返回,不覆盖任何已填写证据。Wave1 初始化前会重跑 Wave0 收口门,并把 Wave0 的 `close-result.json` 哈希、最后一份冻结清单哈希和 `closedAt` 固化进 Wave1 manifest;任一绑定变化、P0、确认发布假阳或超过 4 小时待审积压都会拒绝 Wave1。 ```bash -deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0 +deploy/dogfood-ops/init-wave.sh \ + --root /受控证据根 \ + --wave wave0 \ + --rc-commit ``` 生成目录中的 JSON 带 `_template: true`,表示它还不是证据。填写完成后删除该字段。附件统一放在波次目录内的 `attachments/`;JSON/TSV 只写相对路径。绝对路径、`..` 路径、符号链接和不存在的附件都会被拒绝。 @@ -23,7 +26,10 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0 ```bash # 1. 初始化并填写同一波次的全部结构化证据。 -deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0 +deploy/dogfood-ops/init-wave.sh \ + --root /受控证据根 \ + --wave wave0 \ + --rc-commit # 2. 先跑开波门;模板、缺字段、阈值不达标都返回非零。 deploy/dogfood-ops/check-readiness.sh --wave-dir /受控证据根/wave0 @@ -38,7 +44,10 @@ deploy/dogfood-ops/freeze-evidence.sh \ deploy/dogfood-ops/check-close.sh --wave-dir /受控证据根/wave0 # 5. 只有 Wave0 收口回执仍可复核时,才允许初始化 Wave1。 -deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1 +deploy/dogfood-ops/init-wave.sh \ + --root /受控证据根 \ + --wave wave1 \ + --rc-commit ``` `GATE_PASS` 只表示本地证据在当前规则下可对账,不替代人工判断,不代表 staging 已部署、告警已送达或波次已完成。真实结论必须回到原始截图、网络记录、日志和数据库 ID 核验。 @@ -47,12 +56,12 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1 | 文件 | 必填内容 | |---|---| -| `manifest.json` | 固定波次、人数、持续时间和工具生成的 `startedAt`;Wave1 另绑定 Wave0 收口回执、最终冻结哈希和 `closedAt` | +| `manifest.json` | 固定波次、人数、持续时间、commit-pinned `rcCommit` 和工具生成的 `startedAt`;Wave1 另绑定 Wave0 收口回执、最终冻结哈希和 `closedAt` | | `rc.json` | 完整 40 位 commit、tracked 干净标志、`rcArtifactRef` 与实际 `rcArtifactSha256`,只证明部署候选 | | `config-identity.json` | 同一 RC commit、配置包路径、实际 SHA-256、采集时间 | | `snapshot.json` | MySQL 快照路径/哈希,以及非 staging/production 目标的隔离恢复 PASS、时间和报告 | | `storage-audit.json` | 对象存储审计报告、实际哈希、采集时间 | -| `accounts.json` | batchId、精确创作者/玩家计数、FREE 额度;wave0 至少 4,wave1 至少 12 | +| `accounts.json` | `/2`:绑定激活 run-id、签名数据库报告、报告哈希/采集时间,以及本波明确账号 ID+用户名 | | `role-permissions.json` | 创作者本账号允许、跨账号拒绝、审核员只审、审核员改配置拒绝、只读写拒绝;每项带截图 | | `main-chain.json` | 同一 project/version/game 的 `gameArtifactSha256`,分别绑定 APPROVED 审核记录、实际 runtime package 和主链阶段真实使用的数据库证据;完整覆盖 create→generate→preview→submit-review→approve→feed→play→telemetry | | `release-negatives.json` | DRAFT、REVIEWING、REJECTED、直调绕过、批准后产物漂移、下架后 feed/详情/深链八类拒绝证据 | @@ -71,6 +80,78 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1 所有时间使用带时区 ISO 8601,例如 `2026-07-22T08:00:00Z`。哈希必须是小写 64 位 SHA-256,并与附件内容一致。 +### 3.1 账号报告 `/2` + +账号报告不能手写。采集器必须在 mini-infra 以 root signer 身份运行。它先固定通过 `root@100.64.0.7` 查询 `game-staging-mysql/ruoyi-vue-pro`,在 MySQL `READ ONLY` 事务内取得 40 条 `CLAIMED` 和 12 条 `FREE` 池记录;再固定查询 mini-infra 本机 `infra-postgres/new-api`,从容器已有的 `POSTGRES_PASSWORD` 设置 `PGPASSWORD`,不把密码放入参数、日志或报告。每条池记录必须按 `newapiUserId + newapiTokenId` 唯一命中 canonical `neice_NNN` 用户/token,且 token 同时满足 `status=1`、`unlimited_quota=false`、`expired_time=-1`、`remain_quota>0`。任一禁用、耗尽、ID 漂移、重复行或多 token 匹配都会在签名前失败。 + +```bash +# 在可信工作站从指定 RC commit 导出脚本,不使用当前未提交工作树。 +set -euo pipefail +umask 077 +: "${RC_COMMIT:?请先设置 commit-pinned RC 的 40 位哈希}" +: "${ACTIVATION_RUN_ID:?请先设置激活 run-id}" +[[ "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]] +[[ "$ACTIVATION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] +PINNED_SCRIPT="$(mktemp)" +git show "${RC_COMMIT}:deploy/collect-dogfood-account-report.sh" >"$PINNED_SCRIPT" +chmod 700 "$PINNED_SCRIPT" +LOCAL_SHA="$(shasum -a 256 "$PINNED_SCRIPT" | awk '{print $1}')" + +# mini-infra 不要求存在项目仓库;先传入临时名,哈希一致后再原子安装到固定 signer 目录。 +REMOTE_INCOMING="/root/dogfood-r1-signer/collect-dogfood-account-report.sh.incoming-$RC_COMMIT" +scp -o BatchMode=yes -o StrictHostKeyChecking=yes \ + "$PINNED_SCRIPT" "root@100.64.0.8:$REMOTE_INCOMING" +REMOTE_SHA="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \ + "sha256sum '$REMOTE_INCOMING' | cut -d ' ' -f 1")" +[ "$LOCAL_SHA" = "$REMOTE_SHA" ] +ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \ + "install -o root -g root -m 700 '$REMOTE_INCOMING' /root/dogfood-r1-signer/collect-dogfood-account-report.sh.new && \ + mv -f /root/dogfood-r1-signer/collect-dogfood-account-report.sh.new /root/dogfood-r1-signer/collect-dogfood-account-report.sh && \ + rm -f '$REMOTE_INCOMING'" +rm -f "$PINNED_SCRIPT" + +# 在 mini-infra 运行固定安装件;报告和签名均留在 signer 主机的 0700 目录。 +ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \ + "install -d -o root -g root -m 700 /root/dogfood-account-reports && \ + /root/dogfood-r1-signer/collect-dogfood-account-report.sh \ + --activation-run-id '$ACTIVATION_RUN_ID' \ + --rc-commit '$RC_COMMIT' \ + --output /root/dogfood-account-reports/wave0-account-report.json" +``` + +采集器使用固定私钥 `/root/dogfood-r1-signer/r1-ed25519-private.pem`,生成报告及同名 `.sig`,两者权限均为 `0600`。报告包含账号身份、池绑定、`newapiUsername`、`tokenStatus`、`tokenRemainQuota`、`tokenUnlimitedQuota` 和 `tokenExpiredTime`,不输出 token key、手机号、密码或 OAuth token。通过严格主机密钥校验把两个文件取回可信工作站,再以 `0600` 安装进波次附件: + +```bash +TRANSFER_DIR="$(mktemp -d)" +chmod 700 "$TRANSFER_DIR" +scp -o BatchMode=yes -o StrictHostKeyChecking=yes \ + root@100.64.0.8:/root/dogfood-account-reports/wave0-account-report.json \ + root@100.64.0.8:/root/dogfood-account-reports/wave0-account-report.json.sig \ + "$TRANSFER_DIR/" +install -m 600 "$TRANSFER_DIR/wave0-account-report.json" /受控证据根/wave0/attachments/ +install -m 600 "$TRANSFER_DIR/wave0-account-report.json.sig" /受控证据根/wave0/attachments/ +rm -rf "$TRANSFER_DIR" +``` + +随后填写: + +```json +{ + "schema": "dogfood-accounts/2", + "activationRunId": "activation-20260723T010203Z", + "databaseReportRef": "attachments/wave0-account-report.json", + "databaseReportSignatureRef": "attachments/wave0-account-report.json.sig", + "databaseReportSha256": "<报告文件SHA-256>", + "databaseReportCapturedAt": "<报告内capturedAt原值>", + "selectedAccounts": [ + {"id": 1001, "username": "dogfoodc01"}, + {"id": 1101, "username": "dogfoodp01"} + ] +} +``` + +`selectedAccounts` 必须列出本波全部成员:Wave0 精确 3 名创作者和 5 名玩家,Wave1 精确 10 名创作者和 30 名玩家。证据门只使用仓内 `deploy/trust/r1-attestation-ed25519.pub` 验签,不读取 `accounts.json`、环境变量或命令行中的公钥路径。缺签、签名篡改、错误 issuer、固定查询身份漂移、RC 与 manifest 不一致、new-api user/token 身份重复、token 禁用/耗尽/unlimited/非永久有效都会失败关闭。 + ## 4. 首屏 P75 口径 采样环境固定为 mini-desktop、Chrome 146、`390x844@1`、Tailscale staging。每次禁用缓存后独立导航,从 navigation commit 计到首个可交互游戏帧;每条记录使用唯一 `navigationId` 和唯一原始文件,并标记 `cache=cold`、`source=/gstack@mini-desktop`。`collector` 必须包含 `tool=/gstack`、`host=mini-desktop` 和非空 `sessionId`,且与原始文件内身份一致;`collectedAt` 必须带时区、不早于波次开始且不在未来。热缓存另行报告,禁止写入 `performance.json`。 diff --git a/deploy/dogfood-ops/dogfood_ops.py b/deploy/dogfood-ops/dogfood_ops.py index 0cb7f386..685009b9 100755 --- a/deploy/dogfood-ops/dogfood_ops.py +++ b/deploy/dogfood-ops/dogfood_ops.py @@ -9,6 +9,8 @@ import hashlib import json import math import re +import stat +import subprocess import sys from datetime import date, datetime, timedelta, timezone from pathlib import Path @@ -27,6 +29,12 @@ RC_SCHEMA = "dogfood-rc/2" MAIN_CHAIN_SCHEMA = "dogfood-main-chain/2" PERFORMANCE_SCHEMA = "dogfood-first-frame-performance/2" FREEZE_SCHEMA = "dogfood-evidence-freeze/2" +ACCOUNT_REPORT_SCHEMA = "dogfood-account-database-report/2" +ACCOUNT_REPORT_ISSUER = "r1-mini-infra" +ACCOUNT_REPORT_QUERY_ID = "dogfood-account-readiness-v2" +ACCOUNT_REPORT_QUERY_SCHEMA = "dogfood-account-readiness-row/2" +ACCOUNT_REPORT_PUBLIC_KEY = Path(__file__).resolve().parents[1] / "trust" / "r1-attestation-ed25519.pub" +OPENSSL_BIN = Path("/opt/homebrew/bin/openssl") if sys.platform == "darwin" else Path("/usr/bin/openssl") TSV_TEMPLATES = { "oncall.tsv": "start_at\tend_at\tprimary\tbackup\tcontact\n", @@ -44,6 +52,7 @@ JSON_TEMPLATES: dict[str, dict[str, Any]] = { "schema": "dogfood-accounts/2", "activationRunId": None, "databaseReportRef": None, + "databaseReportSignatureRef": None, "databaseReportSha256": None, "databaseReportCapturedAt": None, "selectedAccounts": [], @@ -238,6 +247,43 @@ class EvidenceGate: return None return path + def verify_detached_signature(self, report_path: Path, signature_ref: Any) -> bool: + """只用仓内固定 R1 公钥验证账号报告,不接受调用方提供信任根。""" + signature_path = self.checked_ref(signature_ref, "accounts.database-report.signature") + if signature_path is None: + self.fail("accounts.database-report.signature", "账号数据库报告缺少 detached signature") + return False + for path, label in ((report_path, "报告"), (signature_path, "签名")): + try: + mode = stat.S_IMODE(path.stat().st_mode) + except OSError as exc: + self.fail("accounts.database-report.signature", f"账号{label}权限不可读:{exc}") + return False + if mode != 0o600: + self.fail("accounts.database-report.signature", f"账号{label}权限必须为 0600") + return False + if not ACCOUNT_REPORT_PUBLIC_KEY.is_file() or ACCOUNT_REPORT_PUBLIC_KEY.is_symlink(): + self.fail("accounts.database-report.signature", "仓内固定 R1 公钥缺失或不是普通文件") + return False + try: + result = subprocess.run( + [ + str(OPENSSL_BIN), "pkeyutl", "-verify", "-pubin", "-inkey", + str(ACCOUNT_REPORT_PUBLIC_KEY), "-rawin", "-in", str(report_path), + "-sigfile", str(signature_path), + ], + check=False, + capture_output=True, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + self.fail("accounts.database-report.signature", f"账号报告验签工具执行失败:{exc}") + return False + if result.returncode != 0: + self.fail("accounts.database-report.signature", "账号数据库报告 detached signature 验证失败") + return False + return True + def read_tsv(self, name: str, fields: list[str]) -> list[dict[str, str]] | None: """用标准库 TSV 解析器校验表头,禁止文本猜字段。""" path = self.wave_dir / name @@ -272,10 +318,14 @@ class EvidenceGate: if started_at > self.now: self.fail("manifest.started-at", "manifest.startedAt 不得晚于当前时间") self.started_at = started_at - expected_keys = {"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt"} + expected_keys = { + "schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt", "rcCommit", + } if wave == "wave1": expected_keys.update({"wave0CloseResultSha256", "wave0FinalFreezeSha256", "wave0ClosedAt"}) fixed_values_match = all(manifest.get(key) == value for key, value in WAVE_SCALES[wave].items()) + if not isinstance(manifest.get("rcCommit"), str) or not COMMIT_PATTERN.fullmatch(manifest["rcCommit"]): + self.fail("manifest.rc-commit", "manifest.rcCommit 必须是完整 40 位小写哈希") if set(manifest) != expected_keys or not fixed_values_match or self.wave_dir.name != wave: self.fail("manifest.identity", "波次目录名、身份或固定规模不匹配") return @@ -309,6 +359,8 @@ class EvidenceGate: if rc.get("repoClean") is not True: self.fail("rc.clean", "RC 必须来自 tracked 工作区干净构建") self.check_hash_ref(rc.get("rcArtifactRef"), rc.get("rcArtifactSha256"), "rc.artifact") + if self.manifest is not None and rc.get("commit") != self.manifest.get("rcCommit"): + self.fail("rc.manifest-commit", "rc.json commit 与 wave manifest 的 rcCommit 不一致") if config is not None: if self.checked_evidence_time(config.get("capturedAt"), "config.capturedAt") is None: self.fail("config.captured-at", "配置身份缺少带时区采集时间") @@ -360,6 +412,12 @@ class EvidenceGate: accounts.get("databaseReportSha256"), "accounts.database-report", ) + expected_account_keys = { + "schema", "activationRunId", "databaseReportRef", "databaseReportSignatureRef", + "databaseReportSha256", "databaseReportCapturedAt", "selectedAccounts", + } + if set(accounts) != expected_account_keys: + self.fail("accounts.fields", "accounts.json 字段必须精确匹配 dogfood-accounts/2") def checked_report_time(value: Any, context: str) -> datetime | None: """激活快照可早于波次开始,但不得陈旧超过两小时或晚于当前可信时钟。""" parsed = parse_timestamp(value) @@ -378,19 +436,30 @@ class EvidenceGate: declared_captured_at = checked_report_time( accounts.get("databaseReportCapturedAt"), "accounts.databaseReportCapturedAt") - if report_path is None: + if report_path is None or not self.verify_detached_signature( + report_path, accounts.get("databaseReportSignatureRef")): return try: report = json.loads(report_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: self.fail("accounts.database-report.invalid-json", f"账号数据库报告不是合法 JSON:{exc}") return - if not isinstance(report, dict) or report.get("schema") != "dogfood-account-database-report/1": + expected_report_keys = { + "schema", "issuer", "activationRunId", "rcCommit", "capturedAt", + "queryId", "querySchema", "accounts", "reserves", + } + if not isinstance(report, dict) or report.get("schema") != ACCOUNT_REPORT_SCHEMA: self.fail("accounts.database-report.schema", "账号数据库报告 schema 非法") return - if report.get("source") != "staging-mysql-readonly" \ - or not isinstance(report.get("queryId"), str) or not report["queryId"]: - self.fail("accounts.database-report.source", "账号报告必须声明 staging MySQL 只读来源和查询 ID") + if set(report) != expected_report_keys: + self.fail("accounts.database-report.fields", "账号数据库报告字段必须精确匹配固定 schema") + if report.get("issuer") != ACCOUNT_REPORT_ISSUER: + self.fail("accounts.database-report.issuer", "账号报告签发者必须为固定 r1-mini-infra") + if report.get("queryId") != ACCOUNT_REPORT_QUERY_ID \ + or report.get("querySchema") != ACCOUNT_REPORT_QUERY_SCHEMA: + self.fail("accounts.database-report.query", "账号报告必须绑定固定只读查询及行 schema") + if report.get("rcCommit") != self.manifest.get("rcCommit"): + self.fail("accounts.database-report.rc-commit", "账号报告 rcCommit 与 wave manifest 不一致") if report.get("activationRunId") != activation_run_id: self.fail("accounts.activation-run-id", "accounts.json 与数据库报告的 activation run-id 不一致") report_captured_at = checked_report_time( @@ -411,6 +480,9 @@ class EvidenceGate: usernames: set[str] = set() account_ids: set[int] = set() pool_ids: set[int] = set() + newapi_user_ids: set[int] = set() + newapi_token_ids: set[int] = set() + newapi_usernames: set[str] = set() for item in report_accounts: if not isinstance(item, dict): self.fail("accounts.database-report.accounts", "数据库报告存在非对象账号") @@ -418,6 +490,12 @@ class EvidenceGate: account_id = item.get("id") username = item.get("username") pool_id = item.get("quotaPoolId") + if set(item) != { + "id", "username", "role", "accountStatus", "quotaPoolId", "quotaStatus", + "grantQuota", "newapiUserId", "newapiTokenId", "newapiUsername", + "tokenStatus", "tokenRemainQuota", "tokenUnlimitedQuota", "tokenExpiredTime"}: + self.fail("accounts.database-report.accounts", "数据库报告账号字段不符合固定行 schema") + continue if not isinstance(account_id, int) or isinstance(account_id, bool) or account_id <= 0 \ or not isinstance(username, str) or username not in expected_roles \ or item.get("role") != expected_roles.get(username) \ @@ -430,12 +508,36 @@ class EvidenceGate: self.fail("accounts.database-report.account-quota", f"账号 {username} 缺少唯一额度池 ID") else: pool_ids.add(pool_id) + newapi_user_id = item.get("newapiUserId") + newapi_token_id = item.get("newapiTokenId") + newapi_username = item.get("newapiUsername") + authority_identity_valid = ( + isinstance(newapi_user_id, int) and not isinstance(newapi_user_id, bool) + and newapi_user_id > 0 and newapi_user_id not in newapi_user_ids + and isinstance(newapi_token_id, int) and not isinstance(newapi_token_id, bool) + and newapi_token_id > 0 and newapi_token_id not in newapi_token_ids + and isinstance(newapi_username, str) + and re.fullmatch(r"neice_[0-9]{3}", newapi_username) + and newapi_username not in newapi_usernames + ) if item.get("accountStatus") != "ACTIVE" \ or item.get("quotaStatus") != "CLAIMED" \ - or item.get("tokenStatus") != "ENABLED" \ or not isinstance(item.get("grantQuota"), int) or item["grantQuota"] <= 0 \ - or not isinstance(item.get("tokenRemainQuota"), int) or item["tokenRemainQuota"] <= 0: + or not isinstance(item.get("newapiUserId"), int) or item["newapiUserId"] <= 0 \ + or not isinstance(item.get("newapiTokenId"), int) or item["newapiTokenId"] <= 0 \ + or not isinstance(item.get("newapiUsername"), str) \ + or not re.fullmatch(r"neice_[0-9]{3}", item["newapiUsername"]) \ + or item.get("tokenStatus") != "ENABLED" \ + or not isinstance(item.get("tokenRemainQuota"), int) \ + or isinstance(item.get("tokenRemainQuota"), bool) or item["tokenRemainQuota"] <= 0 \ + or item.get("tokenUnlimitedQuota") is not False \ + or item.get("tokenExpiredTime") != -1 \ + or not authority_identity_valid: self.fail("accounts.database-report.account-quota", f"账号 {username} 的账号或额度状态不可用") + else: + newapi_user_ids.add(newapi_user_id) + newapi_token_ids.add(newapi_token_id) + newapi_usernames.add(newapi_username) by_identity[(account_id, username)] = item if usernames != set(expected_roles) or len(report_accounts) != len(expected_roles): self.fail("accounts.database-report.accounts", "数据库报告必须精确包含 dogfoodc01-10 与 dogfoodp01-30") @@ -444,7 +546,6 @@ class EvidenceGate: reserve_pool_ids: set[int] = set() reserve_user_ids: set[int] = set() reserve_token_ids: set[int] = set() - reserve_usernames: set[str] = set() if not isinstance(reserves, list) or len(reserves) != 12: self.fail("accounts.database-report.reserve-quota", "数据库报告必须精确包含 12 条 FREE 预留额度") else: @@ -455,21 +556,32 @@ class EvidenceGate: pool_id = item.get("poolId") user_id = item.get("newapiUserId") token_id = item.get("newapiTokenId") - username = item.get("newapiUsername") + if set(item) != { + "poolId", "newapiUserId", "newapiTokenId", "newapiUsername", "quotaStatus", + "grantQuota", "tokenStatus", "tokenRemainQuota", "tokenUnlimitedQuota", + "tokenExpiredTime"}: + self.fail("accounts.database-report.reserve-quota", "FREE 预留字段不符合固定行 schema") + continue identifiers_valid = ( isinstance(pool_id, int) and not isinstance(pool_id, bool) and pool_id > 0 and isinstance(user_id, int) and not isinstance(user_id, bool) and user_id > 0 and isinstance(token_id, int) and not isinstance(token_id, bool) and token_id > 0 - and isinstance(username, str) and re.fullmatch(r"neice_[0-9]{3}", username) and pool_id not in pool_ids and pool_id not in reserve_pool_ids and user_id not in reserve_user_ids and token_id not in reserve_token_ids - and username not in reserve_usernames + and user_id not in newapi_user_ids and token_id not in newapi_token_ids + and item.get("newapiUsername") not in newapi_usernames ) quota_valid = ( item.get("quotaStatus") == "FREE" - and item.get("tokenStatus") == "ENABLED" and isinstance(item.get("grantQuota"), int) and item["grantQuota"] > 0 - and isinstance(item.get("tokenRemainQuota"), int) and item["tokenRemainQuota"] > 0 + and isinstance(item.get("newapiUsername"), str) + and re.fullmatch(r"neice_[0-9]{3}", item["newapiUsername"]) + and item.get("tokenStatus") == "ENABLED" + and isinstance(item.get("tokenRemainQuota"), int) + and not isinstance(item.get("tokenRemainQuota"), bool) + and item["tokenRemainQuota"] > 0 + and item.get("tokenUnlimitedQuota") is False + and item.get("tokenExpiredTime") == -1 ) if not identifiers_valid or not quota_valid: self.fail("accounts.database-report.reserve-quota", "FREE 预留额度的身份、状态或余额非法") @@ -477,7 +589,9 @@ class EvidenceGate: reserve_pool_ids.add(pool_id) reserve_user_ids.add(user_id) reserve_token_ids.add(token_id) - reserve_usernames.add(username) + newapi_user_ids.add(user_id) + newapi_token_ids.add(token_id) + newapi_usernames.add(item["newapiUsername"]) selected = accounts.get("selectedAccounts") if not isinstance(selected, list): @@ -1367,12 +1481,15 @@ def ensure_wave_scaffold(wave_dir: Path) -> list[str]: return created -def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int: +def initialize_wave(root: Path, wave: str, now: datetime | None = None, rc_commit: str | None = None) -> int: """初始化固定波次目录;已存在时只校验身份,不覆盖证据。""" if wave not in WAVE_SCALES: print("INIT_REFUSED:仅允许 wave0 或 wave1。", file=sys.stderr) return 2 current_time = normalized_utc(now) + if not isinstance(rc_commit, str) or not COMMIT_PATTERN.fullmatch(rc_commit): + print("INIT_REFUSED:--rc-commit 必须是完整 40 位小写哈希。", file=sys.stderr) + return 2 root = root.resolve() wave_dir = root / wave manifest_path = wave_dir / "manifest.json" @@ -1386,7 +1503,9 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int: except (OSError, json.JSONDecodeError) as exc: print(f"INIT_REFUSED:既有 manifest 不可读:{exc}", file=sys.stderr) return 2 - expected_keys = {"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt"} + expected_keys = { + "schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt", "rcCommit", + } if wave == "wave1": expected_keys.update({"wave0CloseResultSha256", "wave0FinalFreezeSha256", "wave0ClosedAt"}) started_at = parse_timestamp(current_manifest.get("startedAt")) if isinstance(current_manifest, dict) else None @@ -1396,6 +1515,7 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int: or set(current_manifest) != expected_keys or current_manifest.get("schema") != MANIFEST_SCHEMA or current_manifest.get("wave") != wave + or current_manifest.get("rcCommit") != rc_commit or started_at is None or started_at > current_time or not fixed_values_match @@ -1420,6 +1540,7 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int: "wave": wave, **WAVE_SCALES[wave], "startedAt": format_timestamp(current_time), + "rcCommit": rc_commit, } if wave == "wave1": binding, errors = inspect_wave0_close(root, current_time) @@ -1443,6 +1564,7 @@ def build_parser() -> argparse.ArgumentParser: init_parser = subparsers.add_parser("init", help="初始化 wave0 或 wave1 证据目录") init_parser.add_argument("--root", required=True, type=Path, help="证据根目录") init_parser.add_argument("--wave", required=True, choices=sorted(WAVE_SCALES), help="固定波次") + init_parser.add_argument("--rc-commit", required=True, help="波次绑定的完整 40 位 RC commit") check_parser = subparsers.add_parser("check", help="执行开波或收口证据门") check_parser.add_argument("--wave-dir", required=True, type=Path, help="单个波次证据目录") check_parser.add_argument("--phase", required=True, choices=("readiness", "close"), help="闸门阶段") @@ -1463,7 +1585,7 @@ def main() -> int: """解析命令并返回稳定退出码。""" args = build_parser().parse_args() if args.command == "init": - return initialize_wave(args.root, args.wave) + return initialize_wave(args.root, args.wave, rc_commit=args.rc_commit) if args.command == "check": return EvidenceGate(args.wave_dir, args.phase).run() if args.command == "freeze": diff --git a/deploy/dogfood-ops/tests/test_dogfood_ops.py b/deploy/dogfood-ops/tests/test_dogfood_ops.py index 1c06142f..07d1e7b9 100644 --- a/deploy/dogfood-ops/tests/test_dogfood_ops.py +++ b/deploy/dogfood-ops/tests/test_dogfood_ops.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib import importlib.util import json +import shutil import subprocess import tempfile import unittest @@ -27,6 +28,36 @@ class DogfoodOpsTest(unittest.TestCase): """通过命令行入口验证运维人员实际使用的行为。""" FIXTURE_STARTED_AT = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) + FIXTURE_RC_COMMIT = "1" * 40 + + @classmethod + def setUpClass(cls) -> None: + """生成测试专用签名根,并复制 CLI 到相同的固定相对信任路径。""" + cls.trust_temp_dir = tempfile.TemporaryDirectory() + deploy_dir = Path(cls.trust_temp_dir.name) / "deploy" + (deploy_dir / "dogfood-ops").mkdir(parents=True) + (deploy_dir / "trust").mkdir(parents=True) + cls.test_tool = deploy_dir / "dogfood-ops" / "dogfood_ops.py" + cls.test_private_key = deploy_dir / "trust" / "r1-private.pem" + cls.test_public_key = deploy_dir / "trust" / "r1-attestation-ed25519.pub" + shutil.copy2(TOOL, cls.test_tool) + subprocess.run( + ["openssl", "genpkey", "-algorithm", "ED25519", "-out", str(cls.test_private_key)], + check=True, capture_output=True, + ) + subprocess.run( + ["openssl", "pkey", "-in", str(cls.test_private_key), "-pubout", "-out", str(cls.test_public_key)], + check=True, capture_output=True, + ) + cls.original_public_key = getattr(DOGFOOD_OPS, "ACCOUNT_REPORT_PUBLIC_KEY", None) + DOGFOOD_OPS.ACCOUNT_REPORT_PUBLIC_KEY = cls.test_public_key + + @classmethod + def tearDownClass(cls) -> None: + """恢复模块常量并删除测试签名材料。""" + if cls.original_public_key is not None: + DOGFOOD_OPS.ACCOUNT_REPORT_PUBLIC_KEY = cls.original_public_key + cls.trust_temp_dir.cleanup() def setUp(self) -> None: """每个用例使用独立临时证据根,避免测试相互污染。""" @@ -40,7 +71,7 @@ class DogfoodOpsTest(unittest.TestCase): def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]: """调用真实 CLI,并保留标准输出供失败断言。""" return subprocess.run( - ["python3", str(TOOL), *args], + ["python3", str(self.test_tool), *args], check=False, text=True, capture_output=True, @@ -49,7 +80,8 @@ class DogfoodOpsTest(unittest.TestCase): def init_wave(self, wave: str) -> Path: """初始化指定波次并返回证据目录。""" # 公共通过夹具固定在已过去的可信时间,避免用未来证据伪造刚初始化波次的 readiness。 - result = DOGFOOD_OPS.initialize_wave(self.root, wave, now=self.FIXTURE_STARTED_AT) + result = DOGFOOD_OPS.initialize_wave( + self.root, wave, now=self.FIXTURE_STARTED_AT, rc_commit=self.FIXTURE_RC_COMMIT) self.assertEqual(result, 0) return self.root / wave @@ -64,10 +96,24 @@ class DogfoodOpsTest(unittest.TestCase): attachment.write_text(content, encoding="utf-8") return attachment.relative_to(wave_dir).as_posix(), hashlib.sha256(attachment.read_bytes()).hexdigest() + def sign_report(self, report_path: Path, signature_path: Path) -> None: + """使用测试专用私钥生成真实 detached signature。""" + subprocess.run( + [ + "openssl", "pkeyutl", "-sign", "-inkey", str(self.test_private_key), + "-rawin", "-in", str(report_path), "-out", str(signature_path), + ], + check=True, + capture_output=True, + ) + report_path.chmod(0o600) + signature_path.chmod(0o600) + def close_valid_wave0(self) -> tuple[Path, datetime]: """按两个实际 UTC 运行日构造已通过收口门的 Wave0。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) self.assertEqual( @@ -164,7 +210,8 @@ class DogfoodOpsTest(unittest.TestCase): with self.subTest(evidence=name): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "evidence" - self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0", now=started_at), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = root / "wave0" self.populate_valid_evidence(wave_dir) mutate(wave_dir, invalid_time) @@ -178,7 +225,10 @@ class DogfoodOpsTest(unittest.TestCase): def test_wave1_init_requires_closed_wave0(self) -> None: """没有 Wave0 收口回执时不得初始化 Wave1。""" - result = self.run_tool("init", "--root", str(self.root), "--wave", "wave1") + result = self.run_tool( + "init", "--root", str(self.root), "--wave", "wave1", + "--rc-commit", self.FIXTURE_RC_COMMIT, + ) self.assertNotEqual(result.returncode, 0) self.assertIn("WAVE0_CLOSE_REQUIRED", result.stderr) @@ -186,11 +236,13 @@ class DogfoodOpsTest(unittest.TestCase): def test_manifest_and_freeze_use_tool_generated_trusted_times(self) -> None: """开始与冻结时间必须由工具生成,并拒绝未来或回填日期。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - result = DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + result = DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) self.assertEqual(result, 0) wave_dir = self.root / "wave0" manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8")) self.assertEqual(manifest["startedAt"], "2026-07-20T09:00:00Z") + self.assertEqual(manifest["rcCommit"], self.FIXTURE_RC_COMMIT) backfill = DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-19", now=started_at) future = DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-21", now=started_at) @@ -205,7 +257,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_close_requires_elapsed_runtime_and_every_actual_utc_day(self) -> None: """收口必须达到真实时长,并覆盖从 startedAt 到 close 的每个 UTC 运行日。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-20", now=started_at.replace(hour=23)) @@ -224,7 +277,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_same_minute_cannot_forge_three_daily_freezes(self) -> None: """同一时刻只能冻结当天,不能用三个日期伪造 Wave1 三天运行。""" now = datetime(2026, 7, 23, 5, 30, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=now) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=now, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" results = [ @@ -237,7 +291,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_close_rejects_forged_freeze_created_at(self) -> None: """手工把 createdAt 改成未来或与文件日期不一致时不得收口。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) freeze_now = datetime(2026, 7, 20, 23, 0, tzinfo=timezone.utc) @@ -309,6 +364,7 @@ class DogfoodOpsTest(unittest.TestCase): report = json.loads(report_path.read_text(encoding="utf-8")) report["accounts"].pop() self.write_json(report_path, report) + self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"]) accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() self.write_json(wave_dir / "accounts.json", accounts) @@ -323,12 +379,19 @@ class DogfoodOpsTest(unittest.TestCase): ("activation", "accounts.activation-run-id"), ("captured-at", "accounts.database-report.captured-at"), ("claimed-quota", "accounts.database-report.account-quota"), + ("token-disabled", "accounts.database-report.account-quota"), + ("token-balance", "accounts.database-report.account-quota"), + ("token-unlimited", "accounts.database-report.account-quota"), + ("token-expiry", "accounts.database-report.account-quota"), + ("newapi-user-duplicate", "accounts.database-report.account-quota"), + ("newapi-token-duplicate", "accounts.database-report.account-quota"), ("reserve-status", "accounts.database-report.reserve-quota"), ): with self.subTest(mutation=mutation): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "evidence" - self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = root / "wave0" self.populate_valid_evidence(wave_dir) accounts_path = wave_dir / "accounts.json" @@ -346,10 +409,23 @@ class DogfoodOpsTest(unittest.TestCase): report["capturedAt"] = stale_time accounts["databaseReportCapturedAt"] = stale_time elif mutation == "claimed-quota": + report["accounts"][0]["grantQuota"] = 0 + elif mutation == "token-disabled": + report["accounts"][0]["tokenStatus"] = "DISABLED" + elif mutation == "token-balance": report["accounts"][0]["tokenRemainQuota"] = 0 + elif mutation == "token-unlimited": + report["accounts"][0]["tokenUnlimitedQuota"] = True + elif mutation == "token-expiry": + report["accounts"][0]["tokenExpiredTime"] = 1800000000 + elif mutation == "newapi-user-duplicate": + report["accounts"][1]["newapiUserId"] = report["accounts"][0]["newapiUserId"] + elif mutation == "newapi-token-duplicate": + report["accounts"][1]["newapiTokenId"] = report["accounts"][0]["newapiTokenId"] else: report["reserves"][0]["quotaStatus"] = "CLAIMED" self.write_json(report_path, report) + self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"]) accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() self.write_json(accounts_path, accounts) @@ -371,6 +447,62 @@ class DogfoodOpsTest(unittest.TestCase): self.assertNotEqual(gate.run(), 0) self.assertTrue(any(code == "accounts.selected.identity" for code, _ in gate.errors), gate.errors) + def test_accounts_rejects_missing_or_tampered_detached_signature(self) -> None: + """缺签或报告签名后被篡改时必须失败关闭。""" + for mutation in ("missing", "tampered"): + with self.subTest(mutation=mutation): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "evidence" + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0) + wave_dir = root / "wave0" + self.populate_valid_evidence(wave_dir) + accounts = json.loads((wave_dir / "accounts.json").read_text(encoding="utf-8")) + signature_path = wave_dir / accounts["databaseReportSignatureRef"] + if mutation == "missing": + signature_path.unlink() + else: + report_path = wave_dir / accounts["databaseReportRef"] + report_path.write_text(report_path.read_text(encoding="utf-8") + " ", encoding="utf-8") + accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() + self.write_json(wave_dir / "accounts.json", accounts) + + gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness") + self.assertNotEqual(gate.run(), 0) + self.assertTrue( + any(code == "accounts.database-report.signature" for code, _ in gate.errors), + gate.errors, + ) + + def test_accounts_rejects_signed_issuer_rc_or_query_drift(self) -> None: + """即使签名有效,固定签发者、manifest RC 和固定查询身份也不能漂移。""" + for field, value, expected_code in ( + ("issuer", "operator-self-signed", "accounts.database-report.issuer"), + ("rcCommit", "2" * 40, "accounts.database-report.rc-commit"), + ("queryId", "handwritten-query", "accounts.database-report.query"), + ("querySchema", "handwritten-row/1", "accounts.database-report.query"), + ): + with self.subTest(field=field): + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) / "evidence" + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0) + wave_dir = root / "wave0" + self.populate_valid_evidence(wave_dir) + accounts_path = wave_dir / "accounts.json" + accounts = json.loads(accounts_path.read_text(encoding="utf-8")) + report_path = wave_dir / accounts["databaseReportRef"] + report = json.loads(report_path.read_text(encoding="utf-8")) + report[field] = value + self.write_json(report_path, report) + self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"]) + accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest() + self.write_json(accounts_path, accounts) + + gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness") + self.assertNotEqual(gate.run(), 0) + self.assertTrue(any(code == expected_code for code, _ in gate.errors), gate.errors) + def populate_valid_evidence(self, wave_dir: Path) -> None: """构造不依赖 staging 的完整本地通过 fixture。""" manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8")) @@ -440,8 +572,13 @@ class DogfoodOpsTest(unittest.TestCase): "quotaPoolId": 2000 + index, "quotaStatus": "CLAIMED", "grantQuota": 6849315, + "newapiUserId": 4000 + index, + "newapiTokenId": 5000 + index, + "newapiUsername": f"neice_{index:03d}", "tokenStatus": "ENABLED", "tokenRemainQuota": 6849315, + "tokenUnlimitedQuota": False, + "tokenExpiredTime": -1, }) for index in range(1, 31): all_accounts.append({ @@ -452,29 +589,40 @@ class DogfoodOpsTest(unittest.TestCase): "quotaPoolId": 2100 + index, "quotaStatus": "CLAIMED", "grantQuota": 6849315, + "newapiUserId": 4100 + index, + "newapiTokenId": 5100 + index, + "newapiUsername": f"neice_{10 + index:03d}", "tokenStatus": "ENABLED", "tokenRemainQuota": 6849315, + "tokenUnlimitedQuota": False, + "tokenExpiredTime": -1, }) reserves = [{ "poolId": 3000 + index, - "newapiUserId": 4000 + index, - "newapiUsername": f"neice_{index:03d}", - "newapiTokenId": 5000 + index, + "newapiUserId": 4200 + index, + "newapiTokenId": 5200 + index, + "newapiUsername": f"neice_{40 + index:03d}", "quotaStatus": "FREE", "grantQuota": 6849315, "tokenStatus": "ENABLED", "tokenRemainQuota": 6849315, + "tokenUnlimitedQuota": False, + "tokenExpiredTime": -1, } for index in range(1, 13)] activation_run_id = "activation-20260723T010203Z" self.write_json(wave_dir / account_report_ref, { - "schema": "dogfood-account-database-report/1", + "schema": "dogfood-account-database-report/2", + "issuer": "r1-mini-infra", "activationRunId": activation_run_id, + "rcCommit": commit, "capturedAt": captured_at, - "source": "staging-mysql-readonly", - "queryId": f"account-quota-audit-{wave}", + "queryId": "dogfood-account-readiness-v2", + "querySchema": "dogfood-account-readiness-row/2", "accounts": all_accounts, "reserves": reserves, }) + account_signature_ref = account_report_ref + ".sig" + self.sign_report(wave_dir / account_report_ref, wave_dir / account_signature_ref) account_report_hash = hashlib.sha256((wave_dir / account_report_ref).read_bytes()).hexdigest() selected = ( all_accounts[:3] + all_accounts[10:15] @@ -485,6 +633,7 @@ class DogfoodOpsTest(unittest.TestCase): "schema": "dogfood-accounts/2", "activationRunId": activation_run_id, "databaseReportRef": account_report_ref, + "databaseReportSignatureRef": account_signature_ref, "databaseReportSha256": account_report_hash, "databaseReportCapturedAt": captured_at, "selectedAccounts": [ @@ -627,7 +776,8 @@ class DogfoodOpsTest(unittest.TestCase): """wave1 必须固定为累计 10 名创作者和 30 名玩家。""" wave0_dir, closed_at = self.close_valid_wave0() self.assertTrue((wave0_dir / "close-result.json").is_file()) - self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1)), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = self.root / "wave1" manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8")) @@ -640,7 +790,8 @@ class DogfoodOpsTest(unittest.TestCase): """Wave1 开波门必须持续复核 Wave0 回执和最终冻结哈希。""" wave0_dir, closed_at = self.close_valid_wave0() wave1_started_at = closed_at + timedelta(minutes=1) - self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=wave1_started_at), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=wave1_started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0) wave1_dir = self.root / "wave1" self.populate_valid_evidence(wave1_dir) (wave0_dir / "close-result.json").write_text( @@ -662,7 +813,8 @@ class DogfoodOpsTest(unittest.TestCase): encoding="utf-8", ) - result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1)) + result = DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT) self.assertNotEqual(result, 0) _, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1)) @@ -676,7 +828,8 @@ class DogfoodOpsTest(unittest.TestCase): next(item for item in payload["conditions"] if item["condition"] == "confirmed-false-positive")["triggered"] = True self.write_json(path, payload) - result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1)) + result = DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT) self.assertNotEqual(result, 0) _, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1)) @@ -690,7 +843,8 @@ class DogfoodOpsTest(unittest.TestCase): next(item for item in payload["conditions"] if item["condition"] == "review-backlog-over-4h")["triggered"] = True self.write_json(path, payload) - result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1)) + result = DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT) self.assertNotEqual(result, 0) _, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1)) @@ -703,7 +857,10 @@ class DogfoodOpsTest(unittest.TestCase): feedback.write_text(feedback.read_text(encoding="utf-8") + "F-1\t2026-07-22T00:00:00Z\tu1\tplayer\tbug\tP1\t卡住\tOPEN\tops\tattachments/f.txt\n", encoding="utf-8") before = hashlib.sha256(feedback.read_bytes()).hexdigest() - result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0") + result = self.run_tool( + "init", "--root", str(self.root), "--wave", "wave0", + "--rc-commit", self.FIXTURE_RC_COMMIT, + ) self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(hashlib.sha256(feedback.read_bytes()).hexdigest(), before) @@ -717,7 +874,10 @@ class DogfoodOpsTest(unittest.TestCase): (wave_dir / "rc.json").unlink() (wave_dir / "attachments").rmdir() - result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0") + result = self.run_tool( + "init", "--root", str(self.root), "--wave", "wave0", + "--rc-commit", self.FIXTURE_RC_COMMIT, + ) self.assertEqual(result.returncode, 0, result.stderr) self.assertIn("INIT_REPAIRED", result.stdout) @@ -890,7 +1050,8 @@ class DogfoodOpsTest(unittest.TestCase): with self.subTest(collected_at=collected_at): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "evidence" - self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = root / "wave0" self.populate_valid_evidence(wave_dir) path = wave_dir / "performance.json" @@ -978,7 +1139,8 @@ class DogfoodOpsTest(unittest.TestCase): with self.subTest(ref_key=ref_key): with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) / "evidence" - self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = root / "wave0" self.populate_valid_evidence(wave_dir) chain = json.loads((wave_dir / "main-chain.json").read_text(encoding="utf-8")) @@ -1011,7 +1173,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_close_requires_freeze_and_detects_post_freeze_tampering(self) -> None: """收口门必须验证最近一次 SHA-256 冻结,冻结后改动立即失效。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) closed_at = started_at + timedelta(hours=24, minutes=1) @@ -1035,7 +1198,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_close_rejects_malformed_freeze_without_traceback(self) -> None: """畸形冻结清单应形成可追踪失败,而不是让闸门异常退出。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) freeze_dir = wave_dir / "freezes" @@ -1050,7 +1214,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_close_rejects_symlink_added_after_freeze_without_traceback(self) -> None: """冻结后加入符号链接时应 fail-closed,并保持稳定退出码。""" started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.populate_valid_evidence(wave_dir) self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-20", now=started_at.replace(hour=23)), 0) @@ -1066,7 +1231,8 @@ class DogfoodOpsTest(unittest.TestCase): """Wave1 必须真实运行 72 小时并覆盖四个实际 UTC 日期,且哈希链不可篡改。""" _, wave0_closed_at = self.close_valid_wave0() started_at = wave0_closed_at + timedelta(minutes=1) - self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=started_at), 0) + self.assertEqual(DOGFOOD_OPS.initialize_wave( + self.root, "wave1", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0) wave_dir = self.root / "wave1" self.populate_valid_evidence(wave_dir) close_time = started_at + timedelta(hours=72, minutes=1) @@ -1120,7 +1286,8 @@ class DogfoodOpsTest(unittest.TestCase): def test_freeze_is_idempotent_but_refuses_same_day_rewrite(self) -> None: """同一证据可重复冻结,内容变化后不得覆盖同日清单。""" now = datetime(2026, 7, 22, 8, 0, tzinfo=timezone.utc) - DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=now) + DOGFOOD_OPS.initialize_wave( + self.root, "wave0", now=now, rc_commit=self.FIXTURE_RC_COMMIT) wave_dir = self.root / "wave0" self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-22", now=now), 0) self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-22", now=now + timedelta(minutes=1)), 0) diff --git a/deploy/prepare-staging-rc.sh b/deploy/prepare-staging-rc.sh index 17b4dae9..589876a7 100755 --- a/deploy/prepare-staging-rc.sh +++ b/deploy/prepare-staging-rc.sh @@ -99,11 +99,13 @@ security_java_tests=( GameVersionServiceImplTest PassportServiceImplTest ProjectServiceImplTest + ProjectVersionApiImplDogfoodTest PublishOrchestrationServiceImplTest RuntimePackageServiceImplTest RuntimePackageApiImplDogfoodTest AdminRuntimeControllerDogfoodTest AppFeedControllerDogfoodTest + FeedApiImplDogfoodTest AdControllerDogfoodTest TradeControllerDogfoodTest AdminNewapiQuotaControllerTest diff --git a/deploy/tests/test-collect-dogfood-account-report.sh b/deploy/tests/test-collect-dogfood-account-report.sh new file mode 100755 index 00000000..f2d7adda --- /dev/null +++ b/deploy/tests/test-collect-dogfood-account-report.sh @@ -0,0 +1,169 @@ +#!/usr/bin/env bash +# 账号报告采集器回归:使用假 SSH/MySQL 输出,不连接真实内网主机。 +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +SCRIPT="$ROOT_DIR/deploy/collect-dogfood-account-report.sh" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +fail() { + printf 'TEST_FAIL %s\n' "$*" >&2 + exit 1 +} + +openssl genpkey -algorithm ED25519 -out "$TMP_DIR/private.pem" >/dev/null 2>&1 +openssl pkey -in "$TMP_DIR/private.pem" -pubout -out "$TMP_DIR/public.pem" >/dev/null 2>&1 + +{ + for index in $(seq 1 10); do + printf 'ACCOUNT\t%d\tdogfoodc%02d\tcreator\tACTIVE\t%d\tCLAIMED\t6849315\t%d\t%d\n' \ + "$((1000 + index))" "$index" "$((2000 + index))" "$((4000 + index))" "$((5000 + index))" + done + for index in $(seq 1 30); do + printf 'ACCOUNT\t%d\tdogfoodp%02d\tplayer\tACTIVE\t%d\tCLAIMED\t6849315\t%d\t%d\n' \ + "$((1100 + index))" "$index" "$((2100 + index))" "$((4100 + index))" "$((5100 + index))" + done + for index in $(seq 1 12); do + printf 'RESERVE\t%d\t%d\t%d\tFREE\t6849315\t\t\t\t\n' \ + "$((3000 + index))" "$((4200 + index))" "$((5200 + index))" + done +} >"$TMP_DIR/mysql.tsv" + +{ + for index in $(seq 1 10); do + printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \ + "$((4000 + index))" "$index" "$((5000 + index))" + done + for index in $(seq 1 30); do + printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \ + "$((4100 + index))" "$((10 + index))" "$((5100 + index))" + done + for index in $(seq 1 12); do + printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \ + "$((4200 + index))" "$((40 + index))" "$((5200 + index))" + done +} >"$TMP_DIR/newapi.tsv" + +cat >"$TMP_DIR/fake-ssh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >"$DOGFOOD_TEST_SSH_ARGS" +cat >"$DOGFOOD_TEST_SQL_CAPTURE" +cat "$DOGFOOD_TEST_MYSQL_ROWS" +SH +chmod +x "$TMP_DIR/fake-ssh" + +cat >"$TMP_DIR/fake-docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >"$DOGFOOD_TEST_DOCKER_ARGS" +cat >"$DOGFOOD_TEST_PG_SQL_CAPTURE" +cat "$DOGFOOD_TEST_NEWAPI_ROWS" +SH +chmod +x "$TMP_DIR/fake-docker" + +export DOGFOOD_COLLECTOR_TEST_MODE=1 +export DOGFOOD_COLLECTOR_TEST_SSH_BIN="$TMP_DIR/fake-ssh" +export DOGFOOD_COLLECTOR_TEST_SIGNER_KEY="$TMP_DIR/private.pem" +export DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN="$(command -v openssl)" +export DOGFOOD_COLLECTOR_TEST_DOCKER_BIN="$TMP_DIR/fake-docker" +export DOGFOOD_TEST_SSH_ARGS="$TMP_DIR/ssh-args" +export DOGFOOD_TEST_SQL_CAPTURE="$TMP_DIR/mysql.sql" +export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/mysql.tsv" +export DOGFOOD_TEST_DOCKER_ARGS="$TMP_DIR/docker-args" +export DOGFOOD_TEST_PG_SQL_CAPTURE="$TMP_DIR/newapi.sql" +export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi.tsv" + +report="$TMP_DIR/account-report.json" +"$SCRIPT" \ + --activation-run-id activation-20260723T010203Z \ + --rc-commit 1111111111111111111111111111111111111111 \ + --output "$report" + +[ -f "$report" ] && [ -f "$report.sig" ] || fail '没有生成报告及 detached signature' +[ "$(stat -f '%Lp' "$report" 2>/dev/null || stat -c '%a' "$report")" = 600 ] || fail '报告权限不是 0600' +[ "$(stat -f '%Lp' "$report.sig" 2>/dev/null || stat -c '%a' "$report.sig")" = 600 ] || fail '签名权限不是 0600' +openssl pkeyutl -verify -pubin -inkey "$TMP_DIR/public.pem" -rawin \ + -in "$report" -sigfile "$report.sig" >/dev/null 2>&1 || fail 'detached signature 无法验证' +grep -q 'root@100.64.0.7' "$TMP_DIR/ssh-args" || fail 'SSH 目标不是固定 mini-desktop' +grep -q '^START TRANSACTION READ ONLY;' "$TMP_DIR/mysql.sql" || fail 'MySQL 查询未进入服务端只读事务' +grep -q 'FROM game_player p' "$TMP_DIR/mysql.sql" || fail '固定账号查询缺失' +grep -q 'FROM newapi_quota_pool q' "$TMP_DIR/mysql.sql" || fail '固定额度池查询缺失' +if grep -Eqi 'newapi_token_key|password|access_token|refresh_token' "$TMP_DIR/mysql.sql"; then + fail '固定查询选择了敏感列' +fi +grep -q 'infra-postgres' "$TMP_DIR/docker-args" || fail 'new-api PostgreSQL 容器不是固定值' +grep -q 'new-api' "$TMP_DIR/docker-args" || fail 'new-api PostgreSQL 数据库不是固定值' +grep -q '^BEGIN TRANSACTION READ ONLY;' "$TMP_DIR/newapi.sql" || fail 'new-api 查询未进入只读事务' +grep -q 'FROM users u' "$TMP_DIR/newapi.sql" || fail 'new-api users 固定查询缺失' +grep -q 'JOIN tokens t' "$TMP_DIR/newapi.sql" || fail 'new-api tokens 固定查询缺失' +if grep -Eqi 'SELECT[^;]*(key|access_token|password)' "$TMP_DIR/newapi.sql"; then + fail 'new-api 固定查询选择了敏感凭据' +fi +grep -q "SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem'" "$SCRIPT" || \ + fail '生产 signer 私钥路径不是固定值' +grep -q 'realpath.*signer_key' "$SCRIPT" || fail '生产 signer 私钥未校验 canonical 路径' +grep -q "stat -c '%u'.*signer_key" "$SCRIPT" || fail '生产 signer 私钥未校验 root 所有权' + +python3 - "$report" <<'PY' +import json +import sys + +report = json.load(open(sys.argv[1], encoding="utf-8")) +assert report["schema"] == "dogfood-account-database-report/2" +assert report["issuer"] == "r1-mini-infra" +assert report["queryId"] == "dogfood-account-readiness-v2" +assert report["querySchema"] == "dogfood-account-readiness-row/2" +assert report["rcCommit"] == "1" * 40 +assert len(report["accounts"]) == 40 +assert len(report["reserves"]) == 12 +for item in report["accounts"] + report["reserves"]: + assert item["newapiUsername"].startswith("neice_") + assert item["tokenStatus"] == "ENABLED" + assert item["tokenRemainQuota"] > 0 + assert item["tokenUnlimitedQuota"] is False + assert item["tokenExpiredTime"] == -1 +serialized = json.dumps(report) +for secret_name in ("tokenKey", "password", "accessToken", "refreshToken"): + assert secret_name not in serialized +PY + +# 精确规模是签发前置条件,39 个 CLAIMED 账号必须失败且不得留下半成品。 +head -n 51 "$TMP_DIR/mysql.tsv" >"$TMP_DIR/incomplete.tsv" +export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/incomplete.tsv" +if "$SCRIPT" --activation-run-id activation-20260723T010203Z \ + --rc-commit 1111111111111111111111111111111111111111 \ + --output "$TMP_DIR/incomplete.json" >/dev/null 2>&1; then + fail '不完整 40+12 数据仍被签发' +fi +[ ! -e "$TMP_DIR/incomplete.json" ] && [ ! -e "$TMP_DIR/incomplete.json.sig" ] || fail '失败后遗留半成品' + +# new-api 权威状态异常、ID 关联漂移或一名用户匹配多行时都不得签发。 +for mutation in disabled zero id-drift multi-row; do + case "$mutation" in + disabled) + awk -F'|' 'BEGIN{OFS="|"} NR==1{$5=0} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv" + ;; + zero) + awk -F'|' 'BEGIN{OFS="|"} NR==1{$8=0} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv" + ;; + id-drift) + awk -F'|' 'BEGIN{OFS="|"} NR==1{$4=999999} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv" + ;; + multi-row) + cp "$TMP_DIR/newapi.tsv" "$TMP_DIR/newapi-$mutation.tsv" + head -n 1 "$TMP_DIR/newapi.tsv" >>"$TMP_DIR/newapi-$mutation.tsv" + ;; + esac + export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi-$mutation.tsv" + if "$SCRIPT" --activation-run-id activation-20260723T010203Z \ + --rc-commit 1111111111111111111111111111111111111111 \ + --output "$TMP_DIR/$mutation.json" >/dev/null 2>&1; then + fail "new-api 异常仍被签发 mutation=$mutation" + fi + [ ! -e "$TMP_DIR/$mutation.json" ] && [ ! -e "$TMP_DIR/$mutation.json.sig" ] || \ + fail "new-api 异常后遗留半成品 mutation=$mutation" +done + +printf 'TEST_PASS 账号报告固定 SSH/MySQL、40+12、签名、权限与脱敏校验通过\n' diff --git a/deploy/tests/test-staging-rc.sh b/deploy/tests/test-staging-rc.sh index 86a7b325..488a1d20 100755 --- a/deploy/tests/test-staging-rc.sh +++ b/deploy/tests/test-staging-rc.sh @@ -10,8 +10,11 @@ STORAGE_AUDIT="$ROOT_DIR/deploy/generate-staging-storage-audit.sh" RESTORE_VERIFY="$ROOT_DIR/deploy/verify-mysql-dump-restore.sh" GENERATION_GATE_REF="sha256:$(printf '0%.0s' $(seq 1 64)):r1/$(printf '0%.0s' $(seq 1 64)).attestation" GENERATION_GATE_RUN_ID="r1-test-run-20260723" -GENERATION_ATTESTATION_EXPECTED_ISSUER="r1-independent-signer" -GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="/root/game-staging/trust/r1-attestation-ed25519.pub" +GENERATION_ATTESTATION_EXPECTED_ISSUER="attacker-controlled-issuer" +GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="/tmp/attacker-controlled-public-key.pem" +FIXED_GENERATION_ATTESTATION_ISSUER="r1-mini-infra" +FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256="e3cc17cd86805b053f61450b5c2c6e4e168bb01dbf1e83840124290d1196a304" +REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY="$ROOT_DIR/deploy/trust/r1-attestation-ed25519.pub" TMP_DIR="$(mktemp -d)" SERVER_PID="" @@ -120,6 +123,12 @@ rg -q 'openssl pkeyutl -verify' "$TMP_DIR/remote-script.sh" || fail "R1 attestat rg -q 'expected_generation_gate_run_id' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定独立 run-id" rg -q 'expected_attestation_issuer' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定 issuer" rg -q 'trusted_attestation_public_key_file' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定信任公钥" +rg -Fq 'expected_attestation_issuer="r1-mini-infra"' "$TMP_DIR/remote-script.sh" || fail "R1 issuer 未固定" +rg -Fq 'trusted_attestation_public_key_file="/root/game-staging/trust/r1-attestation-ed25519.pub"' \ + "$TMP_DIR/remote-script.sh" || fail "R1 公钥路径未固定" +rg -Fq "trusted_attestation_public_key_sha256=\"$FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256\"" \ + "$TMP_DIR/remote-script.sh" || fail "R1 公钥摘要未固定" +! rg -q 'attacker-controlled-(issuer|public-key)' "$TMP_DIR/ssh-args.txt" || fail "R1 信任根仍由调用参数传入" rg -q 'flock -n' "$TMP_DIR/remote-script.sh" || fail "activation 缺单实例部署锁" rg -q 'candidate_backend=SKIPPED_NO_ISOLATED_DATA_PLANE' "$TMP_DIR/remote-script.sh" || \ fail "候选探活没有明确跳过共享数据面 backend" @@ -148,10 +157,12 @@ gate_line="$(rg -n 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" snapshot_line="$(rg -n '迁移前数据库快照' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$gate_line" -lt "$snapshot_line" ] || fail "生成门校验没有发生在数据库快照/迁移前" candidate_probe_line="$(rg -n '启动不接共享数据面的候选探针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" -stop_old_line="$(rg -n '候选非数据面探针全绿,停止旧栈' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +maintenance_install_line="$(rg -n '^install_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +stop_old_line="$(rg -n '^stop_stack .*48080 4173 4174 8300 9501$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" restore_verify_line="$(rg -n '在隔离临时 MySQL 容器中验证快照可恢复' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" migration_line="$(rg -n '^run_flyway_migrations$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$candidate_probe_line" -lt "$stop_old_line" ] || fail "候选非数据面探针没有在停止旧栈前完成" +[ "$maintenance_install_line" -lt "$stop_old_line" ] || fail "维护门没有在严格停栈前安装" [ "$stop_old_line" -lt "$snapshot_line" ] || fail "迁移快照前旧栈仍可能写入,失败补偿会丢数据" [ "$snapshot_line" -lt "$restore_verify_line" ] || fail "隔离恢复验证没有使用停写后的最终快照" [ "$restore_verify_line" -lt "$migration_line" ] || fail "未先完成隔离恢复验证就执行正式迁移" @@ -161,7 +172,6 @@ provision_pass_line="$(rg -n 'PROVISION_PASS accounts=40 creators=10 role_mismat runtime_verify_line="$(rg -n '狗粮审核账号轮换与运行态验证 PASS' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" stable_smoke_line="$(rg -n 'stable-smoke\.log' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" active_switch_line="$(rg -n '正式栈全绿,原子切换 active 指针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" -maintenance_install_line="$(rg -n '^install_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" stable_start_line="$(rg -n '^start_stack .*48080 4173 4174 8300 9501 true$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" maintenance_release_line="$(rg -n '^release_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$maintenance_install_line" -lt "$stable_start_line" ] || fail "正式栈启动前未封锁外部入口" @@ -207,7 +217,7 @@ verify_stack_body="$(sed -n '/^verify_stack()/,/^}/p' "$TMP_DIR/remote-script.sh [ "$(printf '%s\n' "$verify_stack_body" | rg -c 'systemctl is-active --quiet')" -eq 5 ] || \ fail "回滚复核没有逐项检查五个 systemd 服务" -# 用有状态假 iptables 执行维护门真实函数,验证安装、复核和解除的完整生命周期。 +# 用有状态假 iptables 执行维护门真实函数,验证链清理失败时独立护栏仍封锁入口。 cat >"$TMP_DIR/fake-iptables" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -224,8 +234,21 @@ case "$*" in '-w 5 -C INPUT ! -i lo -j GDA_STAGING_MAINT') [ -f "$state/input-jump" ] ;; '-w 5 -I INPUT 1 ! -i lo -j GDA_STAGING_MAINT') touch "$state/input-jump" ;; '-w 5 -D INPUT ! -i lo -j GDA_STAGING_MAINT') rm -f "$state/input-jump" ;; - '-w 5 -F GDA_STAGING_MAINT') rm -f "$state/reject-rule" ;; - '-w 5 -X GDA_STAGING_MAINT') rm -f "$state/chain" ;; + '-w 5 -nL GDA_STAGING_RELEASE_GUARD') [ -f "$state/release-guard-chain" ] ;; + '-w 5 -N GDA_STAGING_RELEASE_GUARD') touch "$state/release-guard-chain" ;; + '-w 5 -C GDA_STAGING_RELEASE_GUARD -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT') + [ -f "$state/release-guard-reject" ] ;; + '-w 5 -A GDA_STAGING_RELEASE_GUARD -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT') + touch "$state/release-guard-reject" ;; + '-w 5 -C INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD') [ -f "$state/release-guard-jump" ] ;; + '-w 5 -I INPUT 1 ! -i lo -j GDA_STAGING_RELEASE_GUARD') touch "$state/release-guard-jump" ;; + '-w 5 -D INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD') rm -f "$state/release-guard-jump" ;; + '-w 5 -F GDA_STAGING_MAINT') + [ ! -f "$state/fail-F" ] || exit 91 + rm -f "$state/reject-rule" ;; + '-w 5 -X GDA_STAGING_MAINT') + [ ! -f "$state/fail-X" ] || exit 92 + rm -f "$state/chain" ;; *) printf '未知 fake iptables 调用:%s\n' "$*" >&2; exit 96 ;; esac SH @@ -235,9 +258,11 @@ chmod +x "$TMP_DIR/fake-iptables" printf 'IPTABLES_BIN=%q\n' "$TMP_DIR/fake-iptables" printf 'evidence=%q\n' "$TMP_DIR/maintenance-evidence" printf '%s\n' 'maintenance_chain=GDA_STAGING_MAINT' \ + 'maintenance_release_guard_chain=GDA_STAGING_RELEASE_GUARD' \ 'maintenance_ports=48080,4173,4174,8300,9501' 'maintenance_gate_active=0' printf '%s\n' 'log_step() { :; }' sed -n '/^maintenance_rule_present()/,/^}/p' "$TMP_DIR/remote-script.sh" + sed -n '/^maintenance_release_guard_present()/,/^}/p' "$TMP_DIR/remote-script.sh" sed -n '/^install_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh" sed -n '/^release_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh" printf '%s\n' 'mkdir -p "$evidence"' 'install_maintenance_gate' \ @@ -247,9 +272,81 @@ chmod +x "$TMP_DIR/fake-iptables" bash "$TMP_DIR/maintenance-harness.sh" [ ! -e "$TMP_DIR/iptables-state/chain" ] \ && [ ! -e "$TMP_DIR/iptables-state/reject-rule" ] \ - && [ ! -e "$TMP_DIR/iptables-state/input-jump" ] || fail "维护门解除后残留 iptables 状态" -rg -q '^maintenance_gate=OPENED ' "$TMP_DIR/maintenance-evidence/maintenance-gate.status" || \ - fail "维护门未留下 OPENED 终态证据" + && [ ! -e "$TMP_DIR/iptables-state/input-jump" ] \ + && [ ! -e "$TMP_DIR/iptables-state/release-guard-jump" ] \ + && [ -e "$TMP_DIR/iptables-state/release-guard-chain" ] \ + && [ -e "$TMP_DIR/iptables-state/release-guard-reject" ] || fail "维护门解除后的 guard 状态非法" +rg -q '^maintenance_gate=OPENING ' "$TMP_DIR/maintenance-evidence/maintenance-gate.status" || \ + fail "维护门未留下最终开放提交前证据" +tail -1 "$TMP_DIR/iptables-state/calls.log" | rg -Fq \ + -- '-D INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD' || \ + fail "最终开放不是最后一条 iptables 命令" + +for failed_operation in F X; do + rm -rf "$TMP_DIR/iptables-state" "$TMP_DIR/maintenance-evidence" + { + sed -n '1,/^mkdir -p "\$evidence"/p' "$TMP_DIR/maintenance-harness.sh" + printf '%s\n' 'install_maintenance_gate' + printf 'touch %q\n' "$TMP_DIR/iptables-state/fail-$failed_operation" + printf '%s\n' 'set +e' 'release_maintenance_gate' 'release_rc=$?' 'set -e' \ + '[ "$release_rc" -ne 0 ]' '[ "$maintenance_gate_active" -eq 1 ]' \ + 'maintenance_release_guard_present' + } >"$TMP_DIR/maintenance-fail-$failed_operation.sh" + bash "$TMP_DIR/maintenance-fail-$failed_operation.sh" || fail "-$failed_operation 失败后维护门未保持封锁" +done + +# 执行真实 stop_stack:服务停止失败、unit 残留或端口残留均必须阻止快照继续。 +stop_stack_body="$(sed -n '/^stop_stack()/,/^}/p' "$TMP_DIR/remote-script.sh")" +{ + printf '%s\n' 'set -euo pipefail' 'state="${RC_TEST_TMP:?}/stop-stack-state"' 'mkdir -p "$state"' + printf '%s\n' 'log_step() { :; }' 'unit_name() { printf "%s-%s" "$1" "$2"; }' + cat <<'SH' +systemctl() { + local action="$1" + shift + if [ "${1:-}" = "--quiet" ]; then shift; fi + local unit="${1:-}" + case "$action" in + is-active) [ -f "$state/unit-$unit" ] ;; + stop) + [ "$unit" != "${STOP_FAIL_UNIT:-}" ] || return 93 + rm -f "$state/unit-$unit" + ;; + *) return 94 ;; + esac +} +port_listening() { + [ -f "$state/port-$1" ] +} +SH + printf '%s\n' "$stop_stack_body" + cat <<'SH' +for service in backend studio admin cheap-agent cheap-worker; do + touch "$state/unit-gda-staging-$service" +done +stop_stack gda-staging 48080 4173 4174 8300 9501 +for service in backend studio admin cheap-agent cheap-worker; do + [ ! -e "$state/unit-gda-staging-$service" ] +done + +touch "$state/unit-gda-staging-backend" +set +e +STOP_FAIL_UNIT=gda-staging-backend stop_stack gda-staging 48080 4173 4174 8300 9501 +stop_rc=$? +set -e +[ "$stop_rc" -ne 0 ] +[ -e "$state/unit-gda-staging-backend" ] + +rm -f "$state"/unit-* +touch "$state/port-48080" +set +e +stop_stack gda-staging 48080 4173 4174 8300 9501 +port_rc=$? +set -e +[ "$port_rc" -ne 0 ] +SH +} >"$TMP_DIR/stop-stack-harness.sh" +bash "$TMP_DIR/stop-stack-harness.sh" || fail "stop_stack 未严格传播服务或端口残留" # 在关闭 errexit 的回滚环境中执行真实 start_stack,首个启动失败必须立即返回且不得继续探活。 { @@ -326,6 +423,8 @@ rg -q 'AdminNewapiQuotaControllerTest' "$PREPARE" || fail "prepare 未运行配 rg -q 'NewapiQuotaClaimConsumerTest' "$PREPARE" || fail "prepare 未运行配额 MQ 消费测试" rg -q 'NewapiQuotaServiceImplTest' "$PREPARE" || fail "prepare 未运行配额服务测试" rg -q 'GameVersionServiceImplTest' "$PREPARE" || fail "prepare 未运行版本产物绑定测试" +rg -q 'ProjectVersionApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 project RPC 写边界测试" +rg -q 'FeedApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 feed RPC 写边界测试" rg -q 'DifyCallbackServiceImplTest' "$PREPARE" || fail "prepare 未运行生成回调产物绑定测试" rg -q 'PublishOrchestrationServiceImplTest' "$PREPARE" || fail "prepare 未运行发布编排测试" rg -q 'test-dogfood-reviewer-security\.py' "$PREPARE" || fail "prepare 未运行审核账号安全测试" @@ -363,21 +462,68 @@ tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f rg -q 'cheap-worker/\.venv/bin.*PATH' "$PREPARE" || fail "发布工具回归未显式使用 commit 内 Python" rg -q '原子切换 active' "$PREPARE" || fail "prepare 完成提示仍未使用 active 指针" -# 独立签发者用仓外私钥签名;发布机只持有信任公钥,签名、身份、run-id 和有效期任一漂移均拒绝。 +# 独立签发者用仓外私钥签名;测试 SSH 桩只把固定生产路径映射到临时目录。 cat >"$TMP_DIR/exec-ssh" <<'SH' #!/usr/bin/env bash set -euo pipefail while [ "$#" -gt 0 ] && [ "$1" != bash ]; do shift; done [ "${1:-}" = bash ] || exit 97 -exec "$@" +shift +[ "${1:-}" = -s ] || exit 97 +shift +[ "${1:-}" = -- ] || exit 97 +shift +remote_script="$(mktemp)" +trap 'rm -f "$remote_script"' EXIT +sed "s|/root/game-staging/trust/r1-attestation-ed25519.pub|$RC_TEST_TRUSTED_PUBLIC_KEY|g" \ + >"$remote_script" +PATH="$RC_TEST_FAKE_BIN:$PATH" bash "$remote_script" "$@" SH chmod +x "$TMP_DIR/exec-ssh" + +mkdir -p "$TMP_DIR/r1-fake-bin" +cat >"$TMP_DIR/r1-fake-bin/stat" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = -c ] && [ "${2:-}" = %u ]; then + printf '%s\n' "${RC_TEST_FORCE_BAD_TRUST_OWNER:-0}" + exit 0 +fi +exec /usr/bin/stat "$@" +SH +cat >"$TMP_DIR/r1-fake-bin/openssl" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = pkeyutl ] && printf '%s\n' "$*" | rg -q -- ' -verify( |$)'; then + signature="" + while [ "$#" -gt 0 ]; do + if [ "$1" = -sigfile ]; then signature="$2"; break; fi + shift + done + [ -f "$signature" ] && [ "$(wc -c <"$signature" | tr -d ' ')" -eq 64 ] + exit +fi +exec /usr/bin/openssl "$@" +SH +chmod +x "$TMP_DIR/r1-fake-bin/stat" "$TMP_DIR/r1-fake-bin/openssl" +export RC_TEST_FAKE_BIN="$TMP_DIR/r1-fake-bin" + attestation_root="$(realpath "$TMP_DIR")/attestations" mkdir -p "$attestation_root/r1" openssl genpkey -algorithm ED25519 -out "$TMP_DIR/r1-private.pem" >/dev/null 2>&1 -openssl pkey -in "$TMP_DIR/r1-private.pem" -pubout -out "$TMP_DIR/r1-public.pem" >/dev/null 2>&1 -chmod 600 "$TMP_DIR/r1-private.pem" "$TMP_DIR/r1-public.pem" -trusted_public_key="$(realpath "$TMP_DIR/r1-public.pem")" +chmod 600 "$TMP_DIR/r1-private.pem" +RC_ROOT="$(realpath "$TMP_DIR")/r1-releases" +trusted_public_key="$(realpath "$TMP_DIR")/r1-trust/r1-attestation-ed25519.pub" +mkdir -p "$(dirname "$trusted_public_key")" +cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" "$trusted_public_key" +chmod 755 "$(dirname "$trusted_public_key")" +chmod 644 "$trusted_public_key" +export RC_ROOT RC_TEST_TRUSTED_PUBLIC_KEY="$trusted_public_key" +for fixture_commit in 0000000000000000000000000000000000000000 1111111111111111111111111111111111111111; do + mkdir -p "$RC_ROOT/$fixture_commit/deploy/trust" + cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" \ + "$RC_ROOT/$fixture_commit/deploy/trust/r1-attestation-ed25519.pub" +done +[ "$(sha256sum "$trusted_public_key" | awk '{print $1}')" = "$FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256" ] \ + || fail "仓内 R1 公钥摘要与固定值不一致" read -r issued_at expires_at expired_issued_at expired_expires_at < <(python3 - <<'PY' from datetime import datetime, timedelta, timezone now = datetime.now(timezone.utc).replace(microsecond=0) @@ -393,7 +539,7 @@ cat >"$attestation_file" <"$TMP_DIR/attestation-pass.log" + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >"$TMP_DIR/attestation-pass.log" 2>&1 || { + sed -n '1,120p' "$TMP_DIR/attestation-pass.log" >&2 + fail "固定信任根的独立 attestation 正例失败" + } rg -q 'GENERATION_ATTESTATION_PASS' "$TMP_DIR/attestation-pass.log" || fail "独立 attestation 未通过" +# 调用者提供的 issuer 与公钥路径必须被忽略,不能替换固定信任根。 +RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + GENERATION_ATTESTATION_EXPECTED_ISSUER=other-issuer \ + GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$TMP_DIR/attacker.pem" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null + +cp "$trusted_public_key" "$TMP_DIR/trusted-public-key.backup" +printf 'tampered\n' >"$trusted_public_key" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$TMP_DIR/missing-public.pem" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then - fail "缺信任公钥仍允许 attestation" + fail "固定公钥内容篡改后仍允许 attestation" +fi +mv "$TMP_DIR/trusted-public-key.backup" "$trusted_public_key" +chmod 644 "$trusted_public_key" + +release_public_key="$RC_ROOT/0000000000000000000000000000000000000000/deploy/trust/r1-attestation-ed25519.pub" +printf '\n' >>"$release_public_key" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "远端公钥与 RC 仓内公钥不一致时仍允许 attestation" +fi +cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" "$release_public_key" + +chmod 600 "$trusted_public_key" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "固定公钥权限漂移后仍允许 attestation" +fi +chmod 644 "$trusted_public_key" +chmod 777 "$(dirname "$trusted_public_key")" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "固定公钥父目录可被其他用户写入时仍允许 attestation" +fi +chmod 755 "$(dirname "$trusted_public_key")" +if RC_TEST_FORCE_BAD_TRUST_OWNER=1000 RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "固定公钥 owner 不是 root 时仍允许 attestation" fi mv "$attestation_file.sig" "$attestation_file.sig.missing" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "缺 detached signature 仍允许 attestation" fi @@ -441,7 +627,6 @@ cp "$attestation_file.sig" "$TMP_DIR/valid-attestation.sig" printf 'x' >>"$attestation_file.sig" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "篡改 detached signature 仍允许 attestation" fi @@ -449,22 +634,13 @@ mv "$TMP_DIR/valid-attestation.sig" "$attestation_file.sig" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_GATE_RUN_ID=other-run GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ + GENERATION_GATE_RUN_ID=other-run \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "run-id 漂移 attestation 未拒绝" fi -if RC_COMMIT=0000000000000000000000000000000000000000 \ - GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_EXPECTED_ISSUER=other-issuer \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ - SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then - fail "issuer 漂移 attestation 未拒绝" -fi - chmod 644 "$attestation_file" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "权限过宽 attestation 未拒绝" fi @@ -473,13 +649,11 @@ ln -s "$attestation_file" "$attestation_root/r1/symlink.attestation" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="sha256:$attestation_hash:r1/symlink.attestation" \ GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" SSH_BIN="$TMP_DIR/exec-ssh" \ - bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "symlink attestation 未拒绝" fi if RC_COMMIT=1111111111111111111111111111111111111111 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "rc_commit 漂移 attestation 未拒绝" fi @@ -497,7 +671,6 @@ chmod 600 "$expired_file.sig" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="sha256:$expired_hash:r1/$expired_hash.attestation" \ GENERATION_ATTESTATION_ROOT="$attestation_root" \ - GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "已过期 attestation 未拒绝" fi diff --git a/deploy/trust/r1-attestation-ed25519.pub b/deploy/trust/r1-attestation-ed25519.pub new file mode 100644 index 00000000..39703e60 --- /dev/null +++ b/deploy/trust/r1-attestation-ed25519.pub @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEApuq2r6Rth2iP0MoBStpEFPKHWZV1rCYq14zlRFLtsBU= +-----END PUBLIC KEY----- diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java index 1b0b3e09..fbd993aa 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java @@ -35,6 +35,8 @@ public class ProjectVersionApiImpl implements ProjectVersionApi { @Override public CommonResult createForPackage(ProjectVersionCreateForPackageReqDTO req) { + // 写类 RPC 统一封锁外部 HTTP 入口,普通玩家令牌不能跨项目创建版本或改写当前版本指针。 + rejectExternalRpcWrite("project.rpc.create-for-package"); // 落包建版本:委托 GameVersionService 创建/复用 game_version(status=2 预览就绪),按 genTaskId 幂等 return success(gameVersionService.createForPackage(req)); } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java index 76541f60..7ef5aff7 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImpl.java @@ -97,13 +97,26 @@ public class GameVersionServiceImpl implements GameVersionService { throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); } - // 锁定版本和项目,保证状态、归属、当前版本指针与摘要写入在同一事务快照内完成。 - GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); - if (version == null || version.getGameId() == null) { + // 先无锁读取版本归属,只用于确定项目锁;所有业务事实必须在取得 project -> version 两把锁后重新校验。 + GameVersionDO observedVersion = gameVersionMapper.selectById(req.getVersionId()); + if (observedVersion == null || observedVersion.getGameId() == null) { log.warn("[bindRuntimeArtifact] 目标版本不存在或缺少项目归属 versionId={}", req.getVersionId()); throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); } + // 与审核发布统一 project -> version 锁序,避免两个事务互相持有对方所需行而死锁。 + ProjectDO project = projectMapper.selectByIdForUpdate(observedVersion.getGameId()); + GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId()); + if (project == null || version == null || version.getGameId() == null + || !Objects.equals(observedVersion.getGameId(), version.getGameId()) + || !Objects.equals(project.getId(), version.getGameId()) + || !Objects.equals(project.getCurrentVersionId(), version.getId())) { + log.warn("[bindRuntimeArtifact] 持锁后版本归属或项目指针漂移 versionId={}, observedGameId={}, lockedGameId={}, currentVersionId={}", + req.getVersionId(), observedVersion.getGameId(), version == null ? null : version.getGameId(), + project == null ? null : project.getCurrentVersionId()); + throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); + } + // 已发布版本只允许同一份产物幂等重放;任何改写都拒绝,避免审核后摘要被覆盖。 boolean sameArtifact = Objects.equals(version.getChecksum(), req.getChecksum()) && Objects.equals(version.getBundleSize(), req.getBundleSize()); @@ -117,13 +130,6 @@ public class GameVersionServiceImpl implements GameVersionService { throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); } - ProjectDO project = projectMapper.selectByIdForUpdate(version.getGameId()); - if (project == null || !Objects.equals(project.getCurrentVersionId(), version.getId())) { - log.warn("[bindRuntimeArtifact] 版本与项目当前指针不一致 versionId={}, gameId={}, currentVersionId={}", - version.getId(), version.getGameId(), project == null ? null : project.getCurrentVersionId()); - throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED); - } - Integer runtimeStatus; try { runtimeStatus = runtimePackageApi.getStatus(version.getId()).getCheckedData(); diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java index 3465addb..5ef9c80d 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java @@ -2,6 +2,7 @@ package com.wanxiang.huijing.game.module.project.api; import com.wanxiang.huijing.framework.common.exception.ServiceException; import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO; +import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO; import com.wanxiang.huijing.game.module.project.service.version.GameVersionService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -33,10 +34,22 @@ class ProjectVersionApiImplDogfoodTest { } @Test - void bindRuntimeArtifact_rejectsExternalRpcBeforeService() { + void createForPackage_rejectsOrdinaryPlayerExternalRpcBeforeService() { + ProjectVersionApiImpl api = createApi(); + ProjectVersionCreateForPackageReqDTO req = new ProjectVersionCreateForPackageReqDTO(); + req.setGameId(1024L); + bindOrdinaryPlayerRequest("/rpc-api/project/version/create-for-package"); + + assertThrows(ServiceException.class, () -> api.createForPackage(req)); + + verifyNoInteractions(gameVersionService); + } + + @Test + void bindRuntimeArtifact_rejectsOrdinaryPlayerExternalRpcBeforeService() { ProjectVersionApiImpl api = createApi(); ProjectVersionBindArtifactReqDTO req = artifactReq(); - bindRequest("/rpc-api/project/version/bind-runtime-artifact"); + bindOrdinaryPlayerRequest("/rpc-api/project/version/bind-runtime-artifact"); assertThrows(ServiceException.class, () -> api.bindRuntimeArtifact(req)); @@ -65,6 +78,13 @@ class ProjectVersionApiImplDogfoodTest { RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } + /** 模拟普通玩家携带自己的访问令牌直接命中内部 RPC HTTP 路径。 */ + private static void bindOrdinaryPlayerRequest(String requestUri) { + MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.addHeader("Authorization", "Bearer ordinary-player-token"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + } + /** 构造 callback 已完成 runtime 落包后的合法绑定入参。 */ private static ProjectVersionBindArtifactReqDTO artifactReq() { ProjectVersionBindArtifactReqDTO req = new ProjectVersionBindArtifactReqDTO(); diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java index f08c4301..48b3c31a 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/service/version/GameVersionServiceImplTest.java @@ -12,6 +12,7 @@ import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; +import org.mockito.InOrder; import org.mockito.Mock; import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED; @@ -59,6 +60,40 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { assertEquals(4096L, captor.getValue().getBundleSize()); } + @Test + void bindRuntimeArtifact_usesSameProjectThenVersionLockOrderAsApproval() { + ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64)); + prepareBindableVersion(); + when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1); + + gameVersionService.bindRuntimeArtifact(req); + + InOrder lockOrder = org.mockito.Mockito.inOrder(gameVersionMapper, projectMapper); + lockOrder.verify(gameVersionMapper).selectById(2048L); + lockOrder.verify(projectMapper).selectByIdForUpdate(1024L); + lockOrder.verify(gameVersionMapper).selectByIdForUpdate(2048L); + } + + @Test + void bindRuntimeArtifact_rejectsVersionProjectDriftAfterProjectLock() { + GameVersionDO observed = version(2, "", 0L); + GameVersionDO drifted = version(2, "", 0L); + drifted.setGameId(2049L); + when(gameVersionMapper.selectById(2048L)).thenReturn(observed); + ProjectDO project = new ProjectDO(); + project.setId(1024L); + project.setCurrentVersionId(2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(drifted); + + ServiceException error = assertThrows(ServiceException.class, + () -> gameVersionService.bindRuntimeArtifact(artifactReq("a".repeat(64)))); + + assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode()); + verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class)); + verifyNoInteractions(runtimePackageApi); + } + @Test void bindRuntimeArtifact_rejectsInvalidChecksumWithoutWriting() { ProjectVersionBindArtifactReqDTO req = artifactReq("ABC"); @@ -73,7 +108,7 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Test void bindRuntimeArtifact_rejectsMissingVersion() { ProjectVersionBindArtifactReqDTO req = artifactReq("b".repeat(64)); - when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(null); + when(gameVersionMapper.selectById(2048L)).thenReturn(null); ServiceException error = assertThrows(ServiceException.class, () -> gameVersionService.bindRuntimeArtifact(req)); @@ -86,6 +121,11 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Test void bindRuntimeArtifact_rejectsPublishedVersionWithDifferentArtifact() { GameVersionDO version = version(3, "c".repeat(64), 4096L); + when(gameVersionMapper.selectById(2048L)).thenReturn(version); + ProjectDO project = new ProjectDO(); + project.setId(1024L); + project.setCurrentVersionId(2048L); + when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project); when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); ServiceException error = assertThrows(ServiceException.class, @@ -97,7 +137,9 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Test void bindRuntimeArtifact_rejectsVersionWhoseProjectDoesNotPointToIt() { - when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L)); + GameVersionDO version = version(2, "", 0L); + when(gameVersionMapper.selectById(2048L)).thenReturn(version); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); ProjectDO project = new ProjectDO(); project.setId(1024L); project.setCurrentVersionId(9999L); @@ -138,6 +180,7 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { @Test void bindRuntimeArtifact_sameArtifactIsIdempotent() { GameVersionDO version = version(2, "a".repeat(64), 4096L); + when(gameVersionMapper.selectById(2048L)).thenReturn(version); when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); ProjectDO project = new ProjectDO(); project.setId(1024L); @@ -161,7 +204,9 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest { /** 准备版本、项目、runtime 三方都指向同一待绑定版本的正常场景。 */ private void prepareBindableVersion() { - when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L)); + GameVersionDO version = version(2, "", 0L); + when(gameVersionMapper.selectById(2048L)).thenReturn(version); + when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version); ProjectDO project = new ProjectDO(); project.setId(1024L); project.setCurrentVersionId(2048L);