feat: 新增 anytls/tuic/hysteria2/socks5 协议解析

新增 anytls、tuic(tuic-v5)、hysteria2、socks5/socks5-tls 解析器,
修复 vmess WebSocket path 处理,并优化 Docker 构建配置。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 23:43:35 +08:00
parent fb7a30489e
commit 3bdba3e50f
11 changed files with 278 additions and 5 deletions

View File

@@ -0,0 +1,20 @@
import type { ParsedNode } from './ss.js';
import { readInsecure } from './util.js';
// anytls://password@host:port?sni=...&insecure=1#name
export function parseAnytls(uri: string): ParsedNode {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port || '443', 10);
const name = decodeURIComponent(url.hash.slice(1)) || 'AnyTLS';
const password = decodeURIComponent(url.username);
const params = url.searchParams;
const sni = params.get('sni') || '';
let line = `${name} = anytls, ${server}, ${port}, password=${password}`;
if (sni) line += `, sni=${sni}`;
line += `, skip-cert-verify=${readInsecure(params)}`;
return { name, type: 'anytls', server, port, surgeLine: line };
}

View File

@@ -0,0 +1,28 @@
import type { ParsedNode } from './ss.js';
import { readInsecure } from './util.js';
// hysteria2://auth@host:port?sni=...&insecure=1&obfs=salamander&obfs-password=...#name
// hy2:// is an accepted alias for the scheme.
export function parseHysteria2(uri: string): ParsedNode {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port || '443', 10);
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
// The auth string lives in the userinfo. For userpass auth it is "username:password".
const user = decodeURIComponent(url.username);
const pass = decodeURIComponent(url.password);
const auth = pass ? `${user}:${pass}` : user;
const params = url.searchParams;
const sni = params.get('sni') || '';
const obfs = params.get('obfs') || '';
const obfsPassword = params.get('obfs-password') || params.get('obfs_password') || '';
let line = `${name} = hysteria2, ${server}, ${port}, password=${auth}`;
if (sni) line += `, sni=${sni}`;
if (obfs === 'salamander' && obfsPassword) line += `, salamander-password=${obfsPassword}`;
line += `, skip-cert-verify=${readInsecure(params)}`;
return { name, type: 'hysteria2', server, port, surgeLine: line };
}

View File

