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:
repository: weishaw/sub2api
tag: 0.1.136
tag: 0.1.138
pullPolicy: IfNotPresent
dependencyImages:
@@ -80,7 +80,7 @@ targets:
redisReplicas: 1
image:
repository: 127.0.0.1:5000/platform-infra/sub2api
tag: 0.1.136
tag: 0.1.138
pullPolicy: IfNotPresent
dependencyImages:
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
imagePullPolicy: IfNotPresent
listenPort: 10808
sourceRef: platform-infra/master-vpn-subscription.env
sourceKey: MASTER_VPN_SUBSCRIPTION_URL
sourceType: subscription-url
preferredOutbound: hysteria2
sourceRef: platform-infra/sub2api-master-egress-proxy.env
sourceKey: SUB2API_MASTER_SHADOWSOCKS_PASSWORD
sourceType: master-shadowsocks
masterShadowsocks:
serverHost: 74.48.78.17
serverPort: 18792
method: chacha20-ietf-poly1305
applyToSub2Api: true
applyToSentinel: true
healthProbeUrl: https://www.gstatic.com/generate_204
+156 -30
View File
@@ -153,14 +153,21 @@ interface Sub2ApiEgressProxyConfig {
listenPort: number;
sourceRef: string;
sourceKey: string;
sourceType: "subscription-url";
preferredOutbound: "vless-reality" | "hysteria2";
sourceType: "subscription-url" | "master-shadowsocks";
preferredOutbound: "vless-reality" | "hysteria2" | null;
masterShadowsocks: Sub2ApiMasterShadowsocksConfig | null;
noProxy: string[];
applyToSub2Api: boolean;
applyToSentinel: boolean;
healthProbeUrl: string;
}
interface Sub2ApiMasterShadowsocksConfig {
serverHost: string;
serverPort: number;
method: string;
}
interface ExternalDatabaseConfig {
mode: "external";
sourceRef: string;
@@ -215,9 +222,9 @@ interface EgressProxySecretMaterial {
secretName: string;
secretKey: string;
fingerprint: string;
subscriptionBytes: number;
sourceBytes: number;
selectedOutbound: string;
subscriptionDiagnostics: EgressProxySubscriptionDiagnostics;
sourceDiagnostics: EgressProxySubscriptionDiagnostics;
configJson: string;
proxyUrl: string;
noProxy: string;
@@ -226,7 +233,7 @@ interface EgressProxySecretMaterial {
interface EgressProxySubscriptionCandidateSummary {
sourceLine: number;
kind: SubscriptionNode["kind"];
kind: SubscriptionNode["kind"] | "shadowsocks";
fingerprint: string;
paramKeys: string[];
selected: boolean;
@@ -234,6 +241,7 @@ interface EgressProxySubscriptionCandidateSummary {
interface EgressProxySubscriptionDiagnostics {
ok: boolean;
sourceType: Sub2ApiEgressProxyConfig["sourceType"];
preferredOutbound: Sub2ApiEgressProxyConfig["preferredOutbound"];
candidateCount: number;
supportedCount: number;
@@ -720,8 +728,13 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
if (value === undefined || value === null) return null;
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 sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url"] as const);
const preferredOutbound = enumField(record, "preferredOutbound", `${path}.egressProxy`, ["vless-reality", "hysteria2"] as const);
const sourceType = enumField(record, "sourceType", `${path}.egressProxy`, ["subscription-url", "master-shadowsocks"] 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 noProxy = stringArrayField(record, "noProxy", `${path}.egressProxy`);
const proxy: Sub2ApiEgressProxyConfig = {
@@ -737,6 +750,7 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
sourceKey: stringField(record, "sourceKey", `${path}.egressProxy`),
sourceType,
preferredOutbound,
masterShadowsocks,
noProxy,
applyToSub2Api: booleanField(record, "applyToSub2Api", `${path}.egressProxy`),
applyToSentinel: booleanField(record, "applyToSentinel", `${path}.egressProxy`),
@@ -746,6 +760,16 @@ function parseEgressProxyConfig(value: unknown, path: string): Sub2ApiEgressProx
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"] {
const value = root.runtime;
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`);
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 (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) {
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/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id}
unidesk.ai/proxy-source: master-vpn-subscription
unidesk.ai/proxy-source: ${proxy.sourceType}
spec:
selector:
app.kubernetes.io/name: ${proxy.deploymentName}
@@ -1503,7 +1536,7 @@ metadata:
app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id}
unidesk.ai/proxy-source: master-vpn-subscription
unidesk.ai/proxy-source: ${proxy.sourceType}
spec:
replicas: 1
selector:
@@ -1518,7 +1551,8 @@ spec:
app.kubernetes.io/part-of: platform-infra
annotations:
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:
containers:
- name: proxy
@@ -1590,7 +1624,7 @@ function publicExposureSummary(exposure: Sub2ApiPublicExposureConfig): Record<st
function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unknown> {
return {
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,
serviceName: proxy.serviceName,
secretName: proxy.secretName,
@@ -1600,6 +1634,13 @@ function egressProxySummary(proxy: Sub2ApiEgressProxyConfig): Record<string, unk
sourceRef: proxy.sourceRef,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
masterShadowsocks: proxy.masterShadowsocks === null
? null
: {
serverHost: proxy.masterShadowsocks.serverHost,
serverPort: proxy.masterShadowsocks.serverPort,
method: proxy.masterShadowsocks.method,
},
applyToSub2Api: proxy.applyToSub2Api,
applyToSentinel: proxy.applyToSentinel,
healthProbeUrl: proxy.healthProbeUrl,
@@ -1684,9 +1725,10 @@ function plan(options: TargetOptions): Record<string, unknown> {
: null,
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}`,
sourceRef: target.egressProxy.sourceRef,
sourceType: target.egressProxy.sourceType,
applyToSub2Api: target.egressProxy.applyToSub2Api,
applyToSentinel: target.egressProxy.applyToSentinel,
valuesPrinted: false,
@@ -2064,28 +2106,49 @@ function prepareEgressProxySecret(sub2api: Sub2ApiConfig, target: Sub2ApiTargetC
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`,
missingMessage: (sourcePath) => `egressProxy requires ${redactRepoPath(sourcePath)} with ${proxy.sourceKey}; materialize the declared proxy source first`,
});
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 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);
if (proxy.sourceType === "master-shadowsocks") {
const password = requiredEnvValue(values, proxy.sourceKey, proxy.sourceRef);
const configJson = renderSingBoxMasterShadowsocksConfig(proxy, password);
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({ 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,
sourcePath: source.sourcePathRedacted,
secretName: proxy.secretName,
secretKey: proxy.secretKey,
fingerprint: fingerprintSecretValues({ subscription: subscription.body, configJson }, ["subscription", "configJson"]),
subscriptionBytes: Buffer.byteLength(subscription.body, "utf8"),
sourceBytes: Buffer.byteLength(subscription.body, "utf8"),
selectedOutbound: selected.kind,
subscriptionDiagnostics: analyzed.diagnostics,
sourceDiagnostics: analyzed.diagnostics,
configJson,
proxyUrl,
noProxy,
@@ -2119,11 +2182,13 @@ function decodeSubscription(raw: string): string {
function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: Sub2ApiEgressProxyConfig): EgressProxySubscriptionDiagnostics {
try {
if (proxy.sourceType === "master-shadowsocks") return masterShadowsocksDiagnostics(proxy);
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`,
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 subscription = fetchSubscription(subscriptionUrl);
const decoded = decodeSubscription(subscription.body);
@@ -2132,6 +2197,7 @@ function safeEgressProxySubscriptionDiagnostics(sub2api: Sub2ApiConfig, proxy: S
} catch (error) {
return {
ok: false,
sourceType: proxy.sourceType,
preferredOutbound: proxy.preferredOutbound,
candidateCount: 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 =
| { 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 {
function selectSubscriptionNode(nodes: string[], preferred: Exclude<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): SubscriptionNode {
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 } {
function analyzeEgressProxySubscription(nodes: string[], preferred: Exclude<Sub2ApiEgressProxyConfig["preferredOutbound"], null>): { 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);
@@ -2176,6 +2284,7 @@ function analyzeEgressProxySubscription(nodes: string[], preferred: Sub2ApiEgres
const selectedFingerprint = selectedEntry === null ? null : fingerprintSecretValues({ candidate: selectedEntry.uri }, ["candidate"]);
const diagnostics: EgressProxySubscriptionDiagnostics = {
ok: selectedEntry !== null,
sourceType: "subscription-url",
preferredOutbound: preferred,
candidateCount: nodes.length,
supportedCount: parsed.length,
@@ -2277,6 +2386,23 @@ function renderSingBoxConfig(node: SubscriptionNode, proxy: Sub2ApiEgressProxyCo
obfs: node.obfsPassword === null ? undefined : { type: "salamander", password: node.obfsPassword },
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({
log: { level: "info", timestamp: true },
inbounds: [
@@ -2931,9 +3057,9 @@ function applyScript(
secretName: egressProxySecretMaterial.secretName,
secretKey: egressProxySecretMaterial.secretKey,
fingerprint: egressProxySecretMaterial.fingerprint,
subscriptionBytes: egressProxySecretMaterial.subscriptionBytes,
sourceBytes: egressProxySecretMaterial.sourceBytes,
selectedOutbound: egressProxySecretMaterial.selectedOutbound,
subscriptionDiagnostics: egressProxySecretMaterial.subscriptionDiagnostics,
sourceDiagnostics: egressProxySecretMaterial.sourceDiagnostics,
proxyUrl: egressProxySecretMaterial.proxyUrl,
noProxy: egressProxySecretMaterial.noProxy,
valuesPrinted: false,
@@ -3837,14 +3963,14 @@ fi
`;
const egressProbeJson = proxy === null ? "None" : `{
"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}",
"serviceName": "${proxy.serviceName}",
"proxyUrl": "${proxyUrl}",
"healthProbeUrl": "${proxy.healthProbeUrl}",
"applyToSub2Api": ${proxy.applyToSub2Api ? "True" : "False"},
"applyToSentinel": ${proxy.applyToSentinel ? "True" : "False"},
"subscription": ${egressSubscriptionJson},
"source": ${egressSubscriptionJson},
"deployment": {
"exitCode": egress_deploy_rc,
"ready": deployment_ready(load("egress-deploy")),