refactor(uri-parser): split parser into folder-based modules

This commit is contained in:
Slinetrac 2025-12-31 14:40:45 +08:00
parent bae3576e93
commit 609008f087
No known key found for this signature in database
15 changed files with 1763 additions and 1610 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,94 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parseInteger,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
splitOnce,
stripUriScheme,
} from "./helpers";
export function URI_AnyTLS(line: string): IProxyAnyTLSConfig {
const afterScheme = stripUriScheme(line, "anytls", "Invalid anytls uri");
if (!afterScheme) {
throw new Error("Invalid anytls uri");
}
const {
auth: authRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, {
errorMessage: "Invalid anytls uri",
});
if (!server) {
throw new Error("Invalid anytls uri");
}
const portNum = parsePortOrDefault(port, 443);
const auth = safeDecodeURIComponent(authRaw) ?? authRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `AnyTLS ${server}:${portNum}`;
const proxy: IProxyAnyTLSConfig = {
type: "anytls",
name,
server,
port: portNum,
udp: true,
};
if (auth) {
const [username, password] = splitOnce(auth, ":");
proxy.password = password ?? username;
}
const params = parseQueryStringNormalized(addons);
if (params.sni) {
proxy.sni = params.sni;
}
if (params.alpn) {
const alpn = params.alpn
.split(",")
.map((item) => item.trim())
.filter(Boolean);
if (alpn.length > 0) {
proxy.alpn = alpn;
}
}
const fingerprint = params.fingerprint ?? params.hpkp;
if (fingerprint) {
proxy.fingerprint = fingerprint;
}
const clientFingerprint = params["client-fingerprint"] ?? params.fp;
if (clientFingerprint) {
proxy["client-fingerprint"] = clientFingerprint as ClientFingerprint;
}
if (Object.prototype.hasOwnProperty.call(params, "skip-cert-verify")) {
proxy["skip-cert-verify"] = parseBoolOrPresence(params["skip-cert-verify"]);
} else if (Object.prototype.hasOwnProperty.call(params, "insecure")) {
proxy["skip-cert-verify"] = parseBoolOrPresence(params.insecure);
}
if (Object.prototype.hasOwnProperty.call(params, "udp")) {
proxy.udp = parseBoolOrPresence(params.udp);
}
const idleCheck = parseInteger(params["idle-session-check-interval"]);
if (idleCheck !== undefined) {
proxy["idle-session-check-interval"] = idleCheck;
}
const idleTimeout = parseInteger(params["idle-session-timeout"]);
if (idleTimeout !== undefined) {
proxy["idle-session-timeout"] = idleTimeout;
}
const minIdle = parseInteger(params["min-idle-session"]);
if (minIdle !== undefined) {
proxy["min-idle-session"] = minIdle;
}
return proxy;
}

View File

