fix(platform-infra): expose egress proxy node diagnostics (#820)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
+164
-44
@@ -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<string, number>;
|
||||
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<string, number> = {};
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user