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.
|
2026-05-26 12:14:53 +00:00
|
|
|
// 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>;
|
|
|
|
|
|
2026-05-26 12:27:43 +00:00
|
|
|
// 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 {
|
2026-05-26 18:20:00 +00:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-26 12:27:43 +00:00
|
|
|
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;
|
2026-05-26 12:14:53 +00:00
|
|
|
case 'xhttp': {
|
|
|
|
|
const xhttp = stream.xhttpSettings as Raw;
|
|
|
|
|
xhttp.host = host;
|
|
|
|
|
xhttp.path = path;
|
|
|
|
|
if (params.get('mode')) xhttp.mode = params.get('mode');
|
2026-05-26 12:27:43 +00:00
|
|
|
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;
|
2026-05-26 12:14:53 +00:00
|
|
|
}
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 18:20:00 +00:00
|
|
|
// 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') {
|
2026-05-26 12:14:53 +00:00
|
|
|
const xhttp = stream.xhttpSettings as Raw;
|
|
|
|
|
xhttp.host = json.host ?? '';
|
|
|
|
|
xhttp.path = json.path ?? '/';
|
|
|
|
|
if (json.mode) xhttp.mode = json.mode;
|
2026-05-26 12:27:43 +00:00
|
|
|
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);
|
2026-05-26 18:20:00 +00:00
|
|
|
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);
|
2026-05-26 18:20:00 +00:00
|
|
|
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: {
|
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)
|
|
|
|
|
);
|
|
|
|
|
}
|