@ -0,0 +1,335 @@
const URI_SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//;
export function normalizeUriAndGetScheme(input: string): {
uri: string;
scheme: string;
} {
const trimmed = input.trim();
const match = URI_SCHEME_RE.exec(trimmed);
if (!match) {
const schemeGuess = (trimmed.split("://")[0] ?? "").toLowerCase();
return { uri: trimmed, scheme: schemeGuess };
}
const scheme = match[1].toLowerCase();
return { uri: scheme + trimmed.slice(match[1].length), scheme };
}
export function stripUriScheme(
uri: string,
expectedSchemes: string | readonly string[],
errorMessage: string,
): string {
const match = URI_SCHEME_RE.exec(uri);
if (!match) {
throw new Error(errorMessage);
}
const scheme = match[1].toLowerCase();
const expected =
typeof expectedSchemes === "string" ? [expectedSchemes] : expectedSchemes;
if (!expected.includes(scheme)) {
throw new Error(errorMessage);
}
return uri.slice(match[0].length);
}
export function getIfNotBlank(
value: string | undefined,
dft?: string,
): string | undefined {
return value && value.trim() !== "" ? value : dft;
}
export function getIfPresent<T>(
value: T | null | undefined,
dft?: T,
): T | undefined {
return value !== null && value !== undefined ? value : dft;
}
export function isPresent(value: any): boolean {
return value !== null && value !== undefined;
}
export function trimStr(str: string | undefined): string | undefined {
return str ? str.trim() : str;
}
export function safeDecodeURIComponent(
value: string | undefined,
): string | undefined {
if (value === undefined) return undefined;
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function decodeAndTrim(value: string | undefined): string | undefined {
const decoded = safeDecodeURIComponent(value);
const trimmed = decoded?.trim();
return trimmed ? trimmed : undefined;
}
export function splitOnce(input: string, delimiter: string): [string, string?] {
const idx = input.indexOf(delimiter);
if (idx === -1) return [input];
return [input.slice(0, idx), input.slice(idx + delimiter.length)];
}
export function parseQueryString(
query: string | undefined,
): Record<string, string | undefined> {
const out: Record<string, string | undefined> = {};
if (!query) return out;
for (const part of query.split("&")) {
if (!part) continue;
const [keyRaw, valueRaw] = splitOnce(part, "=");
const key = keyRaw.trim();
if (!key) continue;
out[key] =
valueRaw === undefined
? undefined
: (safeDecodeURIComponent(valueRaw) ?? valueRaw);
}
return out;
}
function normalizeQueryKey(key: string): string {
return key.replace(/_/g, "-");
}
export function parseQueryStringNormalized(
query: string | undefined,
): Record<string, string | undefined> {
const raw = parseQueryString(query);
const normalized: Record<string, string | undefined> = {};
for (const [key, value] of Object.entries(raw)) {
normalized[normalizeQueryKey(key)] = value;
}
return normalized;
}
export function parseBool(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
return /^(?:true|1)$/i.test(value);
}
export function parseBoolOrPresence(value: string | undefined): boolean {
if (value === undefined) return true;
const trimmed = value.trim();
if (trimmed === "") return true;
return /^(?:true|1)$/i.test(trimmed);
}
export function parseVlessFlow(value: string | undefined): string | undefined {
const flow = getIfNotBlank(value);
if (!flow) return undefined;
if (/^none$/i.test(flow)) return undefined;
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(flow)) return undefined;
return flow;
}
export function parseInteger(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? undefined : parsed;
}
function parsePortStrict(
value: string | number | null | undefined,
): number | undefined {
if (value === null || value === undefined) return undefined;
const raw = String(value).trim();
if (!/^\d+$/.test(raw)) return undefined;
const parsed = Number.parseInt(raw, 10);
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65535) {
return undefined;
}
return parsed;
}
export function parseRequiredPort(
value: string | number | null | undefined,
errorMessage: string,
): number {
const parsed = parsePortStrict(value);
if (parsed === undefined) {
throw new Error(errorMessage);
}
return parsed;
}
export function parsePortOrDefault(
port: string | undefined,
dft: number,
): number {
return parseInteger(port) ?? dft;
}
const IP_VERSIONS = [
"dual",
"ipv4",
"ipv6",
"ipv4-prefer",
"ipv6-prefer",
] as const;
export function parseIpVersion(
value: string | undefined,
): (typeof IP_VERSIONS)[number] {
return value && IP_VERSIONS.includes(value as (typeof IP_VERSIONS)[number])
? (value as (typeof IP_VERSIONS)[number])
: "dual";
}
export type UrlLikeParts = {
auth?: string;
host: string;
port?: string;
query?: string;
fragment?: string;
};
const URLLIKE_RE =
/^(?:(?<auth>.*?)@)?(?<host>.*?)(?::(?<port>\d+))?\/?(?:\?(?<query>.*?))?(?:#(?<fragment>.*?))?$/;
export function parseUrlLike(
input: string,
options: { requireAuth: true; errorMessage: string },
): UrlLikeParts & { auth: string };
export function parseUrlLike(
input: string,
options: { requireAuth?: false; errorMessage: string },
): UrlLikeParts;
export function parseUrlLike(
input: string,
options: { requireAuth?: boolean; errorMessage: string },
): UrlLikeParts {
const match = URLLIKE_RE.exec(input);
const groups = (match?.groups ?? {}) as {
auth?: string;
host?: string;
port?: string;
query?: string;
fragment?: string;
};
if (!match || groups.host === undefined) {
throw new Error(options.errorMessage);
}
const auth = getIfNotBlank(groups.auth);
if (options.requireAuth && !auth) {
throw new Error(options.errorMessage);
}
const result: UrlLikeParts = {
auth,
host: groups.host,
port: groups.port,
query: groups.query,
fragment: groups.fragment,
};
return options.requireAuth
? ({ ...result, auth } as UrlLikeParts & { auth: string })
: result;
}
export function isIPv4(address: string): boolean {
// Check if the address is IPv4
const ipv4Regex = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/;
return ipv4Regex.test(address);
}
export function isIPv6(address: string): boolean {
// Check if the address is IPv6 - simplified regex to avoid backreference issues
const ipv6Regex =
/^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^::1$|^([0-9a-fA-F]{1,4}:)*::([0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$/;
return ipv6Regex.test(address);
}
export function decodeBase64OrOriginal(str: string): string {
const normalized = str
.replace(/[\r\n\s]/g, "")
.replace(/-/g, "+")
.replace(/_/g, "/");
const padLen = normalized.length % 4;
const padded =
padLen === 0 ? normalized : normalized + "=".repeat(4 - padLen);
try {
const decoded = atob(padded);
// Heuristic: only accept "text-like" results to avoid accidentally decoding
// non-base64 strings that happen to be decodable.
for (let i = 0; i < decoded.length; i++) {
const code = decoded.charCodeAt(i);
if (code === 9 || code === 10 || code === 13) continue;
if (code < 32 || code === 127) {
return str;
}
}
return decoded;
} catch {
return str;
}
}
const CIPHER_ALIASES: Record<string, CipherType> = {
"chacha20-poly1305": "chacha20-ietf-poly1305",
};
const KNOWN_CIPHERS = new Set<CipherType>([
"none",
"auto",
"dummy",
"aes-128-gcm",
"aes-192-gcm",
"aes-256-gcm",
"lea-128-gcm",
"lea-192-gcm",
"lea-256-gcm",
"aes-128-gcm-siv",
"aes-256-gcm-siv",
"2022-blake3-aes-128-gcm",
"2022-blake3-aes-256-gcm",
"aes-128-cfb",
"aes-192-cfb",
"aes-256-cfb",
"aes-128-ctr",
"aes-192-ctr",
"aes-256-ctr",
"chacha20",
"chacha20-ietf",
"chacha20-ietf-poly1305",
"2022-blake3-chacha20-poly1305",
"rabbit128-poly1305",
"xchacha20-ietf-poly1305",
"xchacha20",
"aegis-128l",
"aegis-256",
"aez-384",
"deoxys-ii-256-128",
"rc4-md5",
]);
export function getCipher(value: unknown): CipherType {
if (value === undefined) return "none";
if (typeof value !== "string") return "auto";
const aliased = CIPHER_ALIASES[value] ?? value;
return KNOWN_CIPHERS.has(aliased as CipherType)
? (aliased as CipherType)
: "auto";
}
export function firstString(value: any): string | undefined {
if (value === null || value === undefined) return undefined;
if (Array.isArray(value)) {
if (value.length === 0) return undefined;
const first = value[0];
return first === null || first === undefined ? undefined : String(first);
}
return String(value);
}

View File

@ -0,0 +1,67 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parseIpVersion,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
splitOnce,
stripUriScheme,
} from "./helpers";
export function URI_HTTP(line: string): IProxyHttpConfig {
const afterScheme = stripUriScheme(
line,
["http", "https"],
"Invalid http uri",
);
if (!afterScheme) {
throw new Error("Invalid http uri");
}
const {
auth: authRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, { errorMessage: "Invalid http uri" });
const portNum = parsePortOrDefault(port, 443);
const auth = safeDecodeURIComponent(authRaw) ?? authRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `HTTP ${server}:${portNum}`;
const proxy: IProxyHttpConfig = {
type: "http",
name,
server,
port: portNum,
};
if (auth) {
const [username, password] = splitOnce(auth, ":");
proxy.username = username;
proxy.password = password;
}
const params = parseQueryStringNormalized(addons);
for (const [key, value] of Object.entries(params)) {
switch (key) {
case "tls":
proxy.tls = parseBoolOrPresence(value);
break;
case "fingerprint":
proxy.fingerprint = value;
break;
case "skip-cert-verify":
proxy["skip-cert-verify"] = parseBoolOrPresence(value);
break;
case "ip-version":
proxy["ip-version"] = parseIpVersion(value);
break;
default:
break;
}
}
return proxy;
}

View File

@ -0,0 +1,104 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parseInteger,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
stripUriScheme,
} from "./helpers";
export function URI_Hysteria(line: string): IProxyHysteriaConfig {
const afterScheme = stripUriScheme(
line,
["hysteria", "hy"],
"Invalid hysteria uri",
);
if (!afterScheme) {
throw new Error("Invalid hysteria uri");
}
const {
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, { errorMessage: "Invalid hysteria uri" });
const portNum = parsePortOrDefault(port, 443);
const name = decodeAndTrim(nameRaw) ?? `Hysteria ${server}:${portNum}`;
const proxy: IProxyHysteriaConfig = {
type: "hysteria",
name,
server,
port: portNum,
};
const params = parseQueryStringNormalized(addons);
for (const [key, value] of Object.entries(params)) {
switch (key) {
case "alpn":
proxy.alpn = value ? value.split(",") : undefined;
break;
case "insecure":
proxy["skip-cert-verify"] = parseBoolOrPresence(value);
break;
case "auth":
if (value) proxy["auth-str"] = value;
break;
case "mport":
if (value) proxy.ports = value;
break;
case "obfsParam":
if (value) proxy.obfs = value;
break;
case "upmbps":
if (value) proxy.up = value;
break;
case "downmbps":
if (value) proxy.down = value;
break;
case "obfs":
if (value !== undefined) proxy.obfs = value || "";
break;
case "fast-open":
proxy["fast-open"] = parseBoolOrPresence(value);
break;
case "peer":
if (!proxy.sni && value) proxy.sni = value;
break;
case "recv-window-conn":
proxy["recv-window-conn"] = parseInteger(value);
break;
case "recv-window":
proxy["recv-window"] = parseInteger(value);
break;
case "ca":
if (value) proxy.ca = value;
break;
case "ca-str":
if (value) proxy["ca-str"] = value;
break;
case "disable-mtu-discovery":
proxy["disable-mtu-discovery"] = parseBoolOrPresence(value);
break;
case "fingerprint":
if (value) proxy.fingerprint = value;
break;
case "protocol":
if (value) proxy.protocol = value;
break;
case "sni":
if (value) proxy.sni = value;
break;
default:
break;
}
}
if (!proxy.protocol) {
proxy.protocol = "udp";
}
return proxy;
}

View File

@ -0,0 +1,66 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
stripUriScheme,
} from "./helpers";
export function URI_Hysteria2(line: string): IProxyHysteria2Config {
const afterScheme = stripUriScheme(
line,
["hysteria2", "hy2"],
"Invalid hysteria2 uri",
);
if (!afterScheme) {
throw new Error("Invalid hysteria2 uri");
}
const {
auth: passwordRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, {
requireAuth: true,
errorMessage: "Invalid hysteria2 uri",
});
const portNum = parsePortOrDefault(port, 443);
const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `Hysteria2 ${server}:${portNum}`;
const proxy: IProxyHysteria2Config = {
type: "hysteria2",
name,
server,
port: portNum,
password,
};
const params = parseQueryStringNormalized(addons);
proxy.sni = params.sni;
if (!proxy.sni && params.peer) {
proxy.sni = params.peer;
}
if (params.obfs && params.obfs !== "none") {
proxy.obfs = params.obfs;
}
proxy.ports = params.mport;
proxy["obfs-password"] = params["obfs-password"];
if (Object.prototype.hasOwnProperty.call(params, "insecure")) {
proxy["skip-cert-verify"] = parseBoolOrPresence(params.insecure);
}
if (Object.prototype.hasOwnProperty.call(params, "fastopen")) {
proxy.tfo = parseBoolOrPresence(params.fastopen);
}
proxy.fingerprint = params.pinSHA256;
return proxy;
}