@@ -2,6 +2,10 @@ import { parseSS, type ParsedNode } from './ss.js';
import { parseVMess } from './vmess.js';
import { parseTrojan } from './trojan.js';
import { parseVless } from './vless.js';
import { parseSocks5, parseSocks5Tls } from './socks.js';
import { parseHysteria2 } from './hysteria2.js';
import { parseTuic } from './tuic.js';
import { parseAnytls } from './anytls.js';
export type { ParsedNode };
@@ -11,6 +15,13 @@ export function parseNodeUri(uri: string): ParsedNode | null {
if (uri.startsWith('vmess://')) return parseVMess(uri);
if (uri.startsWith('trojan://')) return parseTrojan(uri);
if (uri.startsWith('vless://')) return parseVless(uri);
if (uri.startsWith('hysteria2://') || uri.startsWith('hy2://')) return parseHysteria2(uri);
if (uri.startsWith('tuic://')) return parseTuic(uri);
if (uri.startsWith('anytls://')) return parseAnytls(uri);
// socks5-over-tls variants must be checked before plain socks
if (uri.startsWith('socks5-tls://') || uri.startsWith('socks5+tls://') || uri.startsWith('socks+tls://'))
return parseSocks5Tls(uri);
if (uri.startsWith('socks5://') || uri.startsWith('socks://')) return parseSocks5(uri);
return null;
} catch {
return null;

View File

@@ -0,0 +1,51 @@
import type { ParsedNode } from './ss.js';
import { readInsecure } from './util.js';
export function parseSocks5(uri: string): ParsedNode {
return parseSocks(uri, false);
}
export function parseSocks5Tls(uri: string): ParsedNode {
return parseSocks(uri, true);
}
// socks5://user:pass@host:port#name | socks://host:port#name (no auth)
// socks5-tls://user:pass@host:port?sni=...&skip-cert-verify=1#name
function parseSocks(uri: string, tls: boolean): ParsedNode {
const url = new URL(uri);
const type = tls ? 'socks5-tls' : 'socks5';
const server = url.hostname;
const port = parseInt(url.port, 10);
const name = decodeURIComponent(url.hash.slice(1)) || (tls ? 'SOCKS5-TLS' : 'SOCKS5');
let username = decodeURIComponent(url.username);
let password = decodeURIComponent(url.password);
// Shadowrocket-style: userinfo is base64(username:password) with no separate password part
if (username && !password && !username.includes(':')) {
try {
const decoded = Buffer.from(username, 'base64').toString();
const idx = decoded.indexOf(':');
if (idx !== -1) {
username = decoded.slice(0, idx);
password = decoded.slice(idx + 1);
}
} catch {
// keep original username
}
}
// username/password are positional in Surge; emit both only when auth is present
let line = `${name} = ${type}, ${server}, ${port}`;
if (username || password) {
line += `, ${username}, ${password}`;
}
if (tls) {
const sni = url.searchParams.get('sni') || url.searchParams.get('tls-host') || '';
if (sni) line += `, sni=${sni}`;
line += `, skip-cert-verify=${readInsecure(url.searchParams)}`;
}
return { name, type, server, port, surgeLine: line };
}

View File

@@ -1,8 +1,10 @@
/**
* Convert proxy URIs (ss://, vmess://, trojan://) to Clash proxy objects.
* Convert proxy URIs to Clash (mihomo) proxy objects.
* Returns a JS object suitable for inclusion in a Clash YAML `proxies:` array,
* or null if the URI is unsupported/invalid.
*/
import { readInsecure, normalizeWsPath } from './util.js';
export interface ClashProxy {
name: string;
type: string;
@@ -16,6 +18,12 @@ export function uriToClashProxy(uri: string): ClashProxy | null {
if (uri.startsWith('ss://')) return ssToClash(uri);
if (uri.startsWith('vmess://')) return vmessToClash(uri);
if (uri.startsWith('trojan://')) return trojanToClash(uri);
if (uri.startsWith('hysteria2://') || uri.startsWith('hy2://')) return hysteria2ToClash(uri);
if (uri.startsWith('tuic://')) return tuicToClash(uri);
if (uri.startsWith('anytls://')) return anytlsToClash(uri);
if (uri.startsWith('socks5-tls://') || uri.startsWith('socks5+tls://') || uri.startsWith('socks+tls://'))
return socks5ToClash(uri, true);
if (uri.startsWith('socks5://') || uri.startsWith('socks://')) return socks5ToClash(uri, false);
return null;
} catch {
return null;
@@ -66,7 +74,7 @@ function vmessToClash(uri: string): ClashProxy | null {
if (json.net === 'ws') {
proxy.network = 'ws';
const wsOpts: Record<string, unknown> = {};
if (json.path) wsOpts.path = json.path;
if (json.path) wsOpts.path = normalizeWsPath(json.path);
if (json.host) wsOpts.headers = { Host: json.host };
if (Object.keys(wsOpts).length > 0) proxy['ws-opts'] = wsOpts;
}
@@ -84,3 +92,103 @@ function trojanToClash(uri: string): ClashProxy | null {
return { name, type: 'trojan', server, port, password, sni };
}
function socks5ToClash(uri: string, tls: boolean): ClashProxy | null {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port, 10);
if (!server || !port) return null;
const name = decodeURIComponent(url.hash.slice(1)) || (tls ? 'SOCKS5-TLS' : 'SOCKS5');
let username = decodeURIComponent(url.username);
let password = decodeURIComponent(url.password);
if (username && !password && !username.includes(':')) {
try {
const decoded = Buffer.from(username, 'base64').toString();
const idx = decoded.indexOf(':');
if (idx !== -1) {
username = decoded.slice(0, idx);
password = decoded.slice(idx + 1);
}
} catch {
// keep original username
}
}
const proxy: ClashProxy = { name, type: 'socks5', server, port, udp: true };
if (username) proxy.username = username;
if (password) proxy.password = password;
if (tls) {
proxy.tls = true;
const sni = url.searchParams.get('sni') || url.searchParams.get('tls-host');
if (sni) proxy.sni = sni;
proxy['skip-cert-verify'] = readInsecure(url.searchParams);
}
return proxy;
}
function hysteria2ToClash(uri: string): ClashProxy | null {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port || '443', 10);
if (!server || !port) return null;
const name = decodeURIComponent(url.hash.slice(1)) || 'Hysteria2';
const user = decodeURIComponent(url.username);
const pass = decodeURIComponent(url.password);
const auth = pass ? `${user}:${pass}` : user;
const params = url.searchParams;
const proxy: ClashProxy = { name, type: 'hysteria2', server, port, password: auth };
const sni = params.get('sni');
if (sni) proxy.sni = sni;
const obfs = params.get('obfs');
const obfsPassword = params.get('obfs-password') || params.get('obfs_password');
if (obfs) {
proxy.obfs = obfs;
if (obfsPassword) proxy['obfs-password'] = obfsPassword;
}
proxy['skip-cert-verify'] = readInsecure(params);
return proxy;
}
function tuicToClash(uri: string): ClashProxy | null {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port, 10);
if (!server || !port) return null;
const name = decodeURIComponent(url.hash.slice(1)) || 'TUIC';
const uuid = decodeURIComponent(url.username);
const password = decodeURIComponent(url.password);
const params = url.searchParams;
const proxy: ClashProxy = { name, type: 'tuic', server, port, uuid, password };
const sni = params.get('sni');
if (sni) proxy.sni = sni;
const alpn = (params.get('alpn') || 'h3').split(',').map(s => s.trim()).filter(Boolean);
if (alpn.length) proxy.alpn = alpn;
const cc = params.get('congestion_control') || params.get('congestion-controller');
if (cc) proxy['congestion-controller'] = cc;
const udpMode = params.get('udp_relay_mode') || params.get('udp-relay-mode');
if (udpMode) proxy['udp-relay-mode'] = udpMode;
proxy['skip-cert-verify'] = readInsecure(params);
return proxy;
}
function anytlsToClash(uri: string): ClashProxy | null {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port || '443', 10);
if (!server || !port) return null;
const name = decodeURIComponent(url.hash.slice(1)) || 'AnyTLS';
const password = decodeURIComponent(url.username);
const params = url.searchParams;
const proxy: ClashProxy = { name, type: 'anytls', server, port, password };
const sni = params.get('sni');
if (sni) proxy.sni = sni;
proxy['skip-cert-verify'] = readInsecure(params);
return proxy;
}

