diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index 72a22b87..e189d0fc 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -217,12 +217,35 @@ interface EgressProxySecretMaterial { fingerprint: string; subscriptionBytes: number; selectedOutbound: string; + subscriptionDiagnostics: EgressProxySubscriptionDiagnostics; configJson: string; proxyUrl: string; noProxy: string; valuesPrinted: false; } +interface EgressProxySubscriptionCandidateSummary { + sourceLine: number; + kind: SubscriptionNode["kind"]; + fingerprint: string; + paramKeys: string[]; + selected: boolean; +} + +interface EgressProxySubscriptionDiagnostics { + ok: boolean; + preferredOutbound: Sub2ApiEgressProxyConfig["preferredOutbound"]; + candidateCount: number; + supportedCount: number; + unsupportedCount: number; + byKind: Record; + selectedOutbound: string | null; + selectedFingerprint: string | null; + candidates: EgressProxySubscriptionCandidateSummary[]; + error?: string; + valuesPrinted: false; +} + interface ManagedResourceCleanupPlan { externalDbState: { enabled: boolean; @@ -2045,26 +2068,29 @@ function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC }); const values = source.values; const subscriptionUrl = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef); - const subscription = fetchSubscription(subscriptionUrl); - const decoded = decodeSubscription(subscription.body); - const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean); - const selected = selectSubscriptionNode(nodes, proxy.preferredOutbound); - const configJson = renderSingBoxConfig(selected, proxy); - const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`; - const noProxy = proxy.noProxy.join(","); - return { + const subscription = fetchSubscription(subscriptionUrl); + const decoded = decodeSubscription(subscription.body); + const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean); + const analyzed = analyzeEgressProxySubscription(nodes, proxy.preferredOutbound); + if (analyzed.selected === null) throw new Error(analyzed.diagnostics.error ?? `egressProxy preferredOutbound=${proxy.preferredOutbound} is absent from subscription; no fallback outbound was selected`); + const selected = analyzed.selected; + const configJson = renderSingBoxConfig(selected, proxy); + const proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`; + const noProxy = proxy.noProxy.join(","); + return { sourceRef: proxy.sourceRef, sourcePath: source.sourcePathRedacted, secretName: proxy.secretName, secretKey: proxy.secretKey, - fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]), - subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"), - selectedOutbound: selected.kind, - configJson, - proxyUrl, - noProxy, - valuesPrinted: false, - }; + fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]), + subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"), + selectedOutbound: selected.kind, + subscriptionDiagnostics: analyzed.diagnostics, + configJson, + proxyUrl, + noProxy, + valuesPrinted: false, + }; } function fetchSubscription(subscriptionUrl: string): { body: string } { @@ -2091,17 +2117,87 @@ function decodeSubscription(raw: string): string { } } +function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics { + try { + const source = readEnvSourceFile({ + root: secretRoot(sub2api), + sourceRef: proxy.sourceRef, + missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`, + }); + const subscriptionUrl = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef); + const subscription = fetchSubscription(subscriptionUrl); + const decoded = decodeSubscription(subscription.body); + const nodes = decoded.split(/\r?\n/gu).map((line) => line.trim()).filter(Boolean); + return analyzeEgressProxySubscription(nodes, proxy.preferredOutbound).diagnostics; + } catch (error) { + return { + ok: false, + preferredOutbound: proxy.preferredOutbound, + candidateCount: 0, + supportedCount: 0, + unsupportedCount: 0, + byKind: {}, + selectedOutbound: null, + selectedFingerprint: null, + candidates: [], + error: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500), + valuesPrinted: false, + }; + } +} + type SubscriptionNode = | { kind: "vless-reality"; uri: string; uuid: string; host: string; port: number; sni: string; flow: string | null; fingerprint: string | null; publicKey: string; shortId: string | null } | { kind: "hysteria2"; uri: string; password: string; host: string; port: number; sni: string | null; obfsPassword: string | null; insecure: boolean }; function selectSubscriptionNode(nodes: string[], preferred: Sub2ApiEgressProxyConfig["preferredOutbound"]): SubscriptionNode { - const parsed = nodes.map(parseSubscriptionNode).filter((node): node is SubscriptionNode => node !== null); - const preferredNode = parsed.find((node) => node.kind === preferred); - if (preferredNode !== undefined) return preferredNode; - const fallback = parsed.find((node) => node.kind === "vless-reality") ?? parsed.find((node) => node.kind === "hysteria2"); - if (fallback !== undefined) return fallback; - throw new Error("master VPN subscription does not contain a supported vless-reality or hysteria2 node"); + const analyzed = analyzeEgressProxySubscription(nodes, preferred); + if (analyzed.selected === null) throw new Error(analyzed.diagnostics.error ?? `egressProxy preferredOutbound=${preferred} is absent from subscription; no fallback outbound was selected`); + return analyzed.selected; +} + +function analyzeEgressProxySubscription(nodes: string[], preferred: Sub2ApiEgressProxyConfig["preferredOutbound"]): { diagnostics: EgressProxySubscriptionDiagnostics; selected: SubscriptionNode | null } { + const parsed = nodes + .map((uri, index) => ({ uri, sourceLine: index, node: parseSubscriptionNode(uri) })) + .filter((entry): entry is { uri: string; sourceLine: number; node: SubscriptionNode } => entry.node !== null); + const selectedEntry = parsed.find((entry) => entry.node.kind === preferred) ?? null; + const byKind: Record = {}; + for (const entry of parsed) byKind[entry.node.kind] = (byKind[entry.node.kind] ?? 0) + 1; + const candidates = parsed.map((entry): EgressProxySubscriptionCandidateSummary => { + const fingerprint = fingerprintSecretValues({ candidate: entry.uri }, ["candidate"]); + return { + sourceLine: entry.sourceLine, + kind: entry.node.kind, + fingerprint, + paramKeys: subscriptionUriParamKeys(entry.uri), + selected: selectedEntry !== null && entry.sourceLine === selectedEntry.sourceLine, + }; + }); + const selectedFingerprint = selectedEntry === null ? null : fingerprintSecretValues({ candidate: selectedEntry.uri }, ["candidate"]); + const diagnostics: EgressProxySubscriptionDiagnostics = { + ok: selectedEntry !== null, + preferredOutbound: preferred, + candidateCount: nodes.length, + supportedCount: parsed.length, + unsupportedCount: nodes.length - parsed.length, + byKind, + selectedOutbound: selectedEntry?.node.kind ?? null, + selectedFingerprint, + candidates, + valuesPrinted: false, + }; + if (selectedEntry === null) { + diagnostics.error = `egressProxy preferredOutbound=${preferred} is absent from subscription; no fallback outbound was selected`; + } + return { diagnostics, selected: selectedEntry?.node ?? null }; +} + +function subscriptionUriParamKeys(uri: string): string[] { + try { + return Array.from(new URL(uri).searchParams.keys()).sort(); + } catch { + return []; + } } function parseSubscriptionNode(uri: string): SubscriptionNode | null { @@ -2834,13 +2930,14 @@ function applyScript( sourcePath: egressProxySecretMaterial.sourcePath, secretName: egressProxySecretMaterial.secretName, secretKey: egressProxySecretMaterial.secretKey, - fingerprint: egressProxySecretMaterial.fingerprint, - subscriptionBytes: egressProxySecretMaterial.subscriptionBytes, - selectedOutbound: egressProxySecretMaterial.selectedOutbound, - proxyUrl: egressProxySecretMaterial.proxyUrl, - noProxy: egressProxySecretMaterial.noProxy, - valuesPrinted: false, - }))})`; + fingerprint: egressProxySecretMaterial.fingerprint, + subscriptionBytes: egressProxySecretMaterial.subscriptionBytes, + selectedOutbound: egressProxySecretMaterial.selectedOutbound, + subscriptionDiagnostics: egressProxySecretMaterial.subscriptionDiagnostics, + proxyUrl: egressProxySecretMaterial.proxyUrl, + noProxy: egressProxySecretMaterial.noProxy, + valuesPrinted: false, + }))})`; return ` set -u tmp="$(mktemp -d)" @@ -3693,6 +3790,9 @@ function validateExternalActiveScript(sub2api: Sub2ApiConfig, target: Sub2ApiTar const exposure = target.publicExposure?.enabled ? target.publicExposure : null; const proxy = target.egressProxy?.enabled ? target.egressProxy : 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))})`; + const egressSubscriptionOkExpr = egressSubscriptionDiagnostics === null ? "True" : (egressSubscriptionDiagnostics.ok ? "True" : "False"); const publicProbeBlock = exposure === null ? ` public_dns_rc=0 public_probe_rc=0 @@ -3741,13 +3841,14 @@ fi "deploymentName": "${proxy.deploymentName}", "serviceName": "${proxy.serviceName}", "proxyUrl": "${proxyUrl}", - "healthProbeUrl": "${proxy.healthProbeUrl}", - "applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"}, - "applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"}, - "deployment": { - "exitCode": egress_deploy_rc, - "ready": deployment_ready(load("egress-deploy")), - "stderr": text("egress-deploy.err", 2000), + "healthProbeUrl": "${proxy.healthProbeUrl}", + "applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"}, + "applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"}, + "subscription": ${egressSubscriptionJson}, + "deployment": { + "exitCode": egress_deploy_rc, + "ready": deployment_ready(load("egress-deploy")), + "stderr": text("egress-deploy.err", 2000), }, "service": { "exitCode": egress_svc_rc, @@ -3755,14 +3856,15 @@ fi "clusterIP": ((load("egress-svc") or {}).get("spec") or {}).get("clusterIP"), "stderr": text("egress-svc.err", 2000), }, - "probe": { - "exitCode": egress_probe_rc, - "stdout": text("egress-probe.out", 2000), - "stderr": text("egress-probe.err", 2000), - }, - "valuesPrinted": False, - }`; - const egressProbeOkExpr = proxy === null ? "True" : "(egress_deploy_rc == 0 and egress_svc_rc == 0 and deployment_ready(load('egress-deploy')) and egress_probe_rc == 0)"; + "probe": { + "exitCode": egress_probe_rc, + "failureFamily": egress_failure_family(egress_probe_rc, text("egress-probe.err", 2000), text("egress-probe.out", 2000)), + "stdout": text("egress-probe.out", 2000), + "stderr": text("egress-probe.err", 2000), + }, + "valuesPrinted": False, + }`; + const egressProbeOkExpr = proxy === null ? "True" : `(egress_deploy_rc == 0 and egress_svc_rc == 0 and deployment_ready(load('egress-deploy')) and egress_probe_rc == 0 and ${egressSubscriptionOkExpr})`; const publicProbeJson = exposure === null ? "None" : `{ "enabled": True, "publicBaseUrl": "${exposure.publicBaseUrl}", @@ -3860,6 +3962,24 @@ def text(name, limit=4000): except FileNotFoundError: return "" +def egress_failure_family(exit_code, stderr, stdout): + if exit_code == 0: + return "ok" + combined = f"{stderr}\\n{stdout}".lower() + if "connection refused" in combined or "could not connect" in combined: + return "proxy-unreachable-or-refused" + if "connection reset" in combined or "recv failure" in combined: + return "proxy-upstream-reset" + if "timed out" in combined or "timeout" in combined or "deadline exceeded" in combined: + return "proxy-upstream-timeout" + if "empty reply" in combined or "eof" in combined: + return "proxy-upstream-eof" + if "could not resolve" in combined or "no such host" in combined: + return "dns-failure" + if "status=502" in combined or "http 502" in combined: + return "upstream-http-502" + return "unknown" + def is_allow_all_network_policy(item): if not isinstance(item, dict): return False