View File

@ -0,0 +1,44 @@
import { URI_AnyTLS } from "./anytls";
import { normalizeUriAndGetScheme } from "./helpers";
import { URI_HTTP } from "./http";
import { URI_Hysteria } from "./hysteria";
import { URI_Hysteria2 } from "./hysteria2";
import { URI_SOCKS } from "./socks";
import { URI_SS } from "./ss";
import { URI_SSR } from "./ssr";
import { URI_Trojan } from "./trojan";
import { URI_TUIC } from "./tuic";
import { URI_VLESS } from "./vless";
import { URI_VMESS } from "./vmess";
import { URI_Wireguard } from "./wireguard";
type UriParser = (uri: string) => IProxyConfig;
const URI_PARSERS: Record<string, UriParser> = {
ss: URI_SS,
ssr: URI_SSR,
vmess: URI_VMESS,
vless: URI_VLESS,
trojan: URI_Trojan,
anytls: URI_AnyTLS,
hysteria2: URI_Hysteria2,
hy2: URI_Hysteria2,
hysteria: URI_Hysteria,
hy: URI_Hysteria,
tuic: URI_TUIC,
wireguard: URI_Wireguard,
wg: URI_Wireguard,
http: URI_HTTP,
https: URI_HTTP,
socks5: URI_SOCKS,
socks: URI_SOCKS,
};
export default function parseUri(uri: string): IProxyConfig {
const { uri: normalized, scheme } = normalizeUriAndGetScheme(uri);
const parser = URI_PARSERS[scheme];
if (!parser) {
throw new Error(`Unknown uri type: ${scheme}`);
}
return parser(normalized);
}

