import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { rootPath } from "./config"; export type EgressProxySourceType = "subscription-url" | "master-shadowsocks"; export type EgressProxyPreferredOutbound = "vless-reality" | "hysteria2"; export interface MasterShadowsocksSourceSpec { serverHost: string; serverPort: number; method: string; } export interface SharedEgressProxySourceSpec { id: string; configRef: string; sourceType: EgressProxySourceType; sourceRef: string; sourceKey: string; preferredOutbound: EgressProxyPreferredOutbound | null; masterShadowsocks: MasterShadowsocksSourceSpec | null; healthProbeUrl: string; fingerprint: string; valuesPrinted: false; } export function resolveEgressProxySourceRef(ref: string, label: string): SharedEgressProxySourceSpec { const { file, path } = parseConfigRef(ref, label); if (!path.startsWith("sources.")) throw new Error(`${label} must reference sources.`); const sourceId = path.slice("sources.".length); if (!/^[A-Za-z0-9._-]+$/u.test(sourceId)) throw new Error(`${label} has an unsupported source id`); const parsed = Bun.YAML.parse(readFileSync(rootPath(file), "utf8")) as unknown; const root = record(parsed, file); if (root.kind !== "platform-infra-egress-proxy-sources") throw new Error(`${file}.kind must be platform-infra-egress-proxy-sources`); const sources = record(root.sources, `${file}.sources`); const raw = record(sources[sourceId], `${file}#sources.${sourceId}`); return parseSharedEgressProxySource(sourceId, ref, raw, `${file}#sources.${sourceId}`); } function parseSharedEgressProxySource(id: string, configRef: string, raw: Record, label: string): SharedEgressProxySourceSpec { const sourceType = enumField(raw, "sourceType", label, ["subscription-url", "master-shadowsocks"] as const); const preferredOutbound = raw.preferredOutbound === undefined ? null : enumField(raw, "preferredOutbound", label, ["vless-reality", "hysteria2"] as const); const masterShadowsocks = raw.masterShadowsocks === undefined ? null : masterShadowsocksSpec(record(raw.masterShadowsocks, `${label}.masterShadowsocks`), `${label}.masterShadowsocks`); const spec: SharedEgressProxySourceSpec = { id, configRef, sourceType, sourceRef: sourceRefField(raw, "sourceRef", label), sourceKey: envKeyField(raw, "sourceKey", label), preferredOutbound, masterShadowsocks, healthProbeUrl: urlField(raw, "healthProbeUrl", label), fingerprint: "", valuesPrinted: false, }; validateSharedEgressProxySource(spec, label); return { ...spec, fingerprint: `sha256:${createHash("sha256").update(JSON.stringify({ sourceType: spec.sourceType, sourceRef: spec.sourceRef, sourceKey: spec.sourceKey, preferredOutbound: spec.preferredOutbound, masterShadowsocks: spec.masterShadowsocks, healthProbeUrl: spec.healthProbeUrl, })).digest("hex").slice(0, 16)}`, }; } function validateSharedEgressProxySource(spec: SharedEgressProxySourceSpec, label: string): void { if (spec.sourceType === "subscription-url" && spec.preferredOutbound === null) { throw new Error(`${label}.preferredOutbound is required for sourceType=subscription-url`); } if (spec.sourceType === "master-shadowsocks") { if (spec.masterShadowsocks === null) throw new Error(`${label}.masterShadowsocks is required for sourceType=master-shadowsocks`); if (spec.preferredOutbound !== null) throw new Error(`${label}.preferredOutbound must be omitted for sourceType=master-shadowsocks`); } } function masterShadowsocksSpec(raw: Record, label: string): MasterShadowsocksSourceSpec { const serverHost = stringField(raw, "serverHost", label); if (!/^[A-Za-z0-9._:-]+$/u.test(serverHost)) throw new Error(`${label}.serverHost has an unsupported format`); const serverPort = integerField(raw, "serverPort", label); if (serverPort < 1 || serverPort > 65535) throw new Error(`${label}.serverPort must be a TCP port`); const method = stringField(raw, "method", label); if (!/^[A-Za-z0-9-]+$/u.test(method)) throw new Error(`${label}.method has an unsupported format`); return { serverHost, serverPort, method }; } function parseConfigRef(ref: string, label: string): { file: string; path: string } { const [file, path, extra] = ref.split("#"); if (extra !== undefined || file === undefined || path === undefined || file.length === 0 || path.length === 0) { throw new Error(`${label} must use path/to/file.yaml#sources. syntax`); } if (file.startsWith("/") || file.includes("..") || !file.startsWith("config/") || !file.endsWith(".yaml")) { throw new Error(`${label} must reference a repo-relative config/*.yaml file without ..`); } if (!/^[A-Za-z0-9_.-]+$/u.test(path)) throw new Error(`${label} has an unsupported YAML path`); return { file, path }; } function record(value: unknown, label: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`); return value as Record; } function stringField(obj: Record, key: string, label: string): string { const value = obj[key]; if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label}.${key} must be a non-empty string`); return value.trim(); } function integerField(obj: Record, key: string, label: string): number { const value = obj[key]; if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${label}.${key} must be an integer`); return value; } function enumField(obj: Record, key: string, label: string, values: T): T[number] { const value = stringField(obj, key, label); if (!(values as readonly string[]).includes(value)) throw new Error(`${label}.${key} must be one of ${values.join(", ")}`); return value as T[number]; } function sourceRefField(obj: Record, key: string, label: string): string { const value = stringField(obj, key, label); if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${label}.${key} has an unsupported sourceRef format`); return value; } function envKeyField(obj: Record, key: string, label: string): string { const value = stringField(obj, key, label); if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${label}.${key} must be an env key`); return value; } function urlField(obj: Record, key: string, label: string): string { const value = stringField(obj, key, label); const url = new URL(value); if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${label}.${key} must use http:// or https://`); if (url.username || url.password || url.search || url.hash) throw new Error(`${label}.${key} must not include credentials, query, or hash`); return value; }