From 3bdba3e50f8f5a7e06b26445eabfcdad7f36037c Mon Sep 17 00:00:00 2001 From: YANG JIANKUAN Date: Tue, 23 Jun 2026 23:43:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20anytls/tuic/hyster?= =?UTF-8?q?ia2/socks5=20=E5=8D=8F=E8=AE=AE=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 anytls、tuic(tuic-v5)、hysteria2、socks5/socks5-tls 解析器, 修复 vmess WebSocket path 处理,并优化 Docker 构建配置。 Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + Dockerfile | 10 ++- server/src/parsers/anytls.ts | 20 ++++++ server/src/parsers/hysteria2.ts | 28 ++++++++ server/src/parsers/index.ts | 11 ++++ server/src/parsers/socks.ts | 51 +++++++++++++++ server/src/parsers/toClash.ts | 112 +++++++++++++++++++++++++++++++- server/src/parsers/tuic.ts | 26 ++++++++ server/src/parsers/util.ts | 16 +++++ server/src/parsers/vmess.ts | 3 +- web/package.json | 5 ++ 11 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 server/src/parsers/anytls.ts create mode 100644 server/src/parsers/hysteria2.ts create mode 100644 server/src/parsers/socks.ts create mode 100644 server/src/parsers/tuic.ts create mode 100644 server/src/parsers/util.ts diff --git a/.gitignore b/.gitignore index 3a86c11..213183f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +*.tsbuildinfo server/data/*.db *.db-wal *.db-shm diff --git a/Dockerfile b/Dockerfile index 60533f9..c6272c1 100644 --- a/Dockerfile +++ b/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 diff --git a/server/src/parsers/anytls.ts b/server/src/parsers/anytls.ts new file mode 100644 index 0000000..514050c --- /dev/null +++ b/server/src/parsers/anytls.ts @@ -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 }; +} diff --git a/server/src/parsers/hysteria2.ts b/server/src/parsers/hysteria2.ts new file mode 100644 index 0000000..b4ee8ba --- /dev/null +++ b/server/src/parsers/hysteria2.ts @@ -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 }; +} diff --git a/server/src/parsers/index.ts b/server/src/parsers/index.ts index f58c5e7..6a5f0bf 100644 --- a/server/src/parsers/index.ts +++ b/server/src/parsers/index.ts @@ -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; diff --git a/server/src/parsers/socks.ts b/server/src/parsers/socks.ts new file mode 100644 index 0000000..e3363b1 --- /dev/null +++ b/server/src/parsers/socks.ts @@ -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 }; +} diff --git a/server/src/parsers/toClash.ts b/server/src/parsers/toClash.ts index 86cbcff..3d84590 100644 --- a/server/src/parsers/toClash.ts +++ b/server/src/parsers/toClash.ts @@ -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 = {}; - 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; +} diff --git a/server/src/parsers/tuic.ts b/server/src/parsers/tuic.ts new file mode 100644 index 0000000..5375513 --- /dev/null +++ b/server/src/parsers/tuic.ts @@ -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 }; +} diff --git a/server/src/parsers/util.ts b/server/src/parsers/util.ts new file mode 100644 index 0000000..1cc59c7 --- /dev/null +++ b/server/src/parsers/util.ts @@ -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'; +} diff --git a/server/src/parsers/vmess.ts b/server/src/parsers/vmess.ts index 0c2503c..60a209a 100644 --- a/server/src/parsers/vmess.ts +++ b/server/src/parsers/vmess.ts @@ -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}`; } diff --git a/web/package.json b/web/package.json index c6ca34f..89e1162 100644 --- a/web/package.json +++ b/web/package.json @@ -18,5 +18,10 @@ "@vitejs/plugin-react": "^4.3.0", "typescript": "^5.7.0", "vite": "^6.0.0" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] } }