View File

@ -0,0 +1,70 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parseIpVersion,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
splitOnce,
stripUriScheme,
} from "./helpers";
export function URI_SOCKS(line: string): IProxySocks5Config {
const afterScheme = stripUriScheme(
line,
["socks5", "socks"],
"Invalid socks uri",
);
if (!afterScheme) {
throw new Error("Invalid socks uri");
}
const {
auth: authRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, { errorMessage: "Invalid socks uri" });
const portNum = parsePortOrDefault(port, 443);
const auth = safeDecodeURIComponent(authRaw) ?? authRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `SOCKS5 ${server}:${portNum}`;
const proxy: IProxySocks5Config = {
type: "socks5",
name,
server,
port: portNum,
};
if (auth) {
const [username, password] = splitOnce(auth, ":");
proxy.username = username;
proxy.password = password;
}
const params = parseQueryStringNormalized(addons);
for (const [key, value] of Object.entries(params)) {
switch (key) {
case "tls":
proxy.tls = parseBoolOrPresence(value);
break;
case "fingerprint":
proxy.fingerprint = value;
break;
case "skip-cert-verify":
proxy["skip-cert-verify"] = parseBoolOrPresence(value);
break;
case "udp":
proxy.udp = parseBoolOrPresence(value);
break;
case "ip-version":
proxy["ip-version"] = parseIpVersion(value);
break;
default:
break;
}
}
return proxy;
}

114
src/utils/uri-parser/ss.ts Normal file
View File

@ -0,0 +1,114 @@
import {
decodeAndTrim,
decodeBase64OrOriginal,
getCipher,
getIfNotBlank,
getIfPresent,
parseBoolOrPresence,
parseQueryString,
parseRequiredPort,
splitOnce,
stripUriScheme,
} from "./helpers";
export function URI_SS(line: string): IProxyShadowsocksConfig {
const afterScheme = stripUriScheme(line, "ss", "Invalid ss uri");
if (!afterScheme) {
throw new Error("Invalid ss uri");
}
const [withoutHash, hashRaw] = splitOnce(afterScheme, "#");
const nameFromHash = decodeAndTrim(hashRaw);
const [mainRaw, queryRaw] = splitOnce(withoutHash, "?");
const queryParams = parseQueryString(queryRaw);
const main = mainRaw.includes("@")
? mainRaw
: decodeBase64OrOriginal(mainRaw);
const atIdx = main.lastIndexOf("@");
if (atIdx === -1) {
throw new Error("Invalid ss uri: missing '@'");
}
const userInfoStr = decodeBase64OrOriginal(main.slice(0, atIdx));
const serverAndPortWithPath = main.slice(atIdx + 1);
const serverAndPort = serverAndPortWithPath.split("/")[0];
const portIdx = serverAndPort.lastIndexOf(":");
if (portIdx === -1) {
throw new Error("Invalid ss uri: missing port");
}
const server = serverAndPort.slice(0, portIdx);
const portRaw = serverAndPort.slice(portIdx + 1);
const port = parseRequiredPort(portRaw, "Invalid ss uri: invalid port");
const userInfo = userInfoStr.match(/(^.*?):(.*$)/);
const proxy: IProxyShadowsocksConfig = {
name: nameFromHash ?? `SS ${server}:${port}`,
type: "ss",
server,
port,
cipher: getCipher(userInfo?.[1]),
password: userInfo?.[2],
};
// plugin from `plugin=...`
const pluginParam = queryParams.plugin;
if (pluginParam) {
const pluginParts = pluginParam.split(";");
const pluginName = pluginParts[0];
const pluginOptions: Record<string, any> = { plugin: pluginName };
for (const raw of pluginParts.slice(1)) {
if (!raw) continue;
const [key, val] = splitOnce(raw, "=");
if (!key) continue;
pluginOptions[key] = val === undefined || val === "" ? true : val;
}
switch (pluginOptions.plugin) {
case "obfs-local":
case "simple-obfs":
proxy.plugin = "obfs";
proxy["plugin-opts"] = {
mode: pluginOptions.obfs,
host: getIfNotBlank(pluginOptions["obfs-host"]),
};
break;
case "v2ray-plugin":
proxy.plugin = "v2ray-plugin";
proxy["plugin-opts"] = {
mode: "websocket",
host: getIfNotBlank(pluginOptions["obfs-host"] ?? pluginOptions.host),
path: getIfNotBlank(pluginOptions.path),
tls: getIfPresent(pluginOptions.tls),
};
break;
default:
throw new Error(`Unsupported plugin option: ${pluginOptions.plugin}`);
}
}
// plugin from `v2ray-plugin=...` (base64 JSON)
const v2rayPluginParam = queryParams["v2ray-plugin"];
if (!proxy.plugin && v2rayPluginParam) {
proxy.plugin = "v2ray-plugin";
proxy["plugin-opts"] = JSON.parse(decodeBase64OrOriginal(v2rayPluginParam));
}
if (
Object.prototype.hasOwnProperty.call(queryParams, "uot") &&
parseBoolOrPresence(queryParams.uot)
) {
proxy["udp-over-tcp"] = true;
}
if (
Object.prototype.hasOwnProperty.call(queryParams, "tfo") &&
parseBoolOrPresence(queryParams.tfo)
) {
proxy.tfo = true;
}
return proxy;
}

