fix(sub): bake Host VLESS Route into subscription UUIDs

The Host VLESS Route field was stored and shown in the panel but never applied to any generated subscription (raw, JSON, Clash), so the UUID was emitted unmodified (#5655).

Xray reads the route from the UUID's 3rd group (bytes 6-7, net.PortFromBytes) and masks those bytes to zero before authenticating, so a value can be baked into the share/JSON/Clash UUIDs without breaking the user match. A shared applyVlessRoute helper encodes a single 0-65535 value as the 3rd group; empty/invalid/non-UUID input is left unchanged, so legacy data never yields a broken link and no DB migration is needed.

The field was wrongly validated as a multi-segment port spec (that form belongs to the separate server-side routing rule). It is now a single value 0-65535, with frontend validation, link-preview parity (genVlessLink/hostToExternalProxyEntry), hint + error translations across all 13 locales, and tests on every path.

Closes #5655
This commit is contained in:
MHSanaei
2026-06-29 14:32:23 +02:00
parent 789e92cddc
commit d8221a8153
34 changed files with 304 additions and 52 deletions
+1 -1
View File
@@ -340,7 +340,7 @@ export const EXAMPLES: Record<string, unknown> = {
],
"updatedAt": 0,
"verifyPeerCertByName": "",
"vlessRoute": ""
"vlessRoute": "443"
},
"Inbound": {
"clientStats": [
+2 -1
View File
@@ -1545,7 +1545,8 @@ export const SCHEMAS: Record<string, unknown> = {
"type": "string"
},
"vlessRoute": {
"description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
"description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
"example": "443",
"type": "string"
}
},
+2
View File
@@ -17,6 +17,7 @@ export type HostLinkInput = Pick<
| 'echConfigList'
| 'overrideSniFromAddress'
| 'keepSniBlank'
| 'vlessRoute'
>;
// hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
@@ -48,5 +49,6 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
verifyPeerCertByName: host.verifyPeerCertByName || undefined,
echConfigList: host.echConfigList || undefined,
vlessRoute: host.vlessRoute || undefined,
};
}
+13 -1
View File
@@ -326,6 +326,18 @@ export interface GenVlessLinkInput {
externalProxy?: ExternalProxyEntry | null;
}
// Mirror of the Go applyVlessRoute: bake a single 0-65535 value into the UUID's
// 3rd group (bytes 6-7), which xray reads as the vless route. Empty/invalid/non-
// UUID input is returned unchanged.
export function applyVlessRoute(id: string, route: string | undefined): string {
const r = (route ?? '').trim();
if (r === '' || !/^\d{1,5}$/.test(r)) return id;
const n = Number(r);
if (n > 65535) return id;
if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
}
// VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
// query carries network type, encryption, network-specific knobs, and
// security-specific knobs (TLS fingerprint/alpn/sni or Reality
@@ -437,7 +449,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
params.set('flow', flow);
}
const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
for (const [key, value] of params) url.searchParams.set(key, value);
url.hash = encodeURIComponent(remark);
return url.toString();
+1 -1
View File
@@ -260,7 +260,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
<Input />
</Form.Item>
<Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
<Input placeholder="53,443,1000-2000" />
<Input placeholder="443" />
</Form.Item>
<Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
<Select
+3 -2
View File
@@ -58,11 +58,12 @@ export const HostFormSchema = z.object({
muxParams: z.string().default(''),
sockoptParams: z.string().default(''),
finalMask: z.string().default(''),
// A comma-separated list of ports/ranges (e.g. "53,443,1000-2000"). Empty = none.
// Single value 0-65535 baked into the subscription UUID's 3rd group. Empty = none.
vlessRoute: z
.string()
.trim()
.regex(/^(\d{1,5}(-\d{1,5})?)(\s*,\s*\d{1,5}(-\d{1,5})?)*$/, 'pages.hosts.toasts.badVlessRoute')
.regex(/^\d{1,5}$/, 'pages.hosts.toasts.badVlessRoute')
.refine((v) => Number(v) <= 65535, 'pages.hosts.toasts.badVlessRoute')
.or(z.literal(''))
.default(''),
@@ -25,5 +25,6 @@ export const ExternalProxyEntrySchema = z.object({
pinnedPeerCertSha256: z.array(z.string()).optional(),
verifyPeerCertByName: z.string().optional(),
echConfigList: z.string().optional(),
vlessRoute: z.string().optional(),
});
export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;
+6
View File
@@ -17,6 +17,7 @@ describe('hostToExternalProxyEntry', () => {
echConfigList: 'ECH',
overrideSniFromAddress: false,
keepSniBlank: false,
vlessRoute: '',
};
it('maps the overlapping fields onto an external-proxy entry', () => {
@@ -53,4 +54,9 @@ describe('hostToExternalProxyEntry', () => {
const ep = hostToExternalProxyEntry({ ...base, port: 0 });
expect(ep.port).toBe(443);
});
it('carries a single vlessRoute value through to the entry', () => {
expect(hostToExternalProxyEntry({ ...base, vlessRoute: '443' }).vlessRoute).toBe('443');
expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
});
});
+11
View File
@@ -36,6 +36,17 @@ describe('HostFormSchema', () => {
expect(() => HostFormSchema.parse({ ...valid, port: 70000 })).toThrow();
});
it('accepts a single vlessRoute 0-65535 and rejects specs/out-of-range', () => {
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '443' })).not.toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '0' })).not.toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '65535' })).not.toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '' })).not.toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '53,443' })).toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '1000-2000' })).toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '70000' })).toThrow();
expect(() => HostFormSchema.parse({ ...valid, vlessRoute: 'abc' })).toThrow();
});
it('rejects a bad security enum', () => {
expect(() => HostFormSchema.parse({ ...valid, security: 'bogus' })).toThrow();
});
+50
View File
@@ -6,6 +6,7 @@ import {
genInboundLinks,
genShadowsocksLink,
genTrojanLink,
applyVlessRoute,
genVlessLink,
genVmessLink,
genWireguardConfig,
@@ -88,6 +89,55 @@ describe('genVlessLink', () => {
}
});
describe('applyVlessRoute', () => {
const id = '11111111-2222-4333-8444-555555555555';
it('encodes a single value into the 3rd group and no-ops on invalid input', () => {
expect(applyVlessRoute(id, '443')).toBe('11111111-2222-01bb-8444-555555555555');
expect(applyVlessRoute(id, '53')).toBe('11111111-2222-0035-8444-555555555555');
expect(applyVlessRoute(id, '0')).toBe('11111111-2222-0000-8444-555555555555');
expect(applyVlessRoute(id, '65535')).toBe('11111111-2222-ffff-8444-555555555555');
expect(applyVlessRoute(id, '')).toBe(id);
expect(applyVlessRoute(id, undefined)).toBe(id);
expect(applyVlessRoute(id, '70000')).toBe(id);
expect(applyVlessRoute(id, '53,443')).toBe(id);
expect(applyVlessRoute(id, 'abc')).toBe(id);
expect(applyVlessRoute('short', '443')).toBe('short');
});
});
describe('genVlessLink vlessRoute', () => {
const [, raw] = fixturesForProtocol('vless')[0];
const typed = InboundSchema.parse(raw);
it('bakes a host route value into the link UUID 3rd group', () => {
const link = genVlessLink({
inbound: typed,
address: 'example.test',
port: typed.port,
forceTls: 'same',
remark: 'r',
clientId: '11111111-2222-4333-8444-555555555555',
flow: '' as never,
externalProxy: { forceTls: 'same', dest: 'example.test', port: typed.port, remark: '', vlessRoute: '443' },
});
expect(link).toContain('vless://11111111-2222-01bb-8444-555555555555@');
});
it('leaves the UUID unchanged when no route is set', () => {
const link = genVlessLink({
inbound: typed,
address: 'example.test',
port: typed.port,
forceTls: 'same',
remark: 'r',
clientId: '11111111-2222-4333-8444-555555555555',
flow: '' as never,
externalProxy: null,
});
expect(link).toContain('vless://11111111-2222-4333-8444-555555555555@');
});
});
describe('genTrojanLink', () => {
const fixtures = fixturesForProtocol('trojan');
expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);