fix(sub2api): add JD01 account local proxy

This commit is contained in:
Codex
2026-06-30 10:38:03 +00:00
parent b1fda2a479
commit b073a1c52c
9 changed files with 395 additions and 29 deletions
@@ -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:<port>: 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 <id>` 渲染同 Pod sidecar 和 Secret,再用 `validate --target <id>``accountLocalProxy` 探针验证 `http://127.0.0.1:<port>`。输出仍只允许披露 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 做代理和分组窄绑定:
+10
View File
@@ -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
+41 -4
View File
@@ -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<stri
};
}
export function accountLocalProxySummary(proxy: Sub2ApiAccountLocalProxyConfig): Record<string, unknown> {
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<string, unknown> {
const sub2api = readSub2ApiConfig();
const target = resolveTarget(sub2api, options.targetId);
@@ -131,6 +149,7 @@ export function plan(options: TargetOptions): Record<string, unknown> {
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<string, unknown> {
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);
+114 -18
View File
@@ -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))
+53 -2
View File
@@ -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<string, unknown>, 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<string, unknown>, 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<string>();
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<string, unknown>;
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<string, unknown>;
@@ -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.<id>`);
}
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://`);
}
+32
View File
@@ -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";
+47 -1
View File
@@ -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 "";
@@ -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}`;
+38 -3
View File
@@ -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},