View File

@ -0,0 +1,74 @@
import {
decodeBase64OrOriginal,
getCipher,
getIfNotBlank,
parseQueryString,
parseRequiredPort,
stripUriScheme,
} from "./helpers";
export function URI_SSR(line: string): IProxyshadowsocksRConfig {
const afterScheme = stripUriScheme(line, "ssr", "Invalid ssr uri");
if (!afterScheme) {
throw new Error("Invalid ssr uri");
}
line = decodeBase64OrOriginal(afterScheme);
// handle IPV6 & IPV4 format
let splitIdx = line.indexOf(":origin");
if (splitIdx === -1) {
splitIdx = line.indexOf(":auth_");
}
if (splitIdx === -1) {
throw new Error("Invalid ssr uri");
}
const serverAndPort = line.substring(0, splitIdx);
const portIdx = serverAndPort.lastIndexOf(":");
if (portIdx === -1) {
throw new Error("Invalid ssr uri: missing port");
}
const server = serverAndPort.substring(0, portIdx);
const port = parseRequiredPort(
serverAndPort.substring(portIdx + 1),
"Invalid ssr uri: invalid port",
);
const params = line
.substring(splitIdx + 1)
.split("/?")[0]
.split(":");
let proxy: IProxyshadowsocksRConfig = {
name: "SSR",
type: "ssr",
server,
port,
protocol: params[0],
cipher: getCipher(params[1]),
obfs: params[2],
password: decodeBase64OrOriginal(params[3]),
};
// get other params
const otherParams: Record<string, string> = {};
const rawOtherParams = parseQueryString(line.split("/?")[1]);
for (const [key, value] of Object.entries(rawOtherParams)) {
const trimmed = value?.trim();
if (trimmed) {
otherParams[key] = trimmed;
}
}
proxy = {
...proxy,
name: otherParams.remarks
? decodeBase64OrOriginal(otherParams.remarks).trim()
: (proxy.server ?? ""),
"protocol-param": getIfNotBlank(
decodeBase64OrOriginal(otherParams.protoparam || "").replace(/\s/g, ""),
),
"obfs-param": getIfNotBlank(
decodeBase64OrOriginal(otherParams.obfsparam || "").replace(/\s/g, ""),
),
};
return proxy;
}

View File

@ -0,0 +1,92 @@
import {
decodeAndTrim,
getIfNotBlank,
parseBoolOrPresence,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
stripUriScheme,
} from "./helpers";
export function URI_Trojan(line: string): IProxyTrojanConfig {
const afterScheme = stripUriScheme(line, "trojan", "Invalid trojan uri");
if (!afterScheme) {
throw new Error("Invalid trojan uri");
}
const {
auth: passwordRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, {
requireAuth: true,
errorMessage: "Invalid trojan uri",
});
const portNum = parsePortOrDefault(port, 443);
const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw;
const name = decodeAndTrim(nameRaw) ?? `Trojan ${server}:${portNum}`;
const proxy: IProxyTrojanConfig = {
type: "trojan",
name,
server,
port: portNum,
password,
};
const params = parseQueryStringNormalized(addons);
const network = params.type;
if (network && ["ws", "grpc", "h2", "tcp"].includes(network)) {
proxy.network = network as NetworkType;
}
const host = getIfNotBlank(params.host);
const path = getIfNotBlank(params.path);
if (params.alpn) {
proxy.alpn = params.alpn.split(",");
}
if (params.sni) {
proxy.sni = params.sni;
}
if (Object.prototype.hasOwnProperty.call(params, "skip-cert-verify")) {
proxy["skip-cert-verify"] = parseBoolOrPresence(params["skip-cert-verify"]);
}
proxy.fingerprint = params.fingerprint ?? params.fp;
if (params.encryption) {
const encryption = params.encryption.split(";");
if (encryption.length === 3) {
proxy["ss-opts"] = {
enabled: true,
method: encryption[1],
password: encryption[2],
};
}
}
if (params["client-fingerprint"]) {
proxy["client-fingerprint"] = params[
"client-fingerprint"
] as ClientFingerprint;
}
if (proxy.network === "ws") {
const wsOpts: WsOptions = {};
if (host) wsOpts.headers = { Host: host };
if (path) wsOpts.path = path;
if (Object.keys(wsOpts).length > 0) {
proxy["ws-opts"] = wsOpts;
}
} else if (proxy.network === "grpc") {
const serviceName = getIfNotBlank(path);
if (serviceName) {
proxy["grpc-opts"] = { "grpc-service-name": serviceName };
}
}
return proxy;
}

View File

@ -0,0 +1,100 @@
import {
decodeAndTrim,
parseBoolOrPresence,
parseInteger,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
splitOnce,
stripUriScheme,
} from "./helpers";
export function URI_TUIC(line: string): IProxyTuicConfig {
const afterScheme = stripUriScheme(line, "tuic", "Invalid tuic uri");
if (!afterScheme) {
throw new Error("Invalid tuic uri");
}
const {
auth,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, {
requireAuth: true,
errorMessage: "Invalid tuic uri",
});
const [uuid, passwordRaw] = splitOnce(auth, ":");
if (passwordRaw === undefined) {
throw new Error("Invalid tuic uri");
}
const portNum = parsePortOrDefault(port, 443);
const password = safeDecodeURIComponent(passwordRaw) ?? passwordRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `TUIC ${server}:${portNum}`;
const proxy: IProxyTuicConfig = {
type: "tuic",
name,
server,
port: portNum,
password,
uuid,
};
const params = parseQueryStringNormalized(addons);
for (const [key, value] of Object.entries(params)) {
switch (key) {
case "token":
proxy.token = value;
break;
case "ip":
proxy.ip = value;
break;
case "heartbeat-interval":
proxy["heartbeat-interval"] = parseInteger(value);
break;
case "alpn":
proxy.alpn = value ? value.split(",") : undefined;
break;
case "disable-sni":
proxy["disable-sni"] = parseBoolOrPresence(value);
break;
case "reduce-rtt":
proxy["reduce-rtt"] = parseBoolOrPresence(value);
break;
case "request-timeout":
proxy["request-timeout"] = parseInteger(value);
break;
case "udp-relay-mode":
proxy["udp-relay-mode"] = value;
break;
case "congestion-controller":
proxy["congestion-controller"] = value;
break;
case "max-udp-relay-packet-size":
proxy["max-udp-relay-packet-size"] = parseInteger(value);
break;
case "fast-open":
proxy["fast-open"] = parseBoolOrPresence(value);
break;
case "skip-cert-verify":
case "allow-insecure":
proxy["skip-cert-verify"] = parseBoolOrPresence(value);
break;
case "max-open-streams":
proxy["max-open-streams"] = parseInteger(value);
break;
case "sni":
proxy.sni = value;
break;
default:
break;
}
}
return proxy;
}

