Files
pikasTech-unidesk/scripts/src/egress-proxy-sources.ts
T
2026-06-26 10:32:43 +00:00

148 lines
6.9 KiB
TypeScript

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.<id>`);
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<string, unknown>, 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<string, unknown>, 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.<id> 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<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be a YAML object`);
return value as Record<string, unknown>;
}
function stringField(obj: Record<string, unknown>, 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<string, unknown>, 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<const T extends readonly string[]>(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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;
}