diff --git a/config/platform-infra/sub2api-master-egress-proxy.compose.yaml b/config/platform-infra/sub2api-master-egress-proxy.compose.yaml new file mode 100644 index 00000000..edc3990c --- /dev/null +++ b/config/platform-infra/sub2api-master-egress-proxy.compose.yaml @@ -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 diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index f5b4193e..412143a9 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -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 diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index e189d0fc..4a450cdc 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -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; - 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; + return { + serverHost: stringField(record, "serverHost", path), + serverPort: integerField(record, "serverPort", path), + method: stringField(record, "method", path), + }; +} + function parseRuntime(root: Record): 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 { 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 { : 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): 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): { 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, 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")),