From d8221a8153d0e75457d41575379c88f052e9b766 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Mon, 29 Jun 2026 14:32:23 +0200 Subject: [PATCH] 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 --- frontend/public/openapi.json | 13 +-- frontend/src/generated/examples.ts | 2 +- frontend/src/generated/schemas.ts | 3 +- frontend/src/lib/hosts/host-link.ts | 2 + frontend/src/lib/xray/inbound-link.ts | 14 +++- frontend/src/pages/hosts/HostFormModal.tsx | 2 +- frontend/src/schemas/api/host.ts | 5 +- .../protocols/stream/external-proxy.ts | 1 + frontend/src/test/host-link.test.ts | 6 ++ frontend/src/test/host-schema.test.ts | 11 +++ frontend/src/test/inbound-link.test.ts | 50 +++++++++++ internal/database/model/model.go | 6 +- internal/sub/clash_service.go | 2 +- internal/sub/endpoint.go | 4 +- internal/sub/endpoint_test.go | 2 +- internal/sub/host_sub.go | 3 + internal/sub/json_service.go | 4 +- internal/sub/service.go | 14 ++-- internal/sub/vless_route.go | 34 ++++++++ internal/sub/vless_route_sub_test.go | 83 +++++++++++++++++++ internal/sub/vless_route_test.go | 43 ++++++++++ internal/web/translation/ar-EG.json | 4 +- internal/web/translation/en-US.json | 4 +- internal/web/translation/es-ES.json | 4 +- internal/web/translation/fa-IR.json | 4 +- internal/web/translation/id-ID.json | 4 +- internal/web/translation/ja-JP.json | 4 +- internal/web/translation/pt-BR.json | 4 +- internal/web/translation/ru-RU.json | 4 +- internal/web/translation/tr-TR.json | 4 +- internal/web/translation/uk-UA.json | 4 +- internal/web/translation/vi-VN.json | 4 +- internal/web/translation/zh-CN.json | 4 +- internal/web/translation/zh-TW.json | 4 +- 34 files changed, 304 insertions(+), 52 deletions(-) create mode 100644 internal/sub/vless_route.go create mode 100644 internal/sub/vless_route_sub_test.go create mode 100644 internal/sub/vless_route_test.go diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 15097a043..dbc78939e 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1571,7 +1571,8 @@ "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" } }, @@ -8130,7 +8131,7 @@ ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" } ] } @@ -8222,7 +8223,7 @@ ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" } } } @@ -8317,7 +8318,7 @@ ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" } ] } @@ -8457,7 +8458,7 @@ ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" } } } @@ -8569,7 +8570,7 @@ ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" } } } diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 7febe65cc..3cc91538d 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -340,7 +340,7 @@ export const EXAMPLES: Record = { ], "updatedAt": 0, "verifyPeerCertByName": "", - "vlessRoute": "" + "vlessRoute": "443" }, "Inbound": { "clientStats": [ diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 6a95b66a2..96b7d875f 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1545,7 +1545,8 @@ export const SCHEMAS: Record = { "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" } }, diff --git a/frontend/src/lib/hosts/host-link.ts b/frontend/src/lib/hosts/host-link.ts index 1a3b0e92b..ebbb47492 100644 --- a/frontend/src/lib/hosts/host-link.ts +++ b/frontend/src/lib/hosts/host-link.ts @@ -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, }; } diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index 2734de0da..c15bb2a8c 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -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://@:?#. 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(); diff --git a/frontend/src/pages/hosts/HostFormModal.tsx b/frontend/src/pages/hosts/HostFormModal.tsx index e8d7df9dd..d68e8c5ee 100644 --- a/frontend/src/pages/hosts/HostFormModal.tsx +++ b/frontend/src/pages/hosts/HostFormModal.tsx @@ -260,7 +260,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save, - +