fix: route D601 Sub2API egress through Shadowsocks (#843)
Co-authored-by: Codex <codex@noreply.local>
This commit is contained in:
@@ -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
|
||||||
@@ -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
@@ -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")),
|
||||||
|
|||||||
Reference in New Issue
Block a user