View File

@ -0,0 +1,225 @@
import {
decodeAndTrim,
decodeBase64OrOriginal,
getIfNotBlank,
parseBool,
parseBoolOrPresence,
parseQueryStringNormalized,
parseRequiredPort,
parseUrlLike,
parseVlessFlow,
safeDecodeURIComponent,
stripUriScheme,
trimStr,
} from "./helpers";
/**
* VLess URL Decode.
*/
export function URI_VLESS(line: string): IProxyVlessConfig {
const afterScheme = stripUriScheme(line, "vless", "Invalid vless uri");
if (!afterScheme) {
throw new Error("Invalid vless uri");
}
let rest = afterScheme;
let isShadowrocket = false;
const parseVlessRest = (
input: string,
): {
uuidRaw: string;
server: string;
port: number;
addons?: string;
nameRaw?: string;
} => {
const parsed = parseUrlLike(input, {
requireAuth: true,
errorMessage: "Invalid vless uri",
});
if (!parsed.port) {
throw new Error("Invalid vless uri: missing port");
}
const port = parseRequiredPort(
parsed.port,
"Invalid vless uri: invalid port",
);
return {
uuidRaw: parsed.auth,
server: parsed.host,
port,
addons: parsed.query,
nameRaw: parsed.fragment,
};
};
let parsed: ReturnType<typeof parseVlessRest>;
try {
parsed = parseVlessRest(rest);
} catch {
const shadowMatch = /^(.*?)(\?.*?$)/.exec(rest);
if (!shadowMatch) {
throw new Error("Invalid vless uri");
}
const [, base64Part, other] = shadowMatch;
rest = `${decodeBase64OrOriginal(base64Part)}${other}`;
parsed = parseVlessRest(rest);
isShadowrocket = true;
}
const { uuidRaw, server, port, addons = "", nameRaw } = parsed;
let uuid = uuidRaw;
if (isShadowrocket) {
uuid = uuid.replace(/^.*?:/g, "");
}
uuid = safeDecodeURIComponent(uuid) ?? uuid;
const params = parseQueryStringNormalized(addons);
const name =
decodeAndTrim(nameRaw) ??
trimStr(params.remarks) ??
trimStr(params.remark) ??
`VLESS ${server}:${port}`;
const proxy: IProxyVlessConfig = {
type: "vless",
name,
server,
port,
uuid,
};
proxy.tls = (params.security && params.security !== "none") || undefined;
if (isShadowrocket && parseBool(params.tls) === true) {
proxy.tls = true;
params.security = params.security ?? "reality";
}
proxy.servername = params.sni || params.peer;
proxy.flow = parseVlessFlow(params.flow);
proxy["client-fingerprint"] = params.fp as ClientFingerprint;
proxy.alpn = params.alpn ? params.alpn.split(",") : undefined;
if (Object.prototype.hasOwnProperty.call(params, "allowInsecure")) {
proxy["skip-cert-verify"] = parseBoolOrPresence(params.allowInsecure);
}
if (params.security === "reality") {
const opts: IProxyVlessConfig["reality-opts"] = {};
if (params.pbk) {
opts["public-key"] = params.pbk;
}
if (params.sid) {
opts["short-id"] = params.sid;
}
if (Object.keys(opts).length > 0) {
proxy["reality-opts"] = opts;
}
}
let httpupgrade = false;
let network: NetworkType = "tcp";
if (params.headerType === "http") {
network = "http";
} else {
let type = params.type;
if (type === "websocket") type = "ws";
if (isShadowrocket && type === "sw") type = "ws";
if (type === "httpupgrade") {
network = "ws";
httpupgrade = true;
} else if (type && ["tcp", "ws", "http", "grpc", "h2"].includes(type)) {
network = type as NetworkType;
} else {
network = "tcp";
}
if (params.type === "ws") {
httpupgrade = true;
}
}
proxy.network = network;
if (proxy.network && !["tcp", "none"].includes(proxy.network)) {
const host = params.host ?? params.obfsParam;
const path = params.path;
switch (proxy.network) {
case "grpc":
{
const serviceName = getIfNotBlank(path);
if (serviceName) {
proxy["grpc-opts"] = { "grpc-service-name": serviceName };
}
}
break;
case "h2": {
const h2Opts: H2Options = {};
const hostVal = getIfNotBlank(host);
const pathVal = getIfNotBlank(path);
if (hostVal) h2Opts.host = hostVal;
if (pathVal) h2Opts.path = pathVal;
if (Object.keys(h2Opts).length > 0) {
proxy["h2-opts"] = h2Opts;
}
break;
}
case "http": {
const httpOpts: HttpOptions = {};
const hostVal = getIfNotBlank(host);
const pathVal = getIfNotBlank(path);
if (pathVal) httpOpts.path = [pathVal];
if (hostVal) httpOpts.headers = { Host: [hostVal] };
if (Object.keys(httpOpts).length > 0) {
proxy["http-opts"] = httpOpts;
}
break;
}
case "ws": {
const wsOpts: WsOptions = {};
if (host) {
if (params.obfsParam) {
try {
const parsedHeaders = JSON.parse(host);
wsOpts.headers = parsedHeaders;
} catch (e) {
console.warn("[URI_VLESS] host JSON.parse failed:", e);
wsOpts.headers = { Host: host };
}
} else {
wsOpts.headers = { Host: host };
}
}
if (path) {
wsOpts.path = path;
}
if (httpupgrade) {
wsOpts["v2ray-http-upgrade"] = true;
wsOpts["v2ray-http-upgrade-fast-open"] = true;
}
if (Object.keys(wsOpts).length > 0) {
proxy["ws-opts"] = wsOpts;
}
break;
}
default:
break;
}
}
if (proxy.tls && !proxy.servername) {
if (proxy.network === "ws") {
proxy.servername = proxy["ws-opts"]?.headers?.Host;
} else if (proxy.network === "http") {
proxy.servername = proxy["http-opts"]?.headers?.Host?.[0];
} else if (proxy.network === "h2") {
proxy.servername = proxy["h2-opts"]?.host;
}
}
return proxy;
}

