#!/usr/bin/env bash # 在受控 Nacos 临时配置窗口内预置狗粮账号,并在任何退出路径恢复原配置。 # 本脚本不创建额度池资源;调用既有 provision-dogfood-users.sh 完成账号与额度绑定。 set -Eeuo pipefail readonly EXPECTED_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION' readonly DATA_ID='huijing-server-staging.yaml' readonly NACOS_GROUP='DEFAULT_GROUP' readonly CODE_USERNAME_OR_PASSWORD_ERROR='1002090005' readonly CODE_PASSWORD_REGISTER_DISABLED='1002090007' readonly CODE_DOGFOOD_SMS_DISABLED='1002090008' readonly CODE_AD_DOGFOOD_DISABLED='1111002006' MODE='transaction' WATCHDOG_RECOVERY_DIR='' if [ "${1:-}" = '--watchdog' ]; then MODE='watchdog' WATCHDOG_RECOVERY_DIR="${2:-}" elif [ -n "${1:-}" ]; then printf 'DOGFOOD_NACOS_TX_FAIL 未知参数:%s\n' "$1" >&2 exit 2 fi DOGFOOD_PROVISION_ACK="${DOGFOOD_PROVISION_ACK:-}" ADMIN_TOKEN_FILE="${ADMIN_TOKEN_FILE:-}" BASE="${BASE:-http://100.64.0.7:48080}" TENANT="${TENANT:-1}" NACOS_BASE_URL="${NACOS_BASE_URL:-http://100.64.0.8:8848/nacos}" NACOS_USERNAME="${NACOS_USERNAME:-nacos}" NACOS_PASSWORD="${NACOS_PASSWORD:-}" NACOS_ENV_FILE="${NACOS_ENV_FILE:-/root/huijing-dev.env}" BACKEND_UNIT="${BACKEND_UNIT:-gda-staging-backend}" PROVISION_BIN="${PROVISION_BIN:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/provision-dogfood-users.sh}" CURL_BIN="${CURL_BIN:-curl}" SYSTEMCTL_BIN="${SYSTEMCTL_BIN:-systemctl}" SYSTEMD_RUN_BIN="${SYSTEMD_RUN_BIN:-systemd-run}" FLOCK_BIN="${FLOCK_BIN:-flock}" SLEEP_BIN="${SLEEP_BIN:-sleep}" RESTART_TIMEOUT_SEC="${RESTART_TIMEOUT_SEC:-180}" PROVISION_TTL_SEC="${PROVISION_TTL_SEC:-900}" RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}" EVIDENCE_DIR="${EVIDENCE_DIR:-/root/game-staging/evidence/dogfood-provision-$RUN_ID}" LOCK_FILE="${LOCK_FILE:-/root/game-staging/locks/dogfood-provision.lock}" EVENT_LOG='' log() { local line line="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" printf '%s\n' "$line" [ -z "$EVENT_LOG" ] || printf '%s\n' "$line" >>"$EVENT_LOG" } die() { printf 'DOGFOOD_NACOS_TX_FAIL %s\n' "$*" >&2; exit 1; } [[ "$RESTART_TIMEOUT_SEC" =~ ^[0-9]+$ ]] && [ "$RESTART_TIMEOUT_SEC" -ge 30 ] || \ die 'RESTART_TIMEOUT_SEC 必须是至少 30 秒的整数' [[ "$PROVISION_TTL_SEC" =~ ^[0-9]+$ ]] && [ "$PROVISION_TTL_SEC" -ge 60 ] || \ die 'PROVISION_TTL_SEC 必须是至少 60 秒的整数' [ "$MODE" = watchdog ] || [ "$DOGFOOD_PROVISION_ACK" = "$EXPECTED_ACK" ] || \ die "DOGFOOD_PROVISION_ACK 不匹配;必须精确确认 $EXPECTED_ACK" [ "$MODE" = watchdog ] || [ -x "$PROVISION_BIN" ] || die "预置脚本不可执行:$PROVISION_BIN" [ "$MODE" = watchdog ] || [ -n "$ADMIN_TOKEN_FILE" ] || die 'ADMIN_TOKEN_FILE 必填' [ "$MODE" = watchdog ] || { [ -f "$ADMIN_TOKEN_FILE" ] && [ ! -L "$ADMIN_TOKEN_FILE" ]; } || \ die 'ADMIN_TOKEN_FILE 必须是非 symlink 普通文件' if [ "$MODE" != watchdog ]; then token_mode="$(stat -f '%Lp' "$ADMIN_TOKEN_FILE" 2>/dev/null || stat -c '%a' "$ADMIN_TOKEN_FILE")" [ "$token_mode" = 600 ] || die "ADMIN_TOKEN_FILE 权限必须为 0600 actual=$token_mode" fi command -v python3 >/dev/null || die '缺少 python3' python3 -c 'import yaml' >/dev/null 2>&1 || die '缺少 PyYAML,无法安全合并既有 Nacos YAML' mkdir -p "$(dirname "$LOCK_FILE")" exec 9>"$LOCK_FILE" if [ "$MODE" = watchdog ]; then "$FLOCK_BIN" -w 30 9 || die 'watchdog 无法取得预置恢复锁' else "$FLOCK_BIN" -n 9 || die '已有 dogfood 账号预置事务运行,拒绝并发' fi if [ -z "$NACOS_PASSWORD" ]; then [ -f "$NACOS_ENV_FILE" ] || die "缺少 Nacos 凭据来源:$NACOS_ENV_FILE" set -a # shellcheck disable=SC1090 . "$NACOS_ENV_FILE" set +a NACOS_PASSWORD="${NACOS_PASSWORD:-}" fi [ -n "$NACOS_PASSWORD" ] || die 'Nacos 口令为空' umask 077 if [ "$MODE" = watchdog ]; then [ -d "$WATCHDOG_RECOVERY_DIR" ] && [ ! -L "$WATCHDOG_RECOVERY_DIR" ] || die 'watchdog recovery 目录非法' EVIDENCE_DIR="$(dirname "$WATCHDOG_RECOVERY_DIR")" else [ ! -e "$EVIDENCE_DIR" ] || die "证据目录已存在,拒绝覆盖:$EVIDENCE_DIR" mkdir -p "$EVIDENCE_DIR" fi chmod 700 "$EVIDENCE_DIR" EVENT_LOG="$EVIDENCE_DIR/transaction.log" [ "$MODE" = watchdog ] || : >"$EVENT_LOG" chmod 600 "$EVENT_LOG" BODY_FILE="$(mktemp)" NACOS_PASSWORD_FILE="$(mktemp)" NACOS_TOKEN_FILE="$(mktemp)" printf '%s' "$NACOS_PASSWORD" >"$NACOS_PASSWORD_FILE" chmod 600 "$NACOS_PASSWORD_FILE" "$NACOS_TOKEN_FILE" ORIGINAL_CONFIG="$EVIDENCE_DIR/nacos-original.yaml" TEMP_CONFIG="$EVIDENCE_DIR/nacos-temporary.yaml" ORIGINAL_STATUS_FILE="$EVIDENCE_DIR/nacos-original.status" SUMMARY_FILE="$EVIDENCE_DIR/transaction.status" RECOVERY_DIR="$EVIDENCE_DIR/watchdog" WATCHDOG_UNIT="dogfood-provision-watchdog-${RUN_ID//[^A-Za-z0-9_-]/-}" restore_required=0 finalizing=0 watchdog_armed=0 NACOS_TOKEN='' LAST_HTTP='' LAST_BODY='' cleanup_body() { rm -f "$BODY_FILE" "$NACOS_PASSWORD_FILE" "$NACOS_TOKEN_FILE"; } request() { local method="$1" url="$2" data="${3:-}" token="${4:-}" content_file="${5:-}" local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method") if [ -n "$token" ]; then [ "$method" != GET ] || args+=(-G) # token 通过 mode600 临时文件交给 curl,避免出现在进程参数或日志。 args+=(--data-urlencode "accessToken@$NACOS_TOKEN_FILE") fi [ -z "$data" ] || args+=(--data "$data") [ -z "$content_file" ] || args+=(--data-urlencode "content@$content_file") LAST_HTTP="$("$CURL_BIN" "${args[@]}" "$url" 2>/dev/null || printf '000')" LAST_BODY="$(<"$BODY_FILE")" } json_value() { local path="$1" printf '%s' "$LAST_BODY" | python3 -c ' import json,sys try: value=json.load(sys.stdin) for part in sys.argv[1].split("."): value=value.get(part) if isinstance(value,dict) else None if isinstance(value,bool): print("true" if value else "false") else: print("" if value is None else value) except Exception: print("") ' "$path" } nacos_login() { # 用户名与口令分别 URL encode;响应 token 只驻内存和临时响应文件,不落 evidence。 LAST_HTTP="$("$CURL_BIN" -sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' \ -X POST --data-urlencode "username=$NACOS_USERNAME" --data-urlencode "password@$NACOS_PASSWORD_FILE" \ "$NACOS_BASE_URL/v1/auth/login" 2>/dev/null || printf '000')" LAST_BODY="$(<"$BODY_FILE")" [ "$LAST_HTTP" = 200 ] || return 1 NACOS_TOKEN="$(json_value accessToken)" [ -n "$NACOS_TOKEN" ] || return 1 printf '%s' "$NACOS_TOKEN" >"$NACOS_TOKEN_FILE" chmod 600 "$NACOS_TOKEN_FILE" } nacos_get() { local output="$1" request GET "$NACOS_BASE_URL/v1/cs/configs?dataId=$DATA_ID&group=$NACOS_GROUP&tenant=" '' "$NACOS_TOKEN" # 从 curl 原始响应文件复制,保留 Nacos 配置末尾换行等字节;命令替换会吞尾换行,不能用于快照。 cp "$BODY_FILE" "$output" chmod 600 "$output" [ "$LAST_HTTP" = 200 ] || [ "$LAST_HTTP" = 404 ] } nacos_md5() { python3 - "$1" <<'PY' import hashlib,sys with open(sys.argv[1],"rb") as handle: print(hashlib.md5(handle.read()).hexdigest()) PY } nacos_publish() { local content_file="$1" expected_md5="$2" [[ "$expected_md5" =~ ^[0-9a-f]{32}$ ]] || return 1 request POST "$NACOS_BASE_URL/v1/cs/configs" \ "dataId=$DATA_ID&group=$NACOS_GROUP&tenant=&casMd5=$expected_md5" "$NACOS_TOKEN" "$content_file" [ "$LAST_HTTP" = 200 ] && [ "$LAST_BODY" = true ] } verify_nacos_exact() { local expected_file="$1" actual="$EVIDENCE_DIR/nacos-verify.tmp" nacos_get "$actual" || return 1 [ "$LAST_HTTP" = 200 ] || return 1 cmp -s "$expected_file" "$actual" } restart_backend() { "$SYSTEMCTL_BIN" restart "$BACKEND_UNIT" local deadline=$((SECONDS + RESTART_TIMEOUT_SEC)) while [ "$SECONDS" -lt "$deadline" ]; do request GET "$BASE/actuator/health" if [ "$LAST_HTTP" = 200 ] && [ "$(json_value status)" = UP ]; then return 0 fi "$SLEEP_BIN" 2 done return 1 } expect_code() { local name="$1" expected="$2" method="$3" path="$4" data="${5:-}" auth="${6:-}" local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method" -H "tenant-id: $TENANT") [ -z "$auth" ] || args+=(-H "Authorization: Bearer $auth") [ -z "$data" ] || args+=(-H 'Content-Type: application/json' --data "$data") LAST_HTTP="$("$CURL_BIN" "${args[@]}" "$BASE$path" 2>/dev/null || printf '000')" LAST_BODY="$(<"$BODY_FILE")" local actual actual="$(json_value code)" if [ "$LAST_HTTP" != 200 ] || [ "$actual" != "$expected" ]; then log "$name FAIL expected=$expected actual=${actual:-none} http=$LAST_HTTP" return 1 fi log "$name PASS code=$expected" } verify_temporary_runtime() { # 此时还没有真实玩家 token,只校验免登录边界;资金守卫由预置脚本在首次真实登录后验证。 expect_code 'provisioning SMS still disabled' "$CODE_DOGFOOD_SMS_DISABLED" POST \ /app-api/passport/send-sms-code '{"mobile":"13800000000"}' expect_code 'provisioning ad still disabled' "$CODE_AD_DOGFOOD_DISABLED" GET /app-api/ad/slot/list-enabled } verify_restored_runtime() { local suffix="${RUN_ID//[^0-9]/}" local username="dogfoodverify${suffix:0:15}" local login_body="{\"username\":\"$username\",\"password\":\"DogfoodVerify1\"}" expect_code 'password register disabled' "$CODE_PASSWORD_REGISTER_DISABLED" POST \ /app-api/passport/password-register "$login_body" # 查无用户统一返回用户名/密码错误;若登录开关关闭会返回另一业务码。 expect_code 'password login enabled' "$CODE_USERNAME_OR_PASSWORD_ERROR" POST \ /app-api/passport/password-login "$login_body" expect_code 'SMS disabled' "$CODE_DOGFOOD_SMS_DISABLED" POST \ /app-api/passport/send-sms-code '{"mobile":"13800000000"}' expect_code 'ad disabled' "$CODE_AD_DOGFOOD_DISABLED" GET /app-api/ad/slot/list-enabled } restore_original() { local current="$EVIDENCE_DIR/nacos-before-restore.tmp" temporary_md5 log '开始恢复原 Nacos 配置' nacos_login || return 1 nacos_get "$current" || return 1 [ "$LAST_HTTP" = 200 ] || return 1 cmp -s "$TEMP_CONFIG" "$current" || return 1 temporary_md5="$(nacos_md5 "$TEMP_CONFIG")" nacos_publish "$ORIGINAL_CONFIG" "$temporary_md5" || return 1 verify_nacos_exact "$ORIGINAL_CONFIG" || return 1 restart_backend || return 1 verify_restored_runtime || return 1 log '原 Nacos 配置与狗粮安全边界恢复完成' } arm_watchdog() { mkdir -p "$RECOVERY_DIR" chmod 700 "$RECOVERY_DIR" { printf 'schema=dogfood-provision-watchdog/1\n' printf 'run_id=%s\n' "$RUN_ID" printf 'ttl_sec=%s\n' "$PROVISION_TTL_SEC" printf 'temporary_md5=%s\n' "$(nacos_md5 "$TEMP_CONFIG")" printf 'original_md5=%s\n' "$(nacos_md5 "$ORIGINAL_CONFIG")" } >"$RECOVERY_DIR/metadata" : >"$RECOVERY_DIR/armed" chmod 600 "$RECOVERY_DIR/metadata" "$RECOVERY_DIR/armed" if ! "$SYSTEMD_RUN_BIN" --quiet --collect --unit="$WATCHDOG_UNIT" \ --on-active="${PROVISION_TTL_SEC}s" --property=Type=oneshot \ --property=Restart=on-failure --property=RestartSec=15s \ /bin/bash "$(realpath "${BASH_SOURCE[0]}")" --watchdog "$RECOVERY_DIR"; then rm -f "$RECOVERY_DIR/armed" return 1 fi watchdog_armed=1 log "独立恢复 watchdog 已注册 unit=$WATCHDOG_UNIT ttl=${PROVISION_TTL_SEC}s" } disarm_watchdog() { [ "$watchdog_armed" -eq 1 ] || return 0 rm -f "$RECOVERY_DIR/armed" "$SYSTEMCTL_BIN" stop "$WATCHDOG_UNIT.timer" "$WATCHDOG_UNIT.service" >/dev/null 2>&1 || true printf 'disarmed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/disarmed" chmod 600 "$RECOVERY_DIR/disarmed" watchdog_armed=0 } if [ "$MODE" = watchdog ]; then EVENT_LOG="$EVIDENCE_DIR/watchdog.log" touch "$EVENT_LOG" chmod 600 "$EVENT_LOG" [ -f "$RECOVERY_DIR/armed" ] || { log 'watchdog 已解除,无需恢复' cleanup_body exit 0 } log 'watchdog TTL 到期,开始独立恢复 Nacos 安全配置' if restore_original; then rm -f "$RECOVERY_DIR/armed" printf 'WATCHDOG_RESTORE=PASS at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/watchdog.status" chmod 600 "$RECOVERY_DIR/watchdog.status" cleanup_body exit 0 fi printf 'WATCHDOG_RESTORE=FAILED at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/watchdog.status" chmod 600 "$RECOVERY_DIR/watchdog.status" cleanup_body die "watchdog 恢复失败,保留 armed 标记等待人工处置:$RECOVERY_DIR" fi finalize() { local main_rc=$? restore_rc=0 [ "$finalizing" -eq 0 ] || return finalizing=1 trap - EXIT INT TERM set +e if [ "$restore_required" -eq 1 ]; then restore_original restore_rc=$? fi cleanup_body if [ "$restore_rc" -ne 0 ]; then printf 'restore_failed main_rc=%s restore_rc=%s evidence=%s\n' "$main_rc" "$restore_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE" chmod 600 "$SUMMARY_FILE" printf 'DOGFOOD_NACOS_TX_RESTORE_FAIL evidence=%s\n' "$EVIDENCE_DIR" >&2 exit 70 fi disarm_watchdog if [ "$main_rc" -ne 0 ]; then printf 'provision_failed rc=%s restored=true evidence=%s\n' "$main_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE" chmod 600 "$SUMMARY_FILE" printf 'DOGFOOD_NACOS_TX_FAIL rc=%s restored=true evidence=%s\n' "$main_rc" "$EVIDENCE_DIR" >&2 exit "$main_rc" fi printf 'pass restored=true evidence=%s\n' "$EVIDENCE_DIR" >"$SUMMARY_FILE" chmod 600 "$SUMMARY_FILE" printf 'DOGFOOD_NACOS_TX_PASS restored=true evidence=%s\n' "$EVIDENCE_DIR" } trap finalize EXIT trap 'exit 130' INT trap 'exit 143' TERM log "开始狗粮账号预置配置事务 runId=$RUN_ID" nacos_login || die 'Nacos 登录失败' nacos_get "$ORIGINAL_CONFIG" || die "读取 $DATA_ID 失败 http=$LAST_HTTP" printf '%s\n' "$LAST_HTTP" >"$ORIGINAL_STATUS_FILE" chmod 600 "$ORIGINAL_STATUS_FILE" [ "$LAST_HTTP" = 200 ] || die 'Nacos dataId 不存在,无法执行内容 CAS;拒绝创建临时配置' # 在原 YAML 上只覆盖预置窗口所需键;原文始终单独留证并在 EXIT trap 逐字恢复。 PROVISION_TTL_SEC="$PROVISION_TTL_SEC" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY' import datetime,os,sys,yaml source,target=sys.argv[1:] with open(source,encoding="utf-8") as f: doc=yaml.safe_load(f) or {} if not isinstance(doc,dict): raise SystemExit("Nacos staging 配置顶层必须是 YAML object") # 若历史配置使用过 dotted key,先移除同义键,避免 Spring 扁平化后与嵌套键冲突;原文仍会逐字恢复。 for key in ( "huijing.dogfood.enabled", "huijing.dogfood.provisioning-enabled", "huijing.dogfood.provisioning-expires-at", "huijing.dogfood.provisioning-run-id", "wanxiang.passport.password-register-enabled", ): doc.pop(key,None) huijing=doc.setdefault("huijing",{}) if not isinstance(huijing,dict): raise SystemExit("huijing 必须是 object") dogfood=huijing.setdefault("dogfood",{}) if not isinstance(dogfood,dict): raise SystemExit("huijing.dogfood 必须是 object") dogfood["enabled"]=True dogfood["provisioning-enabled"]=True dogfood["provisioning-run-id"]=os.environ["RUN_ID"] expires_at=datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(seconds=int(os.environ["PROVISION_TTL_SEC"])) dogfood["provisioning-expires-at"]=expires_at.isoformat().replace("+00:00","Z") wanxiang=doc.setdefault("wanxiang",{}) if not isinstance(wanxiang,dict): raise SystemExit("wanxiang 必须是 object") passport=wanxiang.setdefault("passport",{}) if not isinstance(passport,dict): raise SystemExit("wanxiang.passport 必须是 object") passport["password-register-enabled"]=True with open(target,"w",encoding="utf-8") as f: yaml.safe_dump(doc,f,allow_unicode=True,sort_keys=False) PY chmod 600 "$TEMP_CONFIG" arm_watchdog || die '无法注册独立恢复 watchdog,未修改 Nacos' original_md5="$(nacos_md5 "$ORIGINAL_CONFIG")" nacos_publish "$TEMP_CONFIG" "$original_md5" || die "发布临时 Nacos 配置 CAS 失败 http=$LAST_HTTP" restore_required=1 verify_nacos_exact "$TEMP_CONFIG" || die '临时 Nacos 配置回读不一致' restart_backend || die '临时配置发布后 backend 未恢复健康' verify_temporary_runtime || die '窄 provisioning 开关未生效或安全守卫被关闭' log '临时配置已生效,执行既有账号预置脚本' env -u ADMIN_USERNAME -u ADMIN_PASSWORD \ PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PROVISIONING_MODE=narrow \ ADMIN_TOKEN_FILE="$ADMIN_TOKEN_FILE" "$PROVISION_BIN" >"$EVIDENCE_DIR/provision.log" 2>&1 chmod 600 "$EVIDENCE_DIR/provision.log" log '账号预置脚本完成,退出时恢复原配置'