View File

@@ -0,0 +1,26 @@
import type { ParsedNode } from './ss.js';
import { readInsecure } from './util.js';
// TUIC v5: tuic://uuid:password@host:port?sni=...&alpn=h3&congestion_control=bbr&allow_insecure=1#name
export function parseTuic(uri: string): ParsedNode {
const url = new URL(uri);
const server = url.hostname;
const port = parseInt(url.port, 10);
const name = decodeURIComponent(url.hash.slice(1)) || 'TUIC';
const uuid = decodeURIComponent(url.username);
const password = decodeURIComponent(url.password);
const params = url.searchParams;
const alpn = params.get('alpn') || 'h3';
const sni = params.get('sni') || '';
// Surge uses the type name `tuic-v5` for TUIC v5 (uuid+password); plain `tuic` is v4 (token).
// There is NO `version` field — using `tuic` + `version=5` makes Surge parse the line as v4 and
// reject it ("token must be provided"). Field order mirrors Surge Mac 6.6.0's own GUI export.
let line = `${name} = tuic-v5, ${server}, ${port}, password=${password}, uuid=${uuid}`;
if (alpn) line += `, alpn=${alpn}`;
line += `, skip-cert-verify=${readInsecure(params)}`;
if (sni) line += `, sni=${sni}`;
return { name, type: 'tuic', server, port, surgeLine: line };
}

View File

@@ -0,0 +1,16 @@
/** Surge (and Clash) require the WebSocket path to be an absolute path; ensure a leading slash. */
export function normalizeWsPath(path: string): string {
if (!path) return path;
return path.startsWith('/') ? path : '/' + path;
}
/** Read a "skip certificate verification / insecure TLS" flag from the common query-param spellings. */
export function readInsecure(params: URLSearchParams): boolean {
const v =
params.get('insecure') ??
params.get('allowInsecure') ??
params.get('allow_insecure') ??
params.get('skip-cert-verify') ??
params.get('skipVerify');
return v === '1' || v === 'true';
}

View File

@@ -1,4 +1,5 @@
import type { ParsedNode } from './ss.js';
import { normalizeWsPath } from './util.js';
export function parseVMess(uri: string): ParsedNode {
const b64 = uri.replace('vmess://', '');
@@ -19,7 +20,7 @@ export function parseVMess(uri: string): ParsedNode {
if (json.net === 'ws') {
line += ', ws=true';
if (json.path) line += `, ws-path=${json.path}`;
if (json.path) line += `, ws-path=${normalizeWsPath(json.path)}`;
if (json.host) line += `, ws-headers=Host:${json.host}`;
}