View File

@ -0,0 +1,268 @@
import {
decodeBase64OrOriginal,
firstString,
getCipher,
getIfNotBlank,
getIfPresent,
isPresent,
parseBool,
parseRequiredPort,
safeDecodeURIComponent,
splitOnce,
stripUriScheme,
trimStr,
} from "./helpers";
function parseVmessShadowrocketParams(raw: string): Record<string, any> {
const match = /(^[^?]+?)\/?\?(.*)$/.exec(raw);
if (!match) return {};
const [, base64Line, qs] = match;
const content = decodeBase64OrOriginal(base64Line);
const params: Record<string, any> = {};
for (const addon of qs.split("&")) {
if (!addon) continue;
const [keyRaw, valueRaw] = splitOnce(addon, "=");
const key = keyRaw.trim();
if (!key) continue;
if (valueRaw === undefined) {
params[key] = true;
continue;
}
const value = safeDecodeURIComponent(valueRaw) ?? valueRaw;
params[key] = value.includes(",") ? value.split(",") : value;
}
const contentMatch = /(^[^:]+?):([^:]+?)@(.*):(\d+)$/.exec(content);
if (!contentMatch) return params;
const [, cipher, uuid, server, port] = contentMatch;
params.scy = cipher;
params.id = uuid;
params.port = port;
params.add = server;
return params;
}
function parseVmessParams(decoded: string, raw: string): Record<string, any> {
try {
// V2rayN URI format
return JSON.parse(decoded);
} catch (e) {
// Shadowrocket URI format
console.warn(
"[URI_VMESS] JSON.parse(content) failed, falling back to Shadowrocket parsing:",
e,
);
return parseVmessShadowrocketParams(raw);
}
}
function parseVmessQuantumult(content: string): IProxyVmessConfig {
const partitions = content.split(",").map((p) => p.trim());
const params: Record<string, string> = {};
for (const part of partitions) {
if (part.indexOf("=") !== -1) {
const [key, val] = splitOnce(part, "=");
params[key.trim()] = val?.trim() ?? "";
}
}
const proxy: IProxyVmessConfig = {
name: partitions[0].split("=")[0].trim(),
type: "vmess",
server: partitions[1],
port: parseRequiredPort(partitions[2], "Invalid vmess uri: invalid port"),
cipher: getCipher(getIfNotBlank(partitions[3], "auto")),
uuid: partitions[4].match(/^"(.*)"$/)?.[1] || "",
tls: params.obfs === "wss",
udp: parseBool(params["udp-relay"]),
tfo: parseBool(params["fast-open"]),
"skip-cert-verify":
params["tls-verification"] === undefined
? undefined
: !parseBool(params["tls-verification"]),
};
if (isPresent(params.obfs)) {
if (params.obfs === "ws" || params.obfs === "wss") {
proxy.network = "ws";
proxy["ws-opts"] = {
path:
(getIfNotBlank(params["obfs-path"]) || '"/"').match(
/^"(.*)"$/,
)?.[1] || "/",
headers: {
Host:
params["obfs-header"]?.match(/Host:\s*([a-zA-Z0-9-.]*)/)?.[1] || "",
},
};
} else {
throw new Error(`Unsupported obfs: ${params.obfs}`);
}
}
return proxy;
}
export function URI_VMESS(line: string): IProxyVmessConfig {
const afterScheme = stripUriScheme(line, "vmess", "Invalid vmess uri");
if (!afterScheme) {
throw new Error("Invalid vmess uri");
}
const raw = afterScheme;
const content = decodeBase64OrOriginal(raw);
if (/=\s*vmess/.test(content)) {
return parseVmessQuantumult(content);
}
const params = parseVmessParams(content, raw);
const server = params.add;
const port = parseRequiredPort(
params.port,
"Invalid vmess uri: invalid port",
);
const tlsValue = params.tls;
const proxy: IProxyVmessConfig = {
name:
trimStr(params.ps) ??
trimStr(params.remarks) ??
trimStr(params.remark) ??
`VMess ${server}:${port}`,
type: "vmess",
server,
port,
cipher: getCipher(getIfPresent(params.scy, "auto")),
uuid: params.id,
tls:
tlsValue === "tls" ||
tlsValue === true ||
tlsValue === 1 ||
tlsValue === "1" ||
tlsValue === "true",
"skip-cert-verify": isPresent(params.verify_cert)
? !parseBool(params.verify_cert.toString())
: undefined,
};
proxy.alterId = parseInt(getIfPresent(params.aid ?? params.alterId, 0), 10);
if (proxy.tls && params.sni) {
proxy.servername = params.sni;
}
let httpupgrade = false;
if (params.net === "ws" || params.obfs === "websocket") {
proxy.network = "ws";
} else if (
["http"].includes(params.net) ||
["http"].includes(params.obfs) ||
["http"].includes(params.type)
) {
proxy.network = "http";
} else if (["grpc"].includes(params.net)) {
proxy.network = "grpc";
} else if (params.net === "httpupgrade") {
proxy.network = "ws";
httpupgrade = true;
} else if (params.net === "h2" || proxy.network === "h2") {
proxy.network = "h2";
}
if (proxy.network) {
let transportHost: any = params.host ?? params.obfsParam;
if (typeof transportHost === "string") {
try {
const parsedObfs = JSON.parse(transportHost);
const parsedHost = parsedObfs?.Host;
if (parsedHost) {
transportHost = parsedHost;
}
} catch (e) {
console.warn("[URI_VMESS] transportHost JSON.parse failed:", e);
}
}
const transportPath: any = params.path;
const hostFirst = getIfNotBlank(firstString(transportHost));
const pathFirst = getIfNotBlank(firstString(transportPath));
switch (proxy.network) {
case "grpc": {
if (!hostFirst && !pathFirst) {
delete proxy.network;
break;
}
const serviceName = getIfNotBlank(pathFirst);
if (serviceName) {
proxy["grpc-opts"] = { "grpc-service-name": serviceName };
}
break;
}
case "h2": {
if (!hostFirst && !pathFirst) {
delete proxy.network;
break;
}
const h2Opts: H2Options = {};
if (hostFirst) h2Opts.host = hostFirst;
if (pathFirst) h2Opts.path = pathFirst;
if (Object.keys(h2Opts).length > 0) {
proxy["h2-opts"] = h2Opts;
}
break;
}
case "http": {
const hosts = Array.isArray(transportHost)
? transportHost
.map((h: any) => String(h).trim())
.filter((h: string) => h)
: hostFirst
? [hostFirst]
: undefined;
let paths = Array.isArray(transportPath)
? transportPath
.map((p: any) => String(p).trim())
.filter((p: string) => p)
: pathFirst
? [pathFirst]
: [];
if (paths.length === 0) paths = ["/"];
const httpOpts: HttpOptions = { path: paths };
if (hosts && hosts.length > 0) {
httpOpts.headers = { Host: hosts };
}
proxy["http-opts"] = httpOpts;
break;
}
case "ws": {
if (!hostFirst && !pathFirst && !httpupgrade) {
delete proxy.network;
break;
}
const wsOpts: WsOptions = {
path: pathFirst,
headers: hostFirst ? { Host: hostFirst } : undefined,
};
if (httpupgrade) {
wsOpts["v2ray-http-upgrade"] = true;
wsOpts["v2ray-http-upgrade-fast-open"] = true;
}
proxy["ws-opts"] = wsOpts;
break;
}
default:
break;
}
if (proxy.tls && !proxy.servername && hostFirst) {
proxy.servername = hostFirst;
}
}
return proxy;
}

