fix: route D601 Sub2API egress through Shadowsocks (#843)

Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
Lyon
2026-06-25 00:53:36 +08:00
committed by GitHub
parent 3d11a2ff50
commit 7f367f3045
3 changed files with 181 additions and 36 deletions
@@ -0,0 +1,16 @@
x-unidesk-log-rotation: &unidesk-log-rotation
driver: json-file
options:
max-size: "${UNIDESK_DOCKER_LOG_MAX_SIZE:-20m}"
max-file: "${UNIDESK_DOCKER_LOG_MAX_FILE:-3}"
services:
sub2api-master-egress-proxy:
image: ghcr.io/shadowsocks/ssserver-rust:latest
container_name: unidesk-sub2api-master-egress-proxy
restart: unless-stopped
ports:
- "0.0.0.0:18792:18792/tcp"
volumes:
- /root/unidesk/.state/secrets/platform-infra/sub2api-master-egress-proxy.config.json:/etc/shadowsocks-rust/config.json:ro
logging: *unidesk-log-rotation
+9 -6
View File
@@ -30,7 +30,7 @@ defaults:
image: image:
repository: weishaw/sub2api repository: weishaw/sub2api
tag: 0.1.136 tag: 0.1.138
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
dependencyImages: dependencyImages:
@@ -80,7 +80,7 @@ targets:
redisReplicas: 1 redisReplicas: 1
image: image:
repository: 127.0.0.1:5000/platform-infra/sub2api repository: 127.0.0.1:5000/platform-infra/sub2api
tag: 0.1.136 tag: 0.1.138
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
dependencyImages: dependencyImages:
postgres: docker.m.daocloud.io/library/postgres:18-alpine postgres: docker.m.daocloud.io/library/postgres:18-alpine
@@ -150,10 +150,13 @@ targets:
image: 127.0.0.1:5000/platform-infra/sing-box:latest image: 127.0.0.1:5000/platform-infra/sing-box:latest
imagePullPolicy: IfNotPresent imagePullPolicy: IfNotPresent
listenPort: 10808 listenPort: 10808
sourceRef: platform-infra/master-vpn-subscription.env sourceRef: platform-infra/sub2api-master-egress-proxy.env
sourceKey: MASTER_VPN_SUBSCRIPTION_URL sourceKey: SUB2API_MASTER_SHADOWSOCKS_PASSWORD
sourceType: subscription-url sourceType: master-shadowsocks
preferredOutbound: hysteria2 masterShadowsocks:
serverHost: 74.48.78.17
serverPort: 18792
method: chacha20-ietf-poly1305
applyToSub2Api: true applyToSub2Api: true
applyToSentinel: true applyToSentinel: true
healthProbeUrl: https://www.gstatic.com/generate_204 healthProbeUrl: https://www.gstatic.com/generate_204
+156 -30
View File
@@ -153,14 +153,21 @@ interface Sub2ApiEgressProxyConfig {
listenPort: number; listenPort: number;
sourceRef: string; sourceRef: string;
sourceKey: string; sourceKey: string;
sourceType: "subscription-url"; sourceType: "subscription-url" | "master-shadowsocks";
preferredOutbound: "vless-reality" | "hysteria2"; preferredOutbound: "vless-reality" | "hysteria2" | null;
masterShadowsocks: Sub2ApiMasterShadowsocksConfig | null;
noProxy: string[]; noProxy: string[];
applyToSub2Api: boolean; applyToSub2Api: boolean;
applyToSentinel: boolean; applyToSentinel: boolean;
healthProbeUrl: string; healthProbeUrl: string;
} }
interface Sub2ApiMasterShadowsocksConfig {
serverHost: string;
serverPort: number;
method: string;
}
interface ExternalDatabaseConfig { interface ExternalDatabaseConfig {
mode: "external"; mode: "external";
sourceRef: string; sourceRef: string;
@@ -215,9 +222,9 @@ interface EgressProxySecretMaterial {
secretName: string; secretName: string;
secretKey: string; secretKey: string;
fingerprint: string; fingerprint: string;
subscriptionBytes: number; sourceBytes: number;
selectedOutbound: string; selectedOutbound: string;
subscriptionDiagnostics: EgressProxySubscriptionDiagnostics; sourceDiagnostics: EgressProxySubscriptionDiagnostics;
configJson: string; configJson: string;
proxyUrl: string; proxyUrl: string;
noProxy: string; noProxy: string;
@@ -226,7 +233,7 @@ interface EgressProxySecretMaterial {
interface EgressProxySubscriptionCandidateSummary { interface EgressProxySubscriptionCandidateSummary {
sourceLine: number; sourceLine: number;
kind: SubscriptionNode["kind"]; kind: SubscriptionNode["kind"] | "shadowsocks";
fingerprint: string; fingerprint: string;
paramKeys: string[]; paramKeys: string[];
selected: boolean; selected: boolean;
@@ -234,6 +241,7 @@ interface EgressProxySubscriptionCandidateSummary {
interface EgressProxySubscriptionDiagnostics { interface EgressProxySubscriptionDiagnostics {
ok: boolean; ok: boolean;
sourceType: Sub2ApiEgressProxyConfig["sourceType"];
preferredOutbound: Sub2ApiEgressProxyConfig["preferredOutbound"]; preferredOutbound: Sub2ApiEgressProxyConfig["preferredOutbound"];
candidateCount: number; candidateCount: number;
supportedCount: number; supportedCount: number;
@@ -720,8 +728,13 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
if (value === undefined || value === null) return null; if (value === undefined || value === null) return null;
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`); if (typeof value !== "object" || Array.isArray(value)) throw new Error(`${configPath}.${path}.egressProxy must be an object`);
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
const sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url"] as const); const sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] as const);
const preferredOutbound = enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const); const preferredOutbound = sourceType === "subscription-url" || record.preferredOutbound !== undefined
? enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const)
: null;
const masterShadowsocks = sourceType === "master-shadowsocks"
? parseMasterShadowsocksConfig(record.masterShadowsocks, `${path}.egressProxy.masterShadowsocks`)
: null;
const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const); const imagePullPolicy = enumField(record, "imagePullPolicy", `${path}.egressProxy`, ["Always", "IfNotPresent", "Never"] as const);
const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`); const noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`);
const proxy: Sub2ApiEgressProxyConfig = { const proxy: Sub2ApiEgressProxyConfig = {
@@ -737,6 +750,7 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`), sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`),
sourceType, sourceType,
preferredOutbound, preferredOutbound,
masterShadowsocks,
noProxy, noProxy,
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`), applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`), applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`),
@@ -746,6 +760,16 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
return proxy; return proxy;
} }
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>;
return {
serverHost: stringField(record, "serverHost", path),
serverPort: integerField(record, "serverPort", path),
method: stringField(record, "method", path),
};
}
function parseRuntime(root: Record<string, unknown>): Sub2ApiConfig["runtime"] { function parseRuntime(root: Record<string, unknown>): Sub2ApiConfig["runtime"] {
const value = root.runtime; const value = root.runtime;
if (value === undefined) throw new Error(`${configPath}.runtime must be an object`); if (value === undefined) throw new Error(`${configPath}.runtime must be an object`);
@@ -907,6 +931,15 @@ function validateEgressProxyConfig(config: Sub2ApiEgressProxyConfig, path: strin
validatePort(config.listenPort, `${path}.egressProxy.listenPort`); validatePort(config.listenPort, `${path}.egressProxy.listenPort`);
if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`); if (!/^[A-Za-z0-9_./-]+$/u.test(config.sourceRef)) throw new Error(`${configPath}.${path}.egressProxy.sourceRef has an unsupported format`);
if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.egressProxy.sourceKey must be an env key`); if (!/^[A-Z0-9_]+$/u.test(config.sourceKey)) throw new Error(`${configPath}.${path}.egressProxy.sourceKey must be an env key`);
if (config.sourceType === "subscription-url" && config.preferredOutbound === null) {
throw new Error(`${configPath}.${path}.egressProxy.preferredOutbound is required for sourceType=subscription-url`);
}
if (config.sourceType === "master-shadowsocks") {
if (config.masterShadowsocks === null) throw new Error(`${configPath}.${path}.egressProxy.masterShadowsocks is required for sourceType=master-shadowsocks`);
validateHostOrIp(config.masterShadowsocks.serverHost, `${path}.egressProxy.masterShadowsocks.serverHost`);
validatePort(config.masterShadowsocks.serverPort, `${path}.egressProxy.masterShadowsocks.serverPort`);
if (!/^[A-Za-z0-9-]+$/u.test(config.masterShadowsocks.method)) throw new Error(`${configPath}.${path}.egressProxy.masterShadowsocks.method has an unsupported format`);
}
for (const entry of config.noProxy) { for (const entry of config.noProxy) {
if (!/^[A-Za-z0-9.:_/*-]+$/u.test(entry)) throw new Error(`${configPath}.${path}.egressProxy.noProxy contains an unsupported entry`); if (!/^[A-Za-z0-9.:_/*-]+$/u.test(entry)) throw new Error(`${configPath}.${path}.egressProxy.noProxy contains an unsupported entry`);
} }
@@ -1482,7 +1515,7 @@ metadata:
app.kubernetes.io/part-of: platform-infra app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk app.kubernetes.io/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id} unidesk.ai/runtime-node: ${target.id}
unidesk.ai/proxy-source: master-vpn-subscription unidesk.ai/proxy-source: ${proxy.sourceType}
spec: spec:
selector: selector:
app.kubernetes.io/name: ${proxy.deploymentName} app.kubernetes.io/name: ${proxy.deploymentName}
@@ -1503,7 +1536,7 @@ metadata:
app.kubernetes.io/part-of: platform-infra app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk app.kubernetes.io/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id} unidesk.ai/runtime-node: ${target.id}
unidesk.ai/proxy-source: master-vpn-subscription unidesk.ai/proxy-source: ${proxy.sourceType}
spec: spec:
replicas: 1 replicas: 1
selector: selector:
@@ -1518,7 +1551,8 @@ spec:
app.kubernetes.io/part-of: platform-infra app.kubernetes.io/part-of: platform-infra
annotations: annotations:
unidesk.ai/proxy-source-ref: "${proxy.sourceRef}" unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
unidesk.ai/proxy-preferred-outbound: "${proxy.preferredOutbound}" unidesk.ai/proxy-source-type: "${proxy.sourceType}"
unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}"
spec: spec:
containers: containers:
- name: proxy - name: proxy
@@ -1590,7 +1624,7 @@ function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Record<st
function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unknown> { function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unknown> {
return { return {
enabled: proxy.enabled, enabled: proxy.enabled,
mode: "master-vpn-subscription-http-proxy", mode: proxy.sourceType === "master-shadowsocks" ? "master-shadowsocks-http-proxy" : "master-vpn-subscription-http-proxy",
deploymentName: proxy.deploymentName, deploymentName: proxy.deploymentName,
serviceName: proxy.serviceName, serviceName: proxy.serviceName,
secretName: proxy.secretName, secretName: proxy.secretName,
@@ -1600,6 +1634,13 @@ function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unk
sourceRef: proxy.sourceRef, sourceRef: proxy.sourceRef,
sourceType: proxy.sourceType, sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound, preferredOutbound: proxy.preferredOutbound,
masterShadowsocks: proxy.masterShadowsocks === null
? null
: {
serverHost: proxy.masterShadowsocks.serverHost,
serverPort: proxy.masterShadowsocks.serverPort,
method: proxy.masterShadowsocks.method,
},
applyToSub2Api: proxy.applyToSub2Api, applyToSub2Api: proxy.applyToSub2Api,
applyToSentinel: proxy.applyToSentinel, applyToSentinel: proxy.applyToSentinel,
healthProbeUrl: proxy.healthProbeUrl, healthProbeUrl: proxy.healthProbeUrl,
@@ -1684,9 +1725,10 @@ function plan(options: TargetOptions): Record<string, unknown> {
: null, : null,
egressProxy: target.egressProxy?.enabled egressProxy: target.egressProxy?.enabled
? { ? {
mode: `${target.id} in-cluster HTTP proxy client to the master VPN subscription`, mode: `${target.id} in-cluster HTTP proxy client to ${target.egressProxy.sourceType}`,
service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`, service: `${target.egressProxy.serviceName}.${target.namespace}.svc.cluster.local:${target.egressProxy.listenPort}`,
sourceRef: target.egressProxy.sourceRef, sourceRef: target.egressProxy.sourceRef,
sourceType: target.egressProxy.sourceType,
applyToSub2Api: target.egressProxy.applyToSub2Api, applyToSub2Api: target.egressProxy.applyToSub2Api,
applyToSentinel: target.egressProxy.applyToSentinel, applyToSentinel: target.egressProxy.applyToSentinel,
valuesPrinted: false, valuesPrinted: false,
@@ -2064,28 +2106,49 @@ function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
const source = readEnvSourceFile({ const source = readEnvSourceFile({
root: secretRoot(sub2api), root: secretRoot(sub2api),
sourceRef: proxy.sourceRef, sourceRef: proxy.sourceRef,
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`, missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
}); });
const values = source.values; const values = source.values;
const subscriptionUrl = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef); if (proxy.sourceType === "master-shadowsocks") {
const subscription = fetchSubscription(subscriptionUrl); const password = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
const decoded = decodeSubscription(subscription.body); const configJson = renderSingBoxMasterShadowsocksConfig(proxy, password);
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 proxyUrl = `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`;
const noProxy = proxy.noProxy.join(","); const noProxy = proxy.noProxy.join(",");
return { 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",
sourceDiagnostics: masterShadowsocksDiagnostics(proxy),
configJson,
proxyUrl,
noProxy,
valuesPrinted: false,
};
}
if (proxy.preferredOutbound === null) throw new Error(`egressProxy preferredOutbound is required for sourceType=${proxy.sourceType}`);
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 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, sourceRef: proxy.sourceRef,
sourcePath: source.sourcePathRedacted, sourcePath: source.sourcePathRedacted,
secretName: proxy.secretName, secretName: proxy.secretName,
secretKey: proxy.secretKey, secretKey: proxy.secretKey,
fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]), fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"), sourceBytes: Buffer.byteLength(subscription.body, "utf8"),
selectedOutbound: selected.kind, selectedOutbound: selected.kind,
subscriptionDiagnostics: analyzed.diagnostics, sourceDiagnostics: analyzed.diagnostics,
configJson, configJson,
proxyUrl, proxyUrl,
noProxy, noProxy,
@@ -2119,11 +2182,13 @@ function decodeSubscription(raw: string): string {
function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics { function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics {
try { try {
if (proxy.sourceType === "master-shadowsocks") return masterShadowsocksDiagnostics(proxy);
const source = readEnvSourceFile({ const source = readEnvSourceFile({
root: secretRoot(sub2api), root: secretRoot(sub2api),
sourceRef: proxy.sourceRef, sourceRef: proxy.sourceRef,
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the master VPN subscription source first`, missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
}); });
if (proxy.preferredOutbound === null) throw new Error(`egressProxy preferredOutbound is required for sourceType=${proxy.sourceType}`);
const subscriptionUrl = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef); const subscriptionUrl = requiredEnvValue(source.values, proxy.sourceKey, proxy.sourceRef);
const subscription = fetchSubscription(subscriptionUrl); const subscription = fetchSubscription(subscriptionUrl);
const decoded = decodeSubscription(subscription.body); const decoded = decodeSubscription(subscription.body);
@@ -2132,6 +2197,7 @@ function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: S
} catch (error) { } catch (error) {
return { return {
ok: false, ok: false,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound, preferredOutbound: proxy.preferredOutbound,
candidateCount: 0, candidateCount: 0,
supportedCount: 0, supportedCount: 0,
@@ -2146,17 +2212,59 @@ function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: S
} }
} }
function masterShadowsocksDiagnostics(proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics {
const shadowsocks = proxy.masterShadowsocks;
if (shadowsocks === null) {
return {
ok: false,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
candidateCount: 0,
supportedCount: 0,
unsupportedCount: 0,
byKind: {},
selectedOutbound: null,
selectedFingerprint: null,
candidates: [],
error: "masterShadowsocks is missing",
valuesPrinted: false,
};
}
const fingerprint = fingerprintSecretValues({ outbound: `${shadowsocks.serverHost}:${shadowsocks.serverPort}:${shadowsocks.method}` }, ["outbound"]);
return {
ok: true,
sourceType: "master-shadowsocks",
preferredOutbound: null,
candidateCount: 1,
supportedCount: 1,
unsupportedCount: 0,
byKind: { shadowsocks: 1 },
selectedOutbound: "shadowsocks",
selectedFingerprint: fingerprint,
candidates: [
{
sourceLine: 1,
kind: "shadowsocks",
fingerprint,
paramKeys: ["serverHost", "serverPort", "method"],
selected: true,
},
],
valuesPrinted: false,
};
}
type SubscriptionNode = 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: "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 }; | { 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 { function selectSubscriptionNode(nodes: string[], preferred: Exclude<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): SubscriptionNode {
const analyzed = analyzeEgressProxySubscription(nodes, preferred); 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`); 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; return analyzed.selected;
} }
function analyzeEgressProxySubscription(nodes: string[], preferred: Sub2ApiEgressProxyConfig["preferredOutbound"]): { diagnostics: EgressProxySubscriptionDiagnostics; selected: SubscriptionNode | null } { function analyzeEgressProxySubscription(nodes: string[], preferred: Exclude<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): { diagnostics: EgressProxySubscriptionDiagnostics; selected: SubscriptionNode | null } {
const parsed = nodes const parsed = nodes
.map((uri, index) => ({ uri, sourceLine: index, node: parseSubscriptionNode(uri) })) .map((uri, index) => ({ uri, sourceLine: index, node: parseSubscriptionNode(uri) }))
.filter((entry): entry is { uri: string; sourceLine: number; node: SubscriptionNode } => entry.node !== null); .filter((entry): entry is { uri: string; sourceLine: number; node: SubscriptionNode } => entry.node !== null);
@@ -2176,6 +2284,7 @@ function analyzeEgressProxySubscription(nodes: string[], preferred: Sub2ApiEgres
const selectedFingerprint = selectedEntry === null ? null : fingerprintSecretValues({ candidate: selectedEntry.uri }, ["candidate"]); const selectedFingerprint = selectedEntry === null ? null : fingerprintSecretValues({ candidate: selectedEntry.uri }, ["candidate"]);
const diagnostics: EgressProxySubscriptionDiagnostics = { const diagnostics: EgressProxySubscriptionDiagnostics = {
ok: selectedEntry !== null, ok: selectedEntry !== null,
sourceType: "subscription-url",
preferredOutbound: preferred, preferredOutbound: preferred,
candidateCount: nodes.length, candidateCount: nodes.length,
supportedCount: parsed.length, supportedCount: parsed.length,
@@ -2277,6 +2386,23 @@ function renderSingBoxConfig(node: SubscriptionNode, proxy: Sub2ApiEgressProxyCo
obfs: node.obfsPassword === null ? undefined : { type: "salamander", password: node.obfsPassword }, obfs: node.obfsPassword === null ? undefined : { type: "salamander", password: node.obfsPassword },
tls: { enabled: true, server_name: node.sni ?? node.host, insecure: node.insecure }, tls: { enabled: true, server_name: node.sni ?? node.host, insecure: node.insecure },
}; };
return renderSingBoxProxyConfig(outbound, proxy);
}
function renderSingBoxMasterShadowsocksConfig(proxy: Sub2ApiEgressProxyConfig, password: string): string {
const shadowsocks = proxy.masterShadowsocks;
if (shadowsocks === null) throw new Error("masterShadowsocks config is required");
return renderSingBoxProxyConfig({
type: "shadowsocks",
tag: "master-vpn",
server: shadowsocks.serverHost,
server_port: shadowsocks.serverPort,
method: shadowsocks.method,
password,
}, proxy);
}
function renderSingBoxProxyConfig(outbound: Record<string, unknown>, proxy: Sub2ApiEgressProxyConfig): string {
const config = stripUndefined({ const config = stripUndefined({
log: { level: "info", timestamp: true }, log: { level: "info", timestamp: true },
inbounds: [ inbounds: [
@@ -2931,9 +3057,9 @@ function applyScript(
secretName: egressProxySecretMaterial.secretName, secretName: egressProxySecretMaterial.secretName,
secretKey: egressProxySecretMaterial.secretKey, secretKey: egressProxySecretMaterial.secretKey,
fingerprint: egressProxySecretMaterial.fingerprint, fingerprint: egressProxySecretMaterial.fingerprint,
subscriptionBytes: egressProxySecretMaterial.subscriptionBytes, sourceBytes: egressProxySecretMaterial.sourceBytes,
selectedOutbound: egressProxySecretMaterial.selectedOutbound, selectedOutbound: egressProxySecretMaterial.selectedOutbound,
subscriptionDiagnostics: egressProxySecretMaterial.subscriptionDiagnostics, sourceDiagnostics: egressProxySecretMaterial.sourceDiagnostics,
proxyUrl: egressProxySecretMaterial.proxyUrl, proxyUrl: egressProxySecretMaterial.proxyUrl,
noProxy: egressProxySecretMaterial.noProxy, noProxy: egressProxySecretMaterial.noProxy,
valuesPrinted: false, valuesPrinted: false,
@@ -3837,14 +3963,14 @@ fi
`; `;
const egressProbeJson = proxy === null ? "None" : `{ const egressProbeJson = proxy === null ? "None" : `{
"enabled": True, "enabled": True,
"mode": "master-vpn-subscription-http-proxy", "mode": "${proxy.sourceType === "master-shadowsocks" ? "master-shadowsocks-http-proxy" : "master-vpn-subscription-http-proxy"}",
"deploymentName": "${proxy.deploymentName}", "deploymentName": "${proxy.deploymentName}",
"serviceName": "${proxy.serviceName}", "serviceName": "${proxy.serviceName}",
"proxyUrl": "${proxyUrl}", "proxyUrl": "${proxyUrl}",
"healthProbeUrl": "${proxy.healthProbeUrl}", "healthProbeUrl": "${proxy.healthProbeUrl}",
"applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"}, "applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"},
"applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"}, "applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"},
"subscription": ${egressSubscriptionJson}, "source": ${egressSubscriptionJson},
"deployment": { "deployment": {
"exitCode": egress_deploy_rc, "exitCode": egress_deploy_rc,
"ready": deployment_ready(load("egress-deploy")), "ready": deployment_ready(load("egress-deploy")),