3x-ui/frontend/src/lib/xray/outbound-link-parser.ts

440 lines
15 KiB
TypeScript
Raw Normal View History

feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
import { Base64 } from '@/utils';
// Focused share-link parser for the OutboundFormModal's link-import
// helper. Each parser returns a wire-shape outbound record (the same
// shape OutboundsTab.tsx stores in templateSettings.outbounds[]) or
// null when the input doesn't match.
//
// Scope: address + port + auth + remark, plus the network/security
// fields the common vmess:// / vless:// links carry as query params.
// XHTTP advanced fields (xPaddingBytes, scMaxEachPostBytes,
// scMinPostsIntervalMs, uplinkChunkSize, noGRPCHeader) round-trip when
// present in either the JSON or URL params. xmux, reality shortIds,
// padding obfs key/header/placement, hysteria udphop are still left
// to the user to fill in after import — the legacy Outbound.fromLink
// was ~250 lines of dense edge-case handling we don't need to
// replicate verbatim for the common phone-to-panel workflow.
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
type Raw = Record<string, unknown>;
// XHTTP knob keys grouped by wire type. Used by both the URL query-param
// (vless/trojan) branch and the vmess JSON branch to consistently pull
// the same set of advanced fields when present. Keep order ~stable to
// match the schema's authoring order so diffs read naturally.
const XHTTP_STRING_KEYS = [
'xPaddingBytes', 'xPaddingKey', 'xPaddingHeader', 'xPaddingPlacement',
'xPaddingMethod', 'sessionPlacement', 'sessionKey', 'seqPlacement',
'seqKey', 'uplinkDataPlacement', 'uplinkDataKey', 'scMaxEachPostBytes',
'scMinPostsIntervalMs', 'scStreamUpServerSecs', 'uplinkHTTPMethod',
] as const;
const XHTTP_NUMBER_KEYS = [
'scMaxBufferedPosts', 'serverMaxHeaderBytes', 'uplinkChunkSize',
] as const;
const XHTTP_BOOL_KEYS = [
'xPaddingObfsMode', 'noSSEHeader', 'noGRPCHeader',
] as const;
function asBool(s: string | null): boolean | undefined {
if (s === null) return undefined;
return s === 'true' || s === '1';
}
function applyXhttpStringFromParams(xhttp: Raw, params: URLSearchParams): void {
// Precedence from lowest to highest: stream-init default →
// x_padding_bytes snake_case alias → extra JSON payload →
// explicit camelCase URL param. Apply in that order so each tier
// overwrites the previous when present.
const padBytesAlt = params.get('x_padding_bytes');
if (padBytesAlt !== null && padBytesAlt !== '') {
xhttp.xPaddingBytes = padBytesAlt;
}
// The inbound link bundles advanced xhttp knobs into `extra=<json>`.
// Decode and merge so re-importing a share link round-trips the full
// xhttp config (xPaddingBytes, scMaxEachPostBytes, sessionKey, etc.).
const extra = params.get('extra');
if (extra) {
try {
const parsed = JSON.parse(extra) as Record<string, unknown>;
applyXhttpStringFromJson(xhttp, parsed);
if (parsed.headers && typeof parsed.headers === 'object') {
xhttp.headers = parsed.headers;
}
} catch {
// malformed extra — silently ignore, the panel can still operate
// on the rest of the link
}
}
for (const k of XHTTP_STRING_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = v;
}
for (const k of XHTTP_NUMBER_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = Number(v) || 0;
}
for (const k of XHTTP_BOOL_KEYS) {
const v = params.get(k);
if (v !== null && v !== '') xhttp[k] = asBool(v);
}
}
function applyXhttpStringFromJson(xhttp: Raw, json: Record<string, unknown>): void {
for (const k of XHTTP_STRING_KEYS) {
if (typeof json[k] === 'string') xhttp[k] = json[k];
}
for (const k of XHTTP_NUMBER_KEYS) {
if (typeof json[k] === 'number') xhttp[k] = json[k];
}
for (const k of XHTTP_BOOL_KEYS) {
if (typeof json[k] === 'boolean') xhttp[k] = json[k];
}
}
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
function buildStream(network: string, security: string): Raw {
const stream: Raw = { network, security };
switch (network) {
case 'tcp':
stream.tcpSettings = { header: { type: 'none' } };
break;
case 'kcp':
stream.kcpSettings = {
mtu: 1350, tti: 20, uplinkCapacity: 5, downlinkCapacity: 20,
cwndMultiplier: 1, maxSendingWindow: 2097152,
};
break;
case 'ws':
stream.wsSettings = { path: '/', host: '', headers: {}, heartbeatPeriod: 0 };
break;
case 'grpc':
stream.grpcSettings = { serviceName: '', authority: '', multiMode: false };
break;
case 'httpupgrade':
stream.httpupgradeSettings = { path: '/', host: '', headers: {} };
break;
case 'xhttp':
stream.xhttpSettings = {
path: '/', host: '', mode: 'auto', headers: {},
xPaddingBytes: '100-1000', scMaxEachPostBytes: '1000000',
};
break;
default:
stream.tcpSettings = { header: { type: 'none' } };
}
if (security === 'tls') {
stream.tlsSettings = {
serverName: '', alpn: [], fingerprint: '',
echConfigList: '', verifyPeerCertByName: '', pinnedPeerCertSha256: '',
};
} else if (security === 'reality') {
stream.realitySettings = {
publicKey: '', fingerprint: 'chrome', serverName: '',
shortId: '', spiderX: '', mldsa65Verify: '',
};
}
return stream;
}
function applyTransportParams(stream: Raw, params: URLSearchParams): void {
const network = stream.network as string;
const host = params.get('host') ?? '';
const path = params.get('path') ?? '/';
switch (network) {
case 'ws':
(stream.wsSettings as Raw).host = host;
(stream.wsSettings as Raw).path = path;
break;
case 'grpc': {
const grpc = stream.grpcSettings as Raw;
const serviceName = params.get('serviceName') ?? params.get('path') ?? '';
grpc.serviceName = serviceName;
grpc.authority = params.get('authority') ?? '';
grpc.multiMode = params.get('mode') === 'multi';
break;
}
case 'httpupgrade':
(stream.httpupgradeSettings as Raw).host = host;
(stream.httpupgradeSettings as Raw).path = path;
break;
case 'xhttp': {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = host;
xhttp.path = path;
if (params.get('mode')) xhttp.mode = params.get('mode');
applyXhttpStringFromParams(xhttp, params);
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
break;
}
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
case 'tcp':
// vless/trojan TCP HTTP camouflage rides on header=http+host+path
if (params.get('headerType') === 'http' || params.get('type') === 'http') {
(stream.tcpSettings as Raw).header = {
type: 'http',
request: {
version: '1.1',
method: 'GET',
path: path.split(',').filter(Boolean),
headers: host ? { Host: host.split(',').filter(Boolean) } : {},
},
};
}
break;
}
}
// The inbound link emits the entire finalmask object as a JSON-encoded
// `fm` query param. Decode and attach to streamSettings so udpHop /
// quicParams / tcp+udp masks round-trip on outbound import.
function applyFinalMaskParam(stream: Raw, params: URLSearchParams): void {
const fm = params.get('fm');
if (!fm) return;
try {
const parsed = JSON.parse(fm) as Record<string, unknown>;
if (parsed && typeof parsed === 'object') {
stream.finalmask = parsed;
}
} catch {
// malformed fm — leave streamSettings.finalmask absent
}
}
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
function applySecurityParams(stream: Raw, params: URLSearchParams): void {
if (stream.security === 'tls') {
const tls = stream.tlsSettings as Raw;
tls.serverName = params.get('sni') ?? '';
tls.fingerprint = params.get('fp') ?? '';
const alpn = params.get('alpn');
if (alpn) tls.alpn = alpn.split(',');
} else if (stream.security === 'reality') {
const reality = stream.realitySettings as Raw;
reality.serverName = params.get('sni') ?? '';
reality.fingerprint = params.get('fp') ?? 'chrome';
reality.publicKey = params.get('pbk') ?? '';
reality.shortId = params.get('sid') ?? '';
reality.spiderX = params.get('spx') ?? '';
}
}
function decodeRemark(url: URL): string {
try {
return decodeURIComponent(url.hash.replace(/^#/, ''));
} catch {
return url.hash.replace(/^#/, '');
}
}
export function parseVmessLink(link: string): Raw | null {
if (!link.startsWith('vmess://')) return null;
try {
const decoded = Base64.decode(link.slice('vmess://'.length));
const json = JSON.parse(decoded) as Record<string, unknown>;
const network = (json.net as string) || 'tcp';
const security = json.tls === 'tls' ? 'tls' : 'none';
const stream = buildStream(network, security);
// Map the vmess JSON's net-specific keys onto the stream branch.
if (network === 'tcp' && json.type === 'http') {
(stream.tcpSettings as Raw).header = {
type: 'http',
request: {
version: '1.1', method: 'GET',
path: (json.path as string ?? '/').split(',').filter(Boolean),
headers: json.host ? { Host: (json.host as string).split(',').filter(Boolean) } : {},
},
};
} else if (network === 'ws') {
(stream.wsSettings as Raw).host = json.host ?? '';
(stream.wsSettings as Raw).path = json.path ?? '/';
} else if (network === 'grpc') {
(stream.grpcSettings as Raw).serviceName = json.path ?? '';
(stream.grpcSettings as Raw).authority = json.authority ?? '';
(stream.grpcSettings as Raw).multiMode = json.type === 'multi';
} else if (network === 'httpupgrade') {
(stream.httpupgradeSettings as Raw).host = json.host ?? '';
(stream.httpupgradeSettings as Raw).path = json.path ?? '/';
} else if (network === 'xhttp') {
const xhttp = stream.xhttpSettings as Raw;
xhttp.host = json.host ?? '';
xhttp.path = json.path ?? '/';
if (json.mode) xhttp.mode = json.mode;
applyXhttpStringFromJson(xhttp, json);
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
}
if (security === 'tls') {
const tls = stream.tlsSettings as Raw;
tls.serverName = json.sni ?? '';
tls.fingerprint = json.fp ?? '';
if (json.alpn) tls.alpn = (json.alpn as string).split(',');
}
const port = Number(json.port) || 443;
return {
protocol: 'vmess',
tag: typeof json.ps === 'string' ? json.ps : '',
settings: {
vnext: [{
address: json.add ?? '',
port,
users: [{ id: json.id ?? '', security: (json.scy as string) || 'auto' }],
}],
},
streamSettings: stream,
};
} catch {
return null;
}
}
function parseUrlLink(link: string, expectedProto: string): URL | null {
try {
const url = new URL(link);
if (url.protocol.replace(/:$/, '') !== expectedProto) return null;
return url;
} catch {
return null;
}
}
export function parseVlessLink(link: string): Raw | null {
const url = parseUrlLink(link, 'vless');
if (!url) return null;
const id = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const network = params.get('type') ?? 'tcp';
const security = (params.get('security') ?? 'none') as string;
const stream = buildStream(network, security);
applyTransportParams(stream, params);
applySecurityParams(stream, params);
applyFinalMaskParam(stream, params);
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
return {
protocol: 'vless',
tag: decodeRemark(url),
settings: {
address,
port,
id,
flow: params.get('flow') ?? '',
encryption: params.get('encryption') ?? 'none',
},
streamSettings: stream,
};
}
export function parseTrojanLink(link: string): Raw | null {
const url = parseUrlLink(link, 'trojan');
if (!url) return null;
const password = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const network = params.get('type') ?? 'tcp';
const security = (params.get('security') ?? 'tls') as string;
const stream = buildStream(network, security);
applyTransportParams(stream, params);
applySecurityParams(stream, params);
applyFinalMaskParam(stream, params);
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
return {
protocol: 'trojan',
tag: decodeRemark(url),
settings: {
servers: [{ address, port, password }],
},
streamSettings: stream,
};
}
export function parseShadowsocksLink(link: string): Raw | null {
if (!link.startsWith('ss://')) return null;
// Two link shapes coexist:
// modern: ss://base64(method:password)@host:port#remark
// legacy: ss://base64(method:password@host:port)#remark
// Try modern first; fall back to legacy decode of the whole userinfo+host.
let userInfo: string;
let host: string;
let port: number;
let remark = '';
const hashIndex = link.indexOf('#');
const linkNoHash = hashIndex >= 0 ? link.slice(0, hashIndex) : link;
if (hashIndex >= 0) {
try { remark = decodeURIComponent(link.slice(hashIndex + 1)); } catch { remark = ''; }
}
const atIndex = linkNoHash.indexOf('@');
if (atIndex >= 0) {
try { userInfo = Base64.decode(linkNoHash.slice('ss://'.length, atIndex)); }
catch { userInfo = linkNoHash.slice('ss://'.length, atIndex); }
const hostPort = linkNoHash.slice(atIndex + 1);
const colon = hostPort.lastIndexOf(':');
if (colon < 0) return null;
host = hostPort.slice(0, colon);
port = Number(hostPort.slice(colon + 1)) || 443;
} else {
let decoded: string;
try { decoded = Base64.decode(linkNoHash.slice('ss://'.length)); }
catch { return null; }
const at = decoded.indexOf('@');
if (at < 0) return null;
userInfo = decoded.slice(0, at);
const hostPort = decoded.slice(at + 1);
const colon = hostPort.lastIndexOf(':');
if (colon < 0) return null;
host = hostPort.slice(0, colon);
port = Number(hostPort.slice(colon + 1)) || 443;
}
const sep = userInfo.indexOf(':');
const method = sep < 0 ? '2022-blake3-aes-128-gcm' : userInfo.slice(0, sep);
const password = sep < 0 ? userInfo : userInfo.slice(sep + 1);
return {
protocol: 'shadowsocks',
tag: remark,
settings: {
servers: [{ address: host, port, password, method }],
},
};
}
export function parseHysteria2Link(link: string): Raw | null {
const url = parseUrlLink(link, 'hysteria2') ?? parseUrlLink(link, 'hy2');
if (!url) return null;
// hysteria2's auth rides as the URL userinfo. The streamSettings
// network branch is the dedicated 'hysteria' transport — the modal's
// newStreamSlice('hysteria') initializer fills in receive-window
// defaults; we override the user-set fields here.
const auth = url.username;
const address = url.hostname;
const port = Number(url.port) || 443;
const params = url.searchParams;
const stream: Raw = {
network: 'hysteria',
security: 'tls',
hysteriaSettings: {
refactor(frontend): align hysteria with new docs + drop hysteria2 protocol Phase 2 smoke fixes on the Inbound add flow surfaced that hysteria2 was modeled as a separate top-level protocol when it's really just hysteria v2. The xray transports/hysteria.html docs also pin the hysteria stream to a minimal shape (version/auth/udpIdleTimeout/masquerade) — the previous schema carried legacy congestion/up/down/udphop/window knobs that aren't part of the wire contract. Hysteria2 removal: - Drop 'hysteria2' from ProtocolSchema enum and Protocols const - Drop hysteria2 branches from inbound/outbound discriminated unions - Drop createDefaultHysteria2InboundSettings / OutboundSettings - Delete schemas/protocols/inbound/hysteria2.ts and outbound/hysteria2.ts - Drop hysteria2 case in getInboundClients / genLink (fell through to the hysteria handler anyway) - Update client form modals' MULTI_CLIENT_PROTOCOLS sets - Remove hysteria2-basic fixture + snapshot entries (14 capability cases, 1 protocols fixture, 1 inbound-defaults factory) - Keep parseHysteria2Link() outbound parser since hysteria2:// is the share-link URI prefix for hysteria v2 Hysteria stream alignment with xtls docs: - HysteriaStreamSettingsSchema reduced to version/auth/udpIdleTimeout/ masquerade per transports/hysteria.html - Masquerade type adds '' (default 404 page) and defaults to it - Outbound form drops Congestion/Upload/Download/UDP hop/Max idle/ Keep alive/Disable Path MTU controls and the receive-window note - newStreamSlice('hysteria') in OutboundFormModal mirrors the trimmed shape; outbound-link-parser emits the trimmed shape too - InboundFormModal Masquerade Select gains the default option New TUN inbound schema: - Add schemas/protocols/inbound/tun.ts with name/mtu/gateway/dns/ userLevel/autoSystemRoutingTable/autoOutboundsInterface - Wire into ProtocolSchema enum, InboundSettingsSchema discriminated union, createDefaultInboundSettings dispatcher Other Phase 2 smoke fixes folded in: - Tunnel portMap UI swaps Form.List for HeaderMapEditor v1 — wire shape is Record<string,string> and the List was producing arrays - Hysteria onValuesChange seeds full TLS schema defaults + one empty certificate row (Cipher Suites/Min/Max Version/uTLS/ALPN were undefined before) - HTTP/Mixed accounts Add button auto-fills user/pass with RandomUtil.randomLowerAndNum - Hysteria security tab gates the 'none' radio out — TLS only - Hysteria stream tab drops the inbound Auth password field (xray inbound auth is per-user via 'users', not stream-level) - Reality onSecurityChange auto-randomizes target/serverNames/ shortIds and fetches an X25519 keypair - Tag and DB-side fields (up/down/total/expiryTime/ lastTrafficResetTime/clientStats/security) gain hidden Form.Items so validateFields keeps them in the wire payload (rc-component form strips unregistered fields) - WireGuard inbound auto-seeds one peer with generated keypair, allowedIPs ['10.0.0.2/32'], keepAlive 0 — matches legacy - WireGuard peer rows separated by Divider with the Peer N title and a small inline remove button (titlePlacement="center")
2026-05-26 15:49:37 +00:00
version: 2, auth, udpIdleTimeout: 60,
feat(frontend): link import on outbound modal (vmess/vless/trojan/ss/hy2) The legacy outbound modal could import a vmess://, vless://, trojan://, ss://, or hysteria2:// share link via a Convert button on the JSON tab. Restore that UX with a focused pure-function parser. lib/xray/outbound-link-parser.ts: - parseVmessLink: base64 JSON, maps net/tls + per-network params onto the discriminated stream branch. - parseVlessLink: standard URL with type/security/sni/pbk/sid/fp/flow query params, dispatches transport via buildStream + applies security params via applySecurityParams. - parseTrojanLink: same URL pattern, defaults security to tls. - parseShadowsocksLink: both modern (base64 userinfo@host:port) and legacy (base64 of whole thing) ss:// formats. - parseHysteria2Link: accepts both hysteria2:// and hy2:// schemes, uses the hysteria stream branch with version=2 + TLS h3. - parseOutboundLink dispatcher returns the first non-null parser result, or null when no scheme matches. test/outbound-link-parser.test.ts: - 13 cases covering happy paths for each protocol family plus malformed input, ss:// dual-format handling, hy2:// alias. OutboundFormModal.tsx: - Import button on the JSON tab Input.Search; on success, parsed payload flows through rawOutboundToFormValues, the form is reset, and we switch back to the Basic tab. - Tag is preserved when the parsed link does not carry one. Out of scope: advanced fields the legacy parser handled (xmux, padding obfs, reality short IDs, finalmask from fm= param). Power users can finish the import in the form after the basics land.
2026-05-26 11:28:04 +00:00
},
tlsSettings: {
serverName: params.get('sni') ?? '',
alpn: ['h3'],
fingerprint: '',
echConfigList: '',
verifyPeerCertByName: '',
pinnedPeerCertSha256: params.get('pinSHA256') ?? '',
},
};
return {
protocol: 'hysteria',
tag: decodeRemark(url),
settings: { address, port, version: 2 },
streamSettings: stream,
};
}
// Dispatcher — first non-null parser wins. Returns null when no parser
// recognizes the link's protocol scheme.
export function parseOutboundLink(link: string): Raw | null {
const trimmed = link.trim();
if (!trimmed) return null;
return (
parseVmessLink(trimmed)
?? parseVlessLink(trimmed)
?? parseTrojanLink(trimmed)
?? parseShadowsocksLink(trimmed)
?? parseHysteria2Link(trimmed)
);
}