View File

@ -0,0 +1,110 @@
import {
decodeAndTrim,
isIPv4,
isIPv6,
parseBoolOrPresence,
parseInteger,
parsePortOrDefault,
parseQueryStringNormalized,
parseUrlLike,
safeDecodeURIComponent,
stripUriScheme,
} from "./helpers";
export function URI_Wireguard(line: string): IProxyWireguardConfig {
const afterScheme = stripUriScheme(
line,
["wireguard", "wg"],
"Invalid wireguard uri",
);
if (!afterScheme) {
throw new Error("Invalid wireguard uri");
}
const {
auth: privateKeyRaw,
host: server,
port,
query: addons,
fragment: nameRaw,
} = parseUrlLike(afterScheme, { errorMessage: "Invalid wireguard uri" });
const portNum = parsePortOrDefault(port, 443);
const privateKey = safeDecodeURIComponent(privateKeyRaw) ?? privateKeyRaw;
const decodedName = decodeAndTrim(nameRaw);
const name = decodedName ?? `WireGuard ${server}:${portNum}`;
const proxy: IProxyWireguardConfig = {
type: "wireguard",
name,
server,
port: portNum,
"private-key": privateKey,
udp: true,
};
const params = parseQueryStringNormalized(addons);
for (const [key, value] of Object.entries(params)) {
switch (key) {
case "address":
case "ip":
if (!value) break;
value.split(",").forEach((i) => {
const ip = i
.trim()
.replace(/\/\d+$/, "")
.replace(/^\[/, "")
.replace(/\]$/, "");
if (isIPv4(ip)) {
proxy.ip = ip;
} else if (isIPv6(ip)) {
proxy.ipv6 = ip;
}
});
break;
case "publickey":
case "public-key":
if (!value) break;
proxy["public-key"] = value;
break;
case "allowed-ips":
if (!value) break;
proxy["allowed-ips"] = value.split(",");
break;
case "pre-shared-key":
if (!value) break;
proxy["pre-shared-key"] = value;
break;
case "reserved":
{
if (!value) break;
const parsed = value
.split(",")
.map((i) => parseInteger(i.trim()))
.filter((i): i is number => Number.isInteger(i));
if (parsed.length === 3) {
proxy["reserved"] = parsed;
}
}
break;
case "udp":
proxy.udp = parseBoolOrPresence(value);
break;
case "mtu":
proxy.mtu = parseInteger(value?.trim());
break;
case "dialer-proxy":
proxy["dialer-proxy"] = value;
break;
case "remote-dns-resolve":
proxy["remote-dns-resolve"] = parseBoolOrPresence(value);
break;
case "dns":
if (!value) break;
proxy.dns = value.split(",");
break;
default:
break;
}
}
return proxy;
}