diff --git a/.agents/skills/unidesk-sub2api/references/full.md b/.agents/skills/unidesk-sub2api/references/full.md index 8b4d6f42..6ce696a0 100644 --- a/.agents/skills/unidesk-sub2api/references/full.md +++ b/.agents/skills/unidesk-sub2api/references/full.md @@ -165,6 +165,8 @@ Codex 启动时反复出现 WebSocket reconnect、HTTPS fallback、`websocket cl Sub2API 管理 UI 的账号连接测试使用账号级 `ProxyID` / proxy URL 配置上游 HTTP transport;账号没有绑定 proxy 时会直接出站,即使 Sub2API Pod 已经有 `HTTP_PROXY` / `HTTPS_PROXY` 环境变量。看到 WebUI 账号测试连 `chatgpt.com` 超时、但 Pod 内显式走目标 proxy 可通时,先检查该账号是否属于 `manualAccounts.protected` 并声明了 `proxyBinding`。如果同一账号用 `gpt-5.2-pro` 返回 ChatGPT OAuth 不支持 Codex 的模型能力错误,但默认/受支持模型能完成 `hi` 或 `/v1/responses` smoke,这不是代理失败;按模型映射/账号能力另行处理。 +如果 WebUI 账号连接测试显示 `proxyconnect tcp: dial tcp 127.0.0.1:: connect: connection refused`,先确认该 proxy URL 是账号级 loopback 配置:在 k3s target 内,`127.0.0.1` 是 Sub2API Pod 自己,不是节点或 PK01。不要先改账号凭据、PK01 Caddy、`api.pikapython.com` 或统一 key;应在目标 `config/platform-infra/sub2api.yaml` 声明 `targets[].accountLocalProxy`,由 `platform-infra sub2api apply --target ` 渲染同 Pod sidecar 和 Secret,再用 `validate --target ` 的 `accountLocalProxy` 探针验证 `http://127.0.0.1:`。输出仍只允许披露 sourceRef、fingerprint、secretName 和 proxyUrl,不打印 proxy 密码或生成配置。 + WebUI 账号连接测试也不经过统一消费 API key 的 pool group 选择器;账号测试正常不代表 PC Codex 客户端能选中该账号。看到 WebUI 账号测试正常、但 `/responses` 或 `/v1/responses` 以 `account-select-failed` / `no available accounts` 返回 503 时,先检查该手动账号是否声明了 `groupBinding.source: pool-group`,并确认 Sub2API `account_groups` join 里存在该账号与当前统一 API key `group_id` 的绑定。对已支持的 k3s target,通过 `sync --confirm` 加入当前 `pool.groupName`;对 PK01 host-Docker target,在 host-Docker codex-pool sync adapter 补齐前,只能用最小 admin API 写入 `group_ids` 做运行面恢复,且必须只输出 account id、group id、presence/fingerprint 和 smoke 状态,不打印密钥。 受保护手动账号仍由人工在 Sub2API UI 维护 credentials/status 等字段;UniDesk 只允许通过 YAML 做代理和分组窄绑定: diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 9297cb49..55b59a52 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -333,6 +333,16 @@ targets: - 74.48.78.17 - hyueapi.com - .hyueapi.com + accountLocalProxy: + enabled: true + containerName: account-local-proxy + secretName: sub2api-account-local-proxy-config + secretKey: config.json + image: ghcr.io/sagernet/sing-box:latest + imagePullPolicy: IfNotPresent + listenHost: 127.0.0.1 + listenPort: 18789 + sourceConfigRef: config/platform-infra/egress-proxy-sources.yaml#sources.master-shadowsocks runtime: database: mode: external diff --git a/scripts/src/platform-infra/actions.ts b/scripts/src/platform-infra/actions.ts index 0c0a6c94..471b7679 100644 --- a/scripts/src/platform-infra/actions.ts +++ b/scripts/src/platform-infra/actions.ts @@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; -import type { Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig } from "./entry"; +import type { Sub2ApiAccountLocalProxyConfig, Sub2ApiEgressProxyConfig, Sub2ApiPublicExposureConfig } from "./entry"; import type { ApplyOptions, DisclosureOptions, TargetOptions } from "./options"; import { applyScript, dryRunScript } from "./apply-script"; import { readSub2ApiConfig } from "./config"; @@ -23,7 +23,7 @@ import { hostDockerApplyScript, hostDockerDryRunScript, hostDockerPlanSummary, h import { imageRef, isExternalTarget, isHostDockerTarget, managedResourceCleanupPlan, manifest, resolveTarget, targetDatabase, targetDependencyImages, targetHasSentinel, targetImage } from "./manifest"; import { applyPk01PublicExposure } from "./pk01-public-exposure"; import { policyChecks } from "./policy"; -import { prepareEgressProxySecret, prepareExternalActiveSecret, preparePublicExposureSecret } from "./secrets-and-egress"; +import { prepareAccountLocalProxySecret, prepareEgressProxySecret, prepareExternalActiveSecret, preparePublicExposureSecret } from "./secrets-and-egress"; import { statusScript } from "./status-script"; import { boolField } from "./utils"; @@ -92,6 +92,24 @@ export function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record { + return { + enabled: proxy.enabled, + mode: "sub2api-pod-loopback-account-proxy", + containerName: proxy.containerName, + secretName: proxy.secretName, + image: proxy.image, + imagePullPolicy: proxy.imagePullPolicy, + listen: `${proxy.listenHost}:${proxy.listenPort}`, + sourceConfigRef: proxy.sourceConfigRef, + sourceFingerprint: proxy.sourceFingerprint, + sourceRef: proxy.sourceRef, + sourceType: proxy.sourceType, + healthProbeUrl: proxy.healthProbeUrl, + valuesPrinted: false, + }; +} + export function plan(options: TargetOptions): Record { const sub2api = readSub2ApiConfig(); const target = resolveTarget(sub2api, options.targetId); @@ -131,6 +149,7 @@ export function plan(options: TargetOptions): Record { hostDocker, publicExposure: target.publicExposure === null ? null : publicExposureSummary(target.publicExposure), egressProxy: target.egressProxy === null ? null : egressProxySummary(target.egressProxy), + accountLocalProxy: target.accountLocalProxy === null ? null : accountLocalProxySummary(target.accountLocalProxy), }, externalDatabase: isExternalTarget(target) ? { status: target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db", @@ -189,6 +208,15 @@ export function plan(options: TargetOptions): Record { valuesPrinted: false, } : null, + accountLocalProxy: target.accountLocalProxy?.enabled + ? { + mode: `${target.id} Sub2API pod loopback proxy for manually configured account proxy URLs`, + listen: `${target.accountLocalProxy.listenHost}:${target.accountLocalProxy.listenPort}`, + sourceRef: target.accountLocalProxy.sourceRef, + sourceType: target.accountLocalProxy.sourceType, + valuesPrinted: false, + } + : null, dataStores: isExternalTarget(target) ? [ isHostDockerTarget(target) ? `PK01 local PostgreSQL through ${database.host}:${database.port}` : target.databaseMode === "external-active" ? "External PostgreSQL active from platform-db/PK01" : "External PostgreSQL pending from platform-db/Pika01", @@ -263,7 +291,15 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi }; } if (options.dryRun) { - const result = await capture(config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerDryRunScript(sub2api, target) : dryRunScript(yaml, target)); + const accountLocalProxySecretMaterial = prepareAccountLocalProxySecret(sub2api, target); + const result = await capture( + config, + target.route, + ["sh"], + isHostDockerTarget(target) + ? hostDockerDryRunScript(sub2api, target) + : dryRunScript(yaml, target, accountLocalProxySecretMaterial), + ); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), @@ -281,13 +317,14 @@ export async function apply(config: UniDeskConfig, options: ApplyOptions): Promi const secretMaterial = target.databaseMode === "external-active" ? prepareExternalActiveSecret(sub2api) : null; const publicExposureSecretMaterial = preparePublicExposureSecret(sub2api, target); const egressProxySecretMaterial = prepareEgressProxySecret(sub2api, target); + const accountLocalProxySecretMaterial = prepareAccountLocalProxySecret(sub2api, target); const result = await capture( config, target.route, ["sh"], isHostDockerTarget(target) ? hostDockerApplyScript(sub2api, target, secretMaterial) - : applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial), + : applyScript(sub2api, yaml, target, secretMaterial, publicExposureSecretMaterial, egressProxySecretMaterial, accountLocalProxySecretMaterial), ); const parsed = parseJsonOutput(result.stdout); const pk01Exposure = target.publicExposure === null ? null : await applyPk01PublicExposure(config, target); diff --git a/scripts/src/platform-infra/apply-script.ts b/scripts/src/platform-infra/apply-script.ts index 078b4ea0..ebc2303b 100644 --- a/scripts/src/platform-infra/apply-script.ts +++ b/scripts/src/platform-infra/apply-script.ts @@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; -import type { EgressProxySecretMaterial, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; +import type { AccountLocalProxySecretMaterial, EgressProxySecretMaterial, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; import { fieldManager, requiredSecretKeys, sub2apiCaddyManagedMarker } from "./entry"; import { managedResourceCleanupPlan } from "./manifest"; @@ -99,8 +99,30 @@ export function quoteEnv(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } -export function dryRunScript(yaml: string, target: Sub2ApiTargetConfig): string { +export function dryRunScript( + yaml: string, + target: Sub2ApiTargetConfig, + accountLocalProxySecretMaterial: AccountLocalProxySecretMaterial | null, +): string { const encoded = Buffer.from(yaml, "utf8").toString("base64"); + const accountLocalProxySecretName = accountLocalProxySecretMaterial?.secretName ?? "sub2api-account-local-proxy-config"; + const accountLocalProxySecretKey = accountLocalProxySecretMaterial?.secretKey ?? "config.json"; + const accountLocalProxySecretFile = accountLocalProxySecretMaterial === null + ? "" + : `printf '%s' '${Buffer.from(accountLocalProxySecretMaterial.configJson, "utf8").toString("base64")}' | base64 -d >"$tmp/account-local-proxy-config.json"`; + const accountLocalProxySecretSummary = accountLocalProxySecretMaterial === null + ? "None" + : `json.loads(${JSON.stringify(JSON.stringify({ + sourceRef: accountLocalProxySecretMaterial.sourceRef, + sourcePath: accountLocalProxySecretMaterial.sourcePath, + secretName: accountLocalProxySecretMaterial.secretName, + secretKey: accountLocalProxySecretMaterial.secretKey, + fingerprint: accountLocalProxySecretMaterial.fingerprint, + sourceBytes: accountLocalProxySecretMaterial.sourceBytes, + selectedOutbound: accountLocalProxySecretMaterial.selectedOutbound, + proxyUrl: accountLocalProxySecretMaterial.proxyUrl, + valuesPrinted: false, + }))})`; return ` set -u tmp="$(mktemp -d)" @@ -111,6 +133,11 @@ client_out="$tmp/client.out" client_err="$tmp/client.err" server_out="$tmp/server.out" server_err="$tmp/server.err" +account_local_proxy_secret_yaml="$tmp/account-local-proxy-secret.yaml" +account_local_proxy_secret_out="$tmp/account-local-proxy-secret.out" +account_local_proxy_secret_err="$tmp/account-local-proxy-secret.err" +account_local_proxy_secret_action="not-enabled" +account_local_proxy_secret_rc=0 kubectl apply --dry-run=client -f "$manifest" >"$client_out" 2>"$client_err" client_rc=$? if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then @@ -123,20 +150,44 @@ else printf '%s\\n' 'server dry-run skipped because namespace does not exist yet; first apply creates it before namespaced resources' >"$server_out" : >"$server_err" fi -python3 - "$client_rc" "$server_rc" "$namespace_exists" "$client_out" "$client_err" "$server_out" "$server_err" <<'PY' +if [ "${accountLocalProxySecretMaterial === null ? "false" : "true"}" = "true" ]; then + ${accountLocalProxySecretFile} + kubectl -n ${target.namespace} create secret generic ${accountLocalProxySecretName} \\ + --from-file=${accountLocalProxySecretKey}="$tmp/account-local-proxy-config.json" \\ + --dry-run=client -o yaml >"$account_local_proxy_secret_yaml" 2>"$account_local_proxy_secret_err" + account_local_proxy_secret_rc=$? + account_local_proxy_secret_action="client-dry-run" + if [ "$account_local_proxy_secret_rc" -eq 0 ]; then + if [ "$namespace_exists" = "true" ]; then + kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$account_local_proxy_secret_yaml" >"$account_local_proxy_secret_out" 2>>"$account_local_proxy_secret_err" + account_local_proxy_secret_rc=$? + account_local_proxy_secret_action="server-dry-run" + else + printf '%s\\n' 'server dry-run skipped because namespace does not exist yet; client dry-run succeeded' >"$account_local_proxy_secret_out" + fi + else + : >"$account_local_proxy_secret_out" + fi +else + : >"$account_local_proxy_secret_out" + : >"$account_local_proxy_secret_err" +fi +python3 - "$client_rc" "$server_rc" "$account_local_proxy_secret_rc" "$namespace_exists" "$account_local_proxy_secret_action" "$client_out" "$client_err" "$server_out" "$server_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" <<'PY' import json import sys client_rc = int(sys.argv[1]) server_rc = int(sys.argv[2]) -namespace_exists = sys.argv[3] == "true" -paths = sys.argv[4:] +account_local_proxy_secret_rc = int(sys.argv[3]) +namespace_exists = sys.argv[4] == "true" +account_local_proxy_secret_action = sys.argv[5] +paths = sys.argv[6:] def text(path): try: return open(path, encoding="utf-8").read() except FileNotFoundError: return "" payload = { - "ok": client_rc == 0 and server_rc == 0, + "ok": client_rc == 0 and server_rc == 0 and account_local_proxy_secret_rc == 0, "target": "${target.id}", "namespace": "${target.namespace}", "namespaceExistsBeforeDryRun": namespace_exists, @@ -151,6 +202,13 @@ payload = { "stdout": text(paths[2])[-4000:], "stderr": text(paths[3])[-4000:], }, + "accountLocalProxy": ${accountLocalProxySecretSummary}, + "accountLocalProxySecretDryRun": { + "exitCode": account_local_proxy_secret_rc, + "action": account_local_proxy_secret_action, + "stdout": text(paths[4])[-4000:], + "stderr": text(paths[5])[-4000:], + }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) @@ -165,6 +223,7 @@ export function applyScript( secretMaterial: ExternalActiveSecretMaterial | null, publicExposureSecretMaterial: PublicExposureSecretMaterial | null, egressProxySecretMaterial: EgressProxySecretMaterial | null, + accountLocalProxySecretMaterial: AccountLocalProxySecretMaterial | null, ): string { const encoded = Buffer.from(yaml, "utf8").toString("base64"); const cleanupPlan = managedResourceCleanupPlan(sub2api, target); @@ -173,6 +232,8 @@ export function applyScript( const publicExposureSecretKey = publicExposureSecretMaterial?.secretKey ?? "frpc.toml"; const egressProxySecretName = egressProxySecretMaterial?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName; const egressProxySecretKey = egressProxySecretMaterial?.secretKey ?? "config.json"; + const accountLocalProxySecretName = accountLocalProxySecretMaterial?.secretName ?? "sub2api-account-local-proxy-config"; + const accountLocalProxySecretKey = accountLocalProxySecretMaterial?.secretKey ?? "config.json"; if (target.databaseMode === "external-active" && secretMaterial === null) throw new Error("external-active apply requires secret material"); const externalSecretFiles = secretMaterial === null ? "" @@ -222,6 +283,22 @@ export function applyScript( noProxy: egressProxySecretMaterial.noProxy, valuesPrinted: false, }))})`; + const accountLocalProxySecretFile = accountLocalProxySecretMaterial === null + ? "" + : ` printf '%s' '${Buffer.from(accountLocalProxySecretMaterial.configJson, "utf8").toString("base64")}' | base64 -d >"$tmp/account-local-proxy-config.json"`; + const accountLocalProxySecretSummary = accountLocalProxySecretMaterial === null + ? "None" + : `json.loads(${JSON.stringify(JSON.stringify({ + sourceRef: accountLocalProxySecretMaterial.sourceRef, + sourcePath: accountLocalProxySecretMaterial.sourcePath, + secretName: accountLocalProxySecretMaterial.secretName, + secretKey: accountLocalProxySecretMaterial.secretKey, + fingerprint: accountLocalProxySecretMaterial.fingerprint, + sourceBytes: accountLocalProxySecretMaterial.sourceBytes, + selectedOutbound: accountLocalProxySecretMaterial.selectedOutbound, + proxyUrl: accountLocalProxySecretMaterial.proxyUrl, + valuesPrinted: false, + }))})`; return ` set -u tmp="$(mktemp -d)" @@ -236,6 +313,8 @@ exposure_secret_out="$tmp/exposure-secret.out" exposure_secret_err="$tmp/exposure-secret.err" egress_secret_out="$tmp/egress-secret.out" egress_secret_err="$tmp/egress-secret.err" +account_local_proxy_secret_out="$tmp/account-local-proxy-secret.out" +account_local_proxy_secret_err="$tmp/account-local-proxy-secret.err" apply_out="$tmp/apply.out" apply_err="$tmp/apply.err" cleanup_out="$tmp/cleanup.out" @@ -248,6 +327,8 @@ exposure_secret_action="not-enabled" exposure_secret_rc=0 egress_secret_action="not-enabled" egress_secret_rc=0 +account_local_proxy_secret_action="not-enabled" +account_local_proxy_secret_rc=0 if [ "$ns_rc" -eq 0 ]; then if [ "${target.databaseMode}" = "external-pending" ]; then secret_action="external-pending-not-managed" @@ -307,14 +388,25 @@ ${egressProxySecretFile} : >"$egress_secret_out" : >"$egress_secret_err" fi + if [ "${accountLocalProxySecretMaterial === null ? "false" : "true"}" = "true" ]; then +${accountLocalProxySecretFile} + kubectl -n ${target.namespace} create secret generic ${accountLocalProxySecretName} \\ + --from-file=${accountLocalProxySecretKey}="$tmp/account-local-proxy-config.json" \\ + --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$account_local_proxy_secret_out" 2>"$account_local_proxy_secret_err" + account_local_proxy_secret_rc=$? + account_local_proxy_secret_action="synced" + else + : >"$account_local_proxy_secret_out" + : >"$account_local_proxy_secret_err" + fi fi apply_rc=1 -if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ] && [ "$exposure_secret_rc" -eq 0 ] && [ "$egress_secret_rc" -eq 0 ]; then +if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ] && [ "$exposure_secret_rc" -eq 0 ] && [ "$egress_secret_rc" -eq 0 ] && [ "$account_local_proxy_secret_rc" -eq 0 ]; then kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$apply_out" 2>"$apply_err" apply_rc=$? else : >"$apply_out" - printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, or egress proxy secret step failed' >"$apply_err" + printf '%s\\n' 'skipped because namespace, app secret, public exposure secret, egress proxy secret, or account-local proxy secret step failed' >"$apply_err" fi cleanup_rc=0 if [ "$apply_rc" -eq 0 ]; then @@ -414,19 +506,21 @@ else printf '%s\\n' 'skipped because apply failed' >"$cleanup_err" cleanup_rc=1 fi -python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY' +python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$account_local_proxy_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$account_local_proxy_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY' import json import sys ns_rc = int(sys.argv[1]) secret_rc = int(sys.argv[2]) exposure_secret_rc = int(sys.argv[3]) egress_secret_rc = int(sys.argv[4]) -apply_rc = int(sys.argv[5]) -cleanup_rc = int(sys.argv[6]) -secret_action = sys.argv[7] -exposure_secret_action = sys.argv[8] -egress_secret_action = sys.argv[9] -paths = sys.argv[10:] +account_local_proxy_secret_rc = int(sys.argv[5]) +apply_rc = int(sys.argv[6]) +cleanup_rc = int(sys.argv[7]) +secret_action = sys.argv[8] +exposure_secret_action = sys.argv[9] +egress_secret_action = sys.argv[10] +account_local_proxy_secret_action = sys.argv[11] +paths = sys.argv[12:] def text(path): try: return open(path, encoding="utf-8").read() @@ -438,7 +532,7 @@ def parsed(path): except Exception: return None payload = { - "ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0, + "ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and account_local_proxy_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0, "target": "${target.id}", "namespace": "${target.namespace}", "databaseMode": "${target.databaseMode}", @@ -454,13 +548,15 @@ payload = { }, "publicExposure": ${exposureSecretSummary}, "egressProxy": ${egressProxySecretSummary}, + "accountLocalProxy": ${accountLocalProxySecretSummary}, "steps": { "namespace": {"exitCode": ns_rc, "stdout": text(paths[0])[-4000:], "stderr": text(paths[1])[-4000:]}, "secret": {"exitCode": secret_rc, "stdout": text(paths[2])[-4000:], "stderr": text(paths[3])[-4000:]}, "publicExposureSecret": {"exitCode": exposure_secret_rc, "action": exposure_secret_action, "stdout": text(paths[4])[-4000:], "stderr": text(paths[5])[-4000:]}, "egressProxySecret": {"exitCode": egress_secret_rc, "action": egress_secret_action, "stdout": text(paths[6])[-4000:], "stderr": text(paths[7])[-4000:]}, - "apply": {"exitCode": apply_rc, "stdout": text(paths[8])[-8000:], "stderr": text(paths[9])[-4000:]}, - "cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[10]), "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]}, + "accountLocalProxySecret": {"exitCode": account_local_proxy_secret_rc, "action": account_local_proxy_secret_action, "stdout": text(paths[8])[-4000:], "stderr": text(paths[9])[-4000:]}, + "apply": {"exitCode": apply_rc, "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]}, + "cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[12]), "stdout": text(paths[12])[-8000:], "stderr": text(paths[13])[-4000:]}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) diff --git a/scripts/src/platform-infra/config.ts b/scripts/src/platform-infra/config.ts index 372bd629..72617438 100644 --- a/scripts/src/platform-infra/config.ts +++ b/scripts/src/platform-infra/config.ts @@ -15,7 +15,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; -import type { Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiHostDockerConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; +import type { Sub2ApiAccountLocalProxyConfig, Sub2ApiConfig, Sub2ApiDefaults, Sub2ApiEgressProxyConfig, Sub2ApiHostDockerConfig, Sub2ApiMasterShadowsocksConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry"; import { secretRoot } from "./apply-script"; import { configPath } from "./entry"; import { normalizePublicBaseUrl, validateHostOrIp, validateHostname, validateKubernetesResourceName, validatePort, validateProxyName, validateProxyUrl } from "./manifest"; @@ -174,6 +174,7 @@ export function parseTargets(root: Record, defaultTargetId: str const hostDocker = parseHostDockerConfig(record.hostDocker, path, runtimeMode); const publicExposure = parsePublicExposureConfig(record.publicExposure, path); const egressProxy = parseEgressProxyConfig(record.egressProxy, path); + const accountLocalProxy = parseAccountLocalProxyConfig(record.accountLocalProxy, path); if (!/^[A-Za-z0-9._-]+$/u.test(id)) throw new Error(`${configPath}.${path}.id must be a simple target id`); if (!/^[A-Za-z0-9:_./-]+$/u.test(route)) throw new Error(`${configPath}.${path}.route has an unsupported format`); if (!isKubernetesName(targetNamespace)) throw new Error(`${configPath}.${path}.namespace must be a Kubernetes namespace name`); @@ -181,8 +182,9 @@ export function parseTargets(root: Record, defaultTargetId: str if (redisReplicas < 0) throw new Error(`${configPath}.${path}.redisReplicas must be >= 0`); if (runtimeMode === "host-docker" && databaseMode !== "external-active") throw new Error(`${configPath}.${path}.databaseMode must be external-active for runtimeMode=host-docker`); if (runtimeMode === "host-docker" && egressProxy?.enabled === true) throw new Error(`${configPath}.${path}.egressProxy must be disabled or omitted for runtimeMode=host-docker`); + if (runtimeMode === "host-docker" && accountLocalProxy?.enabled === true) throw new Error(`${configPath}.${path}.accountLocalProxy must be disabled or omitted for runtimeMode=host-docker`); if (runtimeMode === "host-docker" && publicExposure?.enabled === true && publicExposure.mode !== "pk01-local") throw new Error(`${configPath}.${path}.publicExposure.mode must be pk01-local for runtimeMode=host-docker`); - return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy }; + return { id, route, namespace: targetNamespace, runtimeMode, role, enabled, databaseMode, redisMode, appReplicas, redisReplicas, image, dependencyImages, hostDocker, publicExposure, egressProxy, accountLocalProxy }; }); const ids = new Set(); for (const target of targets) { @@ -372,6 +374,36 @@ export function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgr return proxy; } +export function parseAccountLocalProxyConfig(value: unknown, path: string): Sub2ApiAccountLocalProxyConfig | null { + if (value === undefined || value === null) return null; + if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.accountLocalProxy must be an object`); + const record = value as Record; + const sourceConfigRef = stringField(record, "sourceConfigRef", `${path}.accountLocalProxy`); + const source = resolveEgressProxySourceRef(sourceConfigRef, `${configPath}.${path}.accountLocalProxy.sourceConfigRef`); + if (source.sourceType !== "master-shadowsocks" || source.masterShadowsocks === null) { + throw new Error(`${configPath}.${path}.accountLocalProxy.sourceConfigRef must reference a master-shadowsocks source`); + } + const proxy: Sub2ApiAccountLocalProxyConfig = { + enabled: booleanField(record, "enabled", `${path}.accountLocalProxy`), + containerName: stringField(record, "containerName", `${path}.accountLocalProxy`), + secretName: stringField(record, "secretName", `${path}.accountLocalProxy`), + secretKey: stringField(record, "secretKey", `${path}.accountLocalProxy`), + image: stringField(record, "image", `${path}.accountLocalProxy`), + imagePullPolicy: enumField(record, "imagePullPolicy", `${path}.accountLocalProxy`, ["Always", "IfNotPresent", "Never"] as const), + listenHost: enumField(record, "listenHost", `${path}.accountLocalProxy`, ["127.0.0.1"] as const), + listenPort: integerField(record, "listenPort", `${path}.accountLocalProxy`), + sourceConfigRef, + sourceFingerprint: source.fingerprint, + sourceRef: source.sourceRef, + sourceKey: source.sourceKey, + sourceType: "master-shadowsocks", + masterShadowsocks: source.masterShadowsocks, + healthProbeUrl: source.healthProbeUrl, + }; + validateAccountLocalProxyConfig(proxy, path); + return proxy; +} + export function parseMasterShadowsocksConfig(value: unknown, path: string): Sub2ApiMasterShadowsocksConfig { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.${path} must be an object`); const record = value as Record; @@ -582,3 +614,22 @@ export function validateEgressProxyConfig(config: Sub2ApiEgressProxyConfig, path const url = new URL(config.healthProbeUrl); if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path}.egressProxy.healthProbeUrl must use http:// or https://`); } + +export function validateAccountLocalProxyConfig(config: Sub2ApiAccountLocalProxyConfig, path: string): void { + validateKubernetesResourceName(config.containerName, `${path}.accountLocalProxy.containerName`); + validateKubernetesResourceName(config.secretName, `${path}.accountLocalProxy.secretName`); + if (config.secretKey !== "config.json") throw new Error(`${configPath}.${path}.accountLocalProxy.secretKey must be config.json`); + if (!isImageReference(config.image)) throw new Error(`${configPath}.${path}.accountLocalProxy.image has an unsupported format`); + if (config.listenHost !== "127.0.0.1") throw new Error(`${configPath}.${path}.accountLocalProxy.listenHost must be 127.0.0.1`); + validatePort(config.listenPort, `${path}.accountLocalProxy.listenPort`); + if (!config.sourceConfigRef.startsWith("config/") || !config.sourceConfigRef.includes("#sources.") || config.sourceConfigRef.includes("..")) { + throw new Error(`${configPath}.${path}.accountLocalProxy.sourceConfigRef must reference config/*.yaml#sources.`); + } + if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.accountLocalProxy.sourceRef has an unsupported format`); + if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.accountLocalProxy.sourceKey must be an env key`); + validateHostOrIp(config.masterShadowsocks.serverHost, `${path}.accountLocalProxy.masterShadowsocks.serverHost`); + validatePort(config.masterShadowsocks.serverPort, `${path}.accountLocalProxy.masterShadowsocks.serverPort`); + if (!/^[A-Za-z0-9-]+$/u.test(config.masterShadowsocks.method)) throw new Error(`${configPath}.${path}.accountLocalProxy.masterShadowsocks.method has an unsupported format`); + const url = new URL(config.healthProbeUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path}.accountLocalProxy.healthProbeUrl must use http:// or https://`); +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index 9d87633b..f2b3c45a 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -124,6 +124,7 @@ export interface Sub2ApiTargetConfig { hostDocker: Sub2ApiHostDockerConfig | null; publicExposure: Sub2ApiPublicExposureConfig | null; egressProxy: Sub2ApiEgressProxyConfig | null; + accountLocalProxy: Sub2ApiAccountLocalProxyConfig | null; } export interface Sub2ApiHostDockerConfig { @@ -206,6 +207,24 @@ export interface Sub2ApiEgressProxyConfig { healthProbeUrl: string; } +export interface Sub2ApiAccountLocalProxyConfig { + enabled: boolean; + containerName: string; + secretName: string; + secretKey: string; + image: string; + imagePullPolicy: "Always" | "IfNotPresent" | "Never"; + listenHost: "127.0.0.1"; + listenPort: number; + sourceConfigRef: string; + sourceFingerprint: string; + sourceRef: string; + sourceKey: string; + sourceType: "master-shadowsocks"; + masterShadowsocks: Sub2ApiMasterShadowsocksConfig; + healthProbeUrl: string; +} + export interface Sub2ApiMasterShadowsocksConfig { serverHost: string; serverPort: number; @@ -275,6 +294,19 @@ export interface EgressProxySecretMaterial { valuesPrinted: false; } +export interface AccountLocalProxySecretMaterial { + sourceRef: string; + sourcePath: string; + secretName: string; + secretKey: string; + fingerprint: string; + sourceBytes: number; + selectedOutbound: "shadowsocks"; + configJson: string; + proxyUrl: string; + valuesPrinted: false; +} + export interface EgressProxySubscriptionCandidateSummary { sourceLine: number; kind: SubscriptionNode["kind"] | "shadowsocks"; diff --git a/scripts/src/platform-infra/manifest.ts b/scripts/src/platform-infra/manifest.ts index 5ceb30a6..5f51af34 100644 --- a/scripts/src/platform-infra/manifest.ts +++ b/scripts/src/platform-infra/manifest.ts @@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; -import type { ExternalDatabaseConfig, ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry"; +import type { ExternalDatabaseConfig, ManagedResourceCleanupPlan, Sub2ApiAccountLocalProxyConfig, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry"; import { isKubernetesName, stringField } from "./config"; import { codexPoolConfigPath, configPath, manifestPath } from "./entry"; @@ -229,6 +229,8 @@ export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiT const publicExposure = renderPublicExposureManifest(target); const egressProxy = renderEgressProxyManifest(target); const proxyEnv = sub2ApiProxyEnv(target); + const accountLocalProxyContainer = renderAccountLocalProxyContainer(target.accountLocalProxy); + const accountLocalProxyVolume = renderAccountLocalProxyVolume(target.accountLocalProxy); return `apiVersion: v1 kind: Namespace metadata: @@ -534,9 +536,11 @@ spec: volumeMounts: - name: sub2api-data mountPath: /app/data +${accountLocalProxyContainer} volumes: - name: sub2api-data emptyDir: {} +${accountLocalProxyVolume} ${publicExposure} ${egressProxy} `; @@ -551,6 +555,48 @@ export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: strin }; } +export function renderAccountLocalProxyContainer(proxy: Sub2ApiAccountLocalProxyConfig | null): string { + if (proxy === null || !proxy.enabled) return ""; + return ` - name: ${proxy.containerName} + image: ${proxy.image} + imagePullPolicy: ${proxy.imagePullPolicy} + args: + - run + - -c + - /etc/sing-box/${proxy.secretKey} + ports: + - name: account-proxy + containerPort: ${proxy.listenPort} + readinessProbe: + tcpSocket: + port: account-proxy + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + livenessProbe: + tcpSocket: + port: account-proxy + initialDelaySeconds: 30 + periodSeconds: 20 + timeoutSeconds: 5 + failureThreshold: 6 + volumeMounts: + - name: account-local-proxy-config + mountPath: /etc/sing-box/${proxy.secretKey} + subPath: ${proxy.secretKey} + readOnly: true +`; +} + +export function renderAccountLocalProxyVolume(proxy: Sub2ApiAccountLocalProxyConfig | null): string { + if (proxy === null || !proxy.enabled) return ""; + return ` - name: account-local-proxy-config + secret: + secretName: ${proxy.secretName} +`; +} + export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string { const exposure = target.publicExposure; if (exposure === null || !exposure.enabled) return ""; diff --git a/scripts/src/platform-infra/secrets-and-egress.ts b/scripts/src/platform-infra/secrets-and-egress.ts index 98b662b1..9c4ec345 100644 --- a/scripts/src/platform-infra/secrets-and-egress.ts +++ b/scripts/src/platform-infra/secrets-and-egress.ts @@ -14,7 +14,7 @@ import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; -import type { EgressProxySecretMaterial, EgressProxySubscriptionDiagnostics, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry"; +import type { AccountLocalProxySecretMaterial, EgressProxySecretMaterial, EgressProxySubscriptionDiagnostics, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiAccountLocalProxyConfig, Sub2ApiConfig, Sub2ApiEgressProxyConfig, Sub2ApiTargetConfig } from "./entry"; import { secretRoot, writeEnvFile } from "./apply-script"; import { configPath, requiredSecretKeys } from "./entry"; import { analyzeEgressProxySubscription, renderSingBoxConfig, renderSingBoxMasterShadowsocksConfig } from "./pk01-public-exposure"; @@ -166,6 +166,63 @@ export function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2Api }; } +export function prepareAccountLocalProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): AccountLocalProxySecretMaterial | null { + const proxy = target.accountLocalProxy; + if (proxy === null || !proxy.enabled) return null; + const source = readEnvSourceFile({ + root: secretRoot(sub2api), + sourceRef: proxy.sourceRef, + missingMessage: (sourcePath) => `accountLocalProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`, + }); + const password = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef); + const configJson = renderAccountLocalProxyConfig(proxy, password); + return { + sourceRef: proxy.sourceRef, + sourcePath: source.sourcePathRedacted, + secretName: proxy.secretName, + secretKey: proxy.secretKey, + fingerprint: fingerprintSecretValues({ password, configJson }, ["password", "configJson"]), + sourceBytes: Buffer.byteLength(password, "utf8"), + selectedOutbound: "shadowsocks", + configJson, + proxyUrl: `http://${proxy.listenHost}:${proxy.listenPort}`, + valuesPrinted: false, + }; +} + +export function renderAccountLocalProxyConfig(proxy: Sub2ApiAccountLocalProxyConfig, password: string): string { + return `${JSON.stringify({ + log: { level: "info", timestamp: true }, + inbounds: [ + { + type: "mixed", + tag: "account-local", + listen: proxy.listenHost, + listen_port: proxy.listenPort, + }, + ], + outbounds: [ + { + type: "shadowsocks", + tag: "master-vpn", + server: proxy.masterShadowsocks.serverHost, + server_port: proxy.masterShadowsocks.serverPort, + method: proxy.masterShadowsocks.method, + password, + }, + { type: "direct", tag: "direct" }, + { type: "block", tag: "block" }, + ], + route: { + rules: [ + { ip_is_private: true, outbound: "direct" }, + { domain_suffix: ["cluster.local", "svc"], outbound: "direct" }, + ], + final: "master-vpn", + }, + }, null, 2)}\n`; +} + export function fetchSubscription(subscriptionUrl: string): { body: string } { const shellUrl = shQuote(subscriptionUrl); const command = `curl -fsSL --connect-timeout 10 --max-time 30 ${shellUrl}`; diff --git a/scripts/src/platform-infra/validate-script.ts b/scripts/src/platform-infra/validate-script.ts index da4a29d9..987a647d 100644 --- a/scripts/src/platform-infra/validate-script.ts +++ b/scripts/src/platform-infra/validate-script.ts @@ -175,6 +175,7 @@ export function validateExternalActiveScript(sub2api: Sub2ApiConfig, target: Sub const cleanupPlan = managedResourceCleanupPlan(sub2api, target); const exposure = target.publicExposure?.enabled ? target.publicExposure : null; const proxy = target.egressProxy?.enabled ? target.egressProxy : null; + const accountLocalProxy = target.accountLocalProxy?.enabled ? target.accountLocalProxy : null; const proxyUrl = proxy === null ? "" : `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`; const egressSubscriptionDiagnostics = proxy === null ? null : safeEgressProxySubscriptionDiagnostics(sub2api, proxy); const egressSubscriptionJson = egressSubscriptionDiagnostics === null ? "None" : `json.loads(${JSON.stringify(JSON.stringify(egressSubscriptionDiagnostics))})`; @@ -272,6 +273,38 @@ fi }, }`; const publicProbeOkExpr = exposure === null ? "True" : "(public_dns_rc == 0 and public_probe_rc == 0)"; + const accountLocalProxyBlock = accountLocalProxy === null ? ` +account_local_proxy_probe_rc=0 +: >"$tmp/account-local-proxy-probe.out" +: >"$tmp/account-local-proxy-probe.err" +` : ` +if [ -n "$app_pod" ]; then + kubectl -n ${target.namespace} exec "$app_pod" -c sub2api -- sh -c 'HTTP_PROXY="$1" HTTPS_PROXY="$1" ALL_PROXY="$1" NO_PROXY="$2" curl -fsS --max-time 20 -o /dev/null -w "status=%{http_code}\\n" "$3"' sh ${shQuote(`http://${accountLocalProxy.listenHost}:${accountLocalProxy.listenPort}`)} ${shQuote("localhost,127.0.0.1,::1,.svc,.cluster.local,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16")} ${shQuote(accountLocalProxy.healthProbeUrl)} >"$tmp/account-local-proxy-probe.out" 2>"$tmp/account-local-proxy-probe.err" + account_local_proxy_probe_rc=$? +else + account_local_proxy_probe_rc=1 + printf '%s\\n' 'sub2api app pod not found' >"$tmp/account-local-proxy-probe.err" +fi +`; + const accountLocalProxyJson = accountLocalProxy === null ? "None" : `{ + "enabled": True, + "mode": "sub2api-pod-loopback-account-proxy", + "containerName": "${accountLocalProxy.containerName}", + "secretName": "${accountLocalProxy.secretName}", + "proxyUrl": "http://${accountLocalProxy.listenHost}:${accountLocalProxy.listenPort}", + "healthProbeUrl": "${accountLocalProxy.healthProbeUrl}", + "sourceRef": "${accountLocalProxy.sourceRef}", + "sourceConfigRef": "${accountLocalProxy.sourceConfigRef}", + "sourceFingerprint": "${accountLocalProxy.sourceFingerprint}", + "probe": { + "exitCode": account_local_proxy_probe_rc, + "failureFamily": egress_failure_family(account_local_proxy_probe_rc, text("account-local-proxy-probe.err", 2000), text("account-local-proxy-probe.out", 2000)), + "stdout": text("account-local-proxy-probe.out", 2000), + "stderr": text("account-local-proxy-probe.err", 2000), + }, + "valuesPrinted": False, + }`; + const accountLocalProxyOkExpr = accountLocalProxy === null ? "True" : "(account_local_proxy_probe_rc == 0)"; return ` set -u tmp="$(mktemp -d)" @@ -312,13 +345,14 @@ else fi ${publicProbeBlock} ${egressProbeBlock} -python3 - "$tmp" "$health_rc" "$root_rc" "$redis_rc" "$public_dns_rc" "$public_probe_rc" "$egress_deploy_rc" "$egress_svc_rc" "$egress_probe_rc" <<'PY' +${accountLocalProxyBlock} +python3 - "$tmp" "$health_rc" "$root_rc" "$redis_rc" "$public_dns_rc" "$public_probe_rc" "$egress_deploy_rc" "$egress_svc_rc" "$egress_probe_rc" "$account_local_proxy_probe_rc" <<'PY' import json import os import sys tmp = sys.argv[1] -health_rc, root_rc, redis_rc, public_dns_rc, public_probe_rc, egress_deploy_rc, egress_svc_rc, egress_probe_rc = [int(value) for value in sys.argv[2:]] +health_rc, root_rc, redis_rc, public_dns_rc, public_probe_rc, egress_deploy_rc, egress_svc_rc, egress_probe_rc, account_local_proxy_probe_rc = [int(value) for value in sys.argv[2:]] def rc(name): try: @@ -417,7 +451,7 @@ env_aligned = ( and env.get("DATABASE_PASSWORD_PRESENT") == "true" ) payload = { - "ok": health_rc == 0 and root_rc == 0 and redis_rc == 0 and network_policy_ok and len(missing_secret_keys) == 0 and env_aligned and not local_postgres_present and not redis_pvc_present and ${publicProbeOkExpr} and ${egressProbeOkExpr}, + "ok": health_rc == 0 and root_rc == 0 and redis_rc == 0 and network_policy_ok and len(missing_secret_keys) == 0 and env_aligned and not local_postgres_present and not redis_pvc_present and ${publicProbeOkExpr} and ${egressProbeOkExpr} and ${accountLocalProxyOkExpr}, "target": "${target.id}", "namespace": "${target.namespace}", "status": "external-db-active", @@ -434,6 +468,7 @@ payload = { }, "publicExposure": ${publicProbeJson}, "egressProxy": ${egressProbeJson}, + "accountLocalProxy": ${accountLocalProxyJson}, "checks": { "allowAllNetworkPolicy": {"exitCode": rc("networkpolicy"), "ok": network_policy_ok, "stderr": text("networkpolicy.err", 2000)}, "secretReady": {"ok": len(missing_secret_keys) == 0, "missingKeys": missing_secret_keys, "valuesPrinted": False},