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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
server/data/*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
10
Dockerfile
10
Dockerfile
@@ -1,5 +1,8 @@
|
||||
FROM node:22-slim AS frontend
|
||||
RUN corepack enable pnpm
|
||||
# Pin pnpm: corepack otherwise auto-fetches the latest (pnpm 11), which no longer
|
||||
# reads the "pnpm.onlyBuiltDependencies" field in package.json and fails the build
|
||||
# on ignored install scripts (esbuild / better-sqlite3). v10 still honors that field.
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.34.3 --activate
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json ./
|
||||
RUN pnpm config set registry https://registry.npmmirror.com && pnpm install
|
||||
@@ -7,7 +10,10 @@ COPY web/ ./
|
||||
RUN pnpm run build
|
||||
|
||||
FROM node:22-slim AS production
|
||||
RUN corepack enable pnpm
|
||||
# Pin pnpm: corepack otherwise auto-fetches the latest (pnpm 11), which no longer
|
||||
# reads the "pnpm.onlyBuiltDependencies" field in package.json and fails the build
|
||||
# on ignored install scripts (esbuild / better-sqlite3). v10 still honors that field.
|
||||
RUN corepack enable pnpm && corepack prepare pnpm@10.34.3 --activate
|
||||
WORKDIR /app
|
||||
|
||||
# Switch to Aliyun mirror for faster apt downloads in China
|
||||
|
||||
20
server/src/parsers/anytls.ts
Normal file
20
server/src/parsers/anytls.ts
Normal 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 };
|
||||
}
|
||||
28
server/src/parsers/hysteria2.ts
Normal file
28
server/src/parsers/hysteria2.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
51
server/src/parsers/socks.ts
Normal file
51
server/src/parsers/socks.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
26
server/src/parsers/tuic.ts
Normal file
26
server/src/parsers/tuic.ts
Normal 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 };
|
||||
}
|
||||
16
server/src/parsers/util.ts
Normal file
16
server/src/parsers/util.ts
Normal 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';
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,5 +18,10 @@
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user