From 61d05dccf9ee3b366b5591eb946971338090e24b Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 9 Jul 2026 15:27:04 +0200 Subject: [PATCH] fix: validate Sub2API pool key through admin API --- .../unidesk-sub2api/references/codex-pool.md | 2 +- config/platform-infra/sub2api.yaml | 2 +- .../remote-python-sync.ts | 84 ++++++++++++++++--- scripts/src/platform-infra/apply-script.ts | 1 + scripts/src/platform-infra/entry.ts | 1 + scripts/src/platform-infra/host-docker.ts | 3 +- .../src/platform-infra/secrets-and-egress.ts | 7 +- 7 files changed, 85 insertions(+), 15 deletions(-) diff --git a/.agents/skills/unidesk-sub2api/references/codex-pool.md b/.agents/skills/unidesk-sub2api/references/codex-pool.md index daa9b8a6..e23b9cd9 100644 --- a/.agents/skills/unidesk-sub2api/references/codex-pool.md +++ b/.agents/skills/unidesk-sub2api/references/codex-pool.md @@ -40,7 +40,7 @@ bun scripts/cli.ts platform-infra sub2api codex-pool cleanup-probes --target D60 - `manualAccounts.protected`: 已在 Sub2API 手动创建/维护、且必须排除在 UniDesk-managed Codex pool credentials 和 sentinel 控制之外的账号。默认不得改 credentials/status/schedulable/priority/capacity/loadFactor;只有显式声明 `proxyBinding` 时,`sync --confirm` 才允许把该账号的 `proxy_id` 对齐到 YAML 目标的 egress proxy;只有显式声明 `groupBinding.source: pool-group` 时,才允许把该账号加入统一消费 API key 使用的 pool group。`targetIds` 可选;省略表示所有 target 都保护该账号,设置后只在匹配 target 上纳入 proxy/group 窄同步和 sentinel-probe 拒绝列表,避免 PK01-only 手动账号漂移卡住 JD01 pool。 - Sentinel 配置、marker-only 判定、镜像、report/probe 和远端 job/poll 边界见 [sentinel.md](sentinel.md)。 -`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts,并创建/复用统一 API key。k3s target 还会写入统一 API key Secret 并部署/更新哨兵资源;PK01 host-Docker target 只写 Sub2API runtime 和 host-Docker env 文件。`sync` 不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。若受保护手动账号在运行面缺失,`sync`/`validate` 会报告 manual account drift;不要自动创建、删除、接管或从 YAML 移除该账号。 +`sync --confirm` 会登录 Sub2API admin、创建/更新 group、创建/更新 YAML 中的 `unidesk-codex-*` accounts,并创建/复用统一 API key。k3s target 还会写入统一 API key Secret 并部署/更新哨兵资源;PK01 host-Docker target 只写 Sub2API runtime 和 host-Docker env 文件。host-Docker `validate` 也先使用 env 中的 Sub2API admin 凭据登录管理 API;如果 env 的统一 `API_KEY` 缺失或不匹配,但管理 API 已返回名为 YAML `pool.apiKeyName` 的现有 key,`validate` 只恢复该现有 key 到 host-Docker env 后继续校验,不创建新 key。`sync` 不把既有 managed account 直接恢复为 `schedulable=true`。恢复只由哨兵在读取 Sub2API runtime `schedulable=false` 后触发 recovery probe,并在 marker 命中时执行。`sync` 默认不删除 YAML 中缺席的 managed account。只有明确退役上游时才使用 `sync --confirm --prune-removed` 删除缺席且 `extra.unidesk_managed=true` 的 `unidesk-codex-*` account。对 `manualAccounts.protected`,`sync` 只执行 YAML 显式允许的窄同步;当前允许项是从目标 `egressProxy` 创建/更新 Sub2API internal proxy 记录并绑定 `proxy_id`,以及把受保护手动账号加入当前 `pool.groupName`。它仍不接管该账号凭据、status、schedulable、priority/capacity/loadFactor 或哨兵状态。若受保护手动账号在运行面缺失,`sync`/`validate` 会报告 manual account drift;不要自动创建、删除、接管或从 YAML 移除该账号。 `trace --request-id ` 是只读 request 追溯报表,不触发 probe、不修改账号。默认输出请求开始/最终状态、failover、`account_select_failed`、窗口内 `account_temp_unschedulable`、admin schedulable 写入计数和当前账号快照;`reason=failover-attempted-no-candidate` 表示 Sub2API 已进入自动切号,但排除当前失败账号后没有可用候选。需要机器处理时使用 `--raw`,需要原始匹配行时加 `--show-lines`。 diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 5605b9bf..35684c28 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -82,7 +82,7 @@ targets: redisReplicas: 1 image: repository: docker.1panel.live/weishaw/sub2api - tag: 0.1.147 + tag: 0.1.146 pullPolicy: IfNotPresent dependencyImages: redis: docker.m.daocloud.io/library/redis:8-alpine diff --git a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts index 4f521a40..2ecdea08 100644 --- a/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts +++ b/scripts/src/platform-infra-sub2api-codex/remote-python-sync.ts @@ -1070,15 +1070,77 @@ def generate_api_key(): alphabet = string.ascii_letters + string.digits return "sk-unidesk-codex-" + "".join(secrets.choice(alphabet) for _ in range(48)) +def sub2api_key_value(item): + if not isinstance(item, dict): + return None + key = item.get("key") + return key if isinstance(key, str) and key else None + +def find_pool_key_by_name(keys): + return next((item for item in keys if isinstance(item, dict) and item.get("name") == POOL_API_KEY_NAME), None) + def existing_sub2api_pool_api_key(token): try: - existing = next((item for item in list_user_keys(token) if item.get("name") == POOL_API_KEY_NAME), None) + existing = find_pool_key_by_name(list_user_keys(token)) except Exception: return None - if not isinstance(existing, dict): - return None - key = existing.get("key") - return key if isinstance(key, str) and key else None + return sub2api_key_value(existing) + +def resolve_pool_api_key_for_validate(token): + secret_value = None + secret_error = None + try: + secret_value = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY) + except Exception as exc: + if RUNTIME_MODE != "host-docker": + raise + secret_error = str(exc) + keys = list_user_keys(token) + matched_item = None + if secret_value: + matched_item = next((item for item in keys if isinstance(item, dict) and item.get("key") == secret_value), None) + if isinstance(matched_item, dict) and matched_item.get("name") == POOL_API_KEY_NAME: + return secret_value, matched_item, { + "ok": True, + "source": pool_api_key_secret_location(), + "sourceAction": "kept-existing", + "secretPresent": True, + "lookup": "matched-by-value", + "hostEnvAction": "none", + "valuesPrinted": False, + } + if RUNTIME_MODE == "host-docker": + named_item = find_pool_key_by_name(keys) + named_key = sub2api_key_value(named_item) + if named_key: + host_env_action = write_host_env_value(POOL_API_KEY_SECRET_KEY, named_key) + return named_key, named_item, { + "ok": True, + "source": "sub2api-admin-api", + "sourceAction": "recovered-missing" if not secret_value else "repaired-mismatch", + "secretPresent": bool(secret_value), + "lookup": "matched-by-name", + "hostEnvAction": host_env_action, + "hostEnvPath": HOST_DOCKER_ENV_PATH, + "hostEnvKey": POOL_API_KEY_SECRET_KEY, + "valuesPrinted": False, + } + if not secret_value: + detail = f"; envReadError={secret_error}" if secret_error else "" + raise RuntimeError(f"{pool_api_key_secret_location()} missing and Sub2API admin API key named {POOL_API_KEY_NAME} did not return a recoverable key{detail}") + matched_name = matched_item.get("name") if isinstance(matched_item, dict) else None + raise RuntimeError(f"{pool_api_key_secret_location()} present but does not match pool key {POOL_API_KEY_NAME}; matchedName={matched_name or '-'}; admin API did not return a recoverable named key") + if not secret_value: + raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing") + return secret_value, matched_item, { + "ok": isinstance(matched_item, dict) and matched_item.get("name") == POOL_API_KEY_NAME, + "source": pool_api_key_secret_location(), + "sourceAction": "kept-existing", + "secretPresent": True, + "lookup": "matched-by-value" if matched_item else "missing-in-admin-api", + "hostEnvAction": "not-applicable", + "valuesPrinted": False, + } def ensure_api_key_secret(group_id, token): existing = None @@ -2614,6 +2676,7 @@ def run_sync(): "webSocketsV2": ws_v2_status, "tempUnschedulable": temp_unschedulable_status, "apiKey": { + "ok": True, "name": POOL_API_KEY_NAME, "secret": pool_api_key_secret_location(), "secretAction": secret_action, @@ -2633,11 +2696,9 @@ def run_sync(): } def run_validate(): - api_key = decode_secret_value(POOL_API_KEY_SECRET_NAME, POOL_API_KEY_SECRET_KEY) - if not api_key: - raise RuntimeError(f"{POOL_API_KEY_SECRET_NAME}.{POOL_API_KEY_SECRET_KEY} missing") admin_email, token, admin_compliance = login() - key_item = next((item for item in list_user_keys(token) if item.get("key") == api_key), None) + api_key, key_item, api_key_source = resolve_pool_api_key_for_validate(token) + api_key_ok = isinstance(key_item, dict) and key_item.get("name") == POOL_API_KEY_NAME owner_balance = None owner_concurrency = None if key_item is not None and key_item.get("user_id") is not None: @@ -2656,7 +2717,7 @@ def run_validate(): runtime_capabilities = validate_runtime_capabilities(token) sentinel = sentinel_runtime_status() return { - "ok": gateway["ok"] is True and responses_smoke["ok"] is True and (owner_concurrency is None or owner_concurrency["ok"] is True) and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True and manual_account_protections.get("ok") is True and sentinel.get("ok") is True and runtime_capabilities.get("ok") is True, + "ok": api_key_ok is True and gateway["ok"] is True and responses_smoke["ok"] is True and (owner_concurrency is None or owner_concurrency["ok"] is True) and capacity_status["ok"] is True and load_factor_status["ok"] is True and ws_v2_status["ok"] is True and temp_unschedulable_status["ok"] is True and manual_account_protections.get("ok") is True and sentinel.get("ok") is True and runtime_capabilities.get("ok") is True, "degraded": bool(responses_smoke.get("degraded")) or bool(compact_evidence.get("degraded")) or bool(responses_evidence.get("degraded")) or runtime_capabilities.get("ok") is not True, "mode": "validate", "namespace": NAMESPACE, @@ -2664,7 +2725,10 @@ def run_validate(): "appPod": APP_POD, "admin": {"email": admin_email, "tokenPrinted": False, "compliance": admin_compliance}, "apiKey": { + "ok": api_key_ok, + "name": POOL_API_KEY_NAME, "secret": pool_api_key_secret_location(), + "source": api_key_source, "sub2apiId": key_item.get("id") if isinstance(key_item, dict) else None, "userId": key_item.get("user_id") if isinstance(key_item, dict) else None, "groupId": key_item.get("group_id") if isinstance(key_item, dict) else None, diff --git a/scripts/src/platform-infra/apply-script.ts b/scripts/src/platform-infra/apply-script.ts index bda7f5ac..8cd979ff 100644 --- a/scripts/src/platform-infra/apply-script.ts +++ b/scripts/src/platform-infra/apply-script.ts @@ -258,6 +258,7 @@ export function applyScript( appSourcePath: secretMaterial.appSourcePath, sourceAction: secretMaterial.action, fingerprint: secretMaterial.fingerprint, + adminEmail: secretMaterial.adminEmail, valuesPrinted: false, }))})`; const exposureSecretFile = publicExposureSecretMaterial === null diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 33a87e89..71a3dc62 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -287,6 +287,7 @@ export interface ExternalActiveSecretMaterial { appSourcePath: string; action: "create" | "update" | "none"; fingerprint: string; + adminEmail: string; values: Record; } diff --git a/scripts/src/platform-infra/host-docker.ts b/scripts/src/platform-infra/host-docker.ts index e305cf9e..6d9f474a 100644 --- a/scripts/src/platform-infra/host-docker.ts +++ b/scripts/src/platform-infra/host-docker.ts @@ -91,7 +91,7 @@ export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC REDIS_POOL_SIZE: "32", REDIS_MIN_IDLE_CONNS: "2", REDIS_ENABLE_TLS: "false", - ADMIN_EMAIL: "admin@sub2api.platform-infra.local", + ADMIN_EMAIL: secretMaterial.adminEmail, ADMIN_PASSWORD: secretMaterial.values.ADMIN_PASSWORD, JWT_SECRET: secretMaterial.values.JWT_SECRET, TOTP_ENCRYPTION_KEY: secretMaterial.values.TOTP_ENCRYPTION_KEY, @@ -240,6 +240,7 @@ export function hostDockerApplyScript(sub2api: Sub2ApiConfig, target: Sub2ApiTar appSourcePath: secretMaterial.appSourcePath, sourceAction: secretMaterial.action, fingerprint: secretMaterial.fingerprint, + adminEmail: secretMaterial.adminEmail, valuesPrinted: false, }; return ` diff --git a/scripts/src/platform-infra/secrets-and-egress.ts b/scripts/src/platform-infra/secrets-and-egress.ts index acff5a94..af6dcfbe 100644 --- a/scripts/src/platform-infra/secrets-and-egress.ts +++ b/scripts/src/platform-infra/secrets-and-egress.ts @@ -64,6 +64,7 @@ export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalAct const existing = existedBefore ? parseEnvFile(readFileSync(appSourcePath, "utf8")) : {}; const next = { ...existing }; next.POSTGRES_PASSWORD = dbPassword; + if (next.ADMIN_EMAIL === undefined || next.ADMIN_EMAIL.length === 0) next.ADMIN_EMAIL = "admin@sub2api.platform-infra.local"; if (next.ADMIN_PASSWORD === undefined || next.ADMIN_PASSWORD.length === 0) next.ADMIN_PASSWORD = randomBytes(16).toString("hex"); if (next.JWT_SECRET === undefined || next.JWT_SECRET.length === 0) next.JWT_SECRET = randomBytes(32).toString("hex"); if (next.TOTP_ENCRYPTION_KEY === undefined || next.TOTP_ENCRYPTION_KEY.length === 0) next.TOTP_ENCRYPTION_KEY = randomBytes(32).toString("hex"); @@ -75,12 +76,13 @@ export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalAct }; const missing = requiredSecretKeys.filter((key) => values[key].length === 0); if (missing.length > 0) throw new Error(`external-active app secret source is missing ${missing.join(", ")}`); + const managedAppSourceKeys = [...requiredSecretKeys, "ADMIN_EMAIL"] as const; const action = !existedBefore ? "create" - : requiredSecretKeys.some((key) => existing[key] !== values[key]) + : managedAppSourceKeys.some((key) => existing[key] !== next[key]) ? "update" : "none"; - if (action !== "none") writeEnvFile(appSourcePath, { ...existing, ...values }); + if (action !== "none") writeEnvFile(appSourcePath, { ...existing, ...values, ADMIN_EMAIL: next.ADMIN_EMAIL }); return { sourceRef: database.sourceRef, sourcePath: dbSource.sourcePathRedacted, @@ -88,6 +90,7 @@ export function prepareExternalActiveSecret(sub2api: Sub2ApiConfig): ExternalAct appSourcePath: redactRepoPath(appSourcePath), action, fingerprint: fingerprintSecretValues(values, [...requiredSecretKeys]), + adminEmail: next.ADMIN_EMAIL, values, }; }