Преглед изворни кода

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
MHSanaei пре 13 часа
родитељ
комит
d8221a8153
34 измењених фајлова са 304 додато и 52 уклоњено
  1. 7 6
      frontend/public/openapi.json
  2. 1 1
      frontend/src/generated/examples.ts
  3. 2 1
      frontend/src/generated/schemas.ts
  4. 2 0
      frontend/src/lib/hosts/host-link.ts
  5. 13 1
      frontend/src/lib/xray/inbound-link.ts
  6. 1 1
      frontend/src/pages/hosts/HostFormModal.tsx
  7. 3 2
      frontend/src/schemas/api/host.ts
  8. 1 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  9. 6 0
      frontend/src/test/host-link.test.ts
  10. 11 0
      frontend/src/test/host-schema.test.ts
  11. 50 0
      frontend/src/test/inbound-link.test.ts
  12. 3 3
      internal/database/model/model.go
  13. 1 1
      internal/sub/clash_service.go
  14. 2 2
      internal/sub/endpoint.go
  15. 1 1
      internal/sub/endpoint_test.go
  16. 3 0
      internal/sub/host_sub.go
  17. 3 1
      internal/sub/json_service.go
  18. 8 6
      internal/sub/service.go
  19. 34 0
      internal/sub/vless_route.go
  20. 83 0
      internal/sub/vless_route_sub_test.go
  21. 43 0
      internal/sub/vless_route_test.go
  22. 2 2
      internal/web/translation/ar-EG.json
  23. 2 2
      internal/web/translation/en-US.json
  24. 2 2
      internal/web/translation/es-ES.json
  25. 2 2
      internal/web/translation/fa-IR.json
  26. 2 2
      internal/web/translation/id-ID.json
  27. 2 2
      internal/web/translation/ja-JP.json
  28. 2 2
      internal/web/translation/pt-BR.json
  29. 2 2
      internal/web/translation/ru-RU.json
  30. 2 2
      internal/web/translation/tr-TR.json
  31. 2 2
      internal/web/translation/uk-UA.json
  32. 2 2
      internal/web/translation/vi-VN.json
  33. 2 2
      internal/web/translation/zh-CN.json
  34. 2 2
      internal/web/translation/zh-TW.json

+ 7 - 6
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"
                   }
                 }
               }

+ 1 - 1
frontend/src/generated/examples.ts

@@ -340,7 +340,7 @@ export const EXAMPLES: Record<string, unknown> = {
     ],
     "updatedAt": 0,
     "verifyPeerCertByName": "",
-    "vlessRoute": ""
+    "vlessRoute": "443"
   },
   "Inbound": {
     "clientStats": [

+ 2 - 1
frontend/src/generated/schemas.ts

@@ -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 - 0
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,
   };
 }

+ 13 - 1
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://<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
frontend/src/pages/hosts/HostFormModal.tsx

@@ -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
frontend/src/schemas/api/host.ts

@@ -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(''),
 

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -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 - 0
frontend/src/test/host-link.test.ts

@@ -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 - 0
frontend/src/test/host-schema.test.ts

@@ -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 - 0
frontend/src/test/inbound-link.test.ts

@@ -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);

+ 3 - 3
internal/database/model/model.go

@@ -772,9 +772,9 @@ type Host struct {
 	// merged into this host's JSON-subscription stream. Empty = no override.
 	FinalMask string `json:"finalMask" form:"finalMask" gorm:"type:text;column:final_mask"`
 
-	// VlessRoute is a free-form port/range routing spec (e.g. "53,443,1000-2000");
-	// stored verbatim, format-validated on the frontend.
-	VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route"`
+	// Single VLESS route value (0-65535) baked into the subscription UUID's 3rd
+	// group (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.
+	VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route" example:"443"`
 
 	ExcludeFromSubTypes []string `json:"excludeFromSubTypes" form:"excludeFromSubTypes" gorm:"serializer:json;column:exclude_from_sub_types"`
 

+ 1 - 1
internal/sub/clash_service.go

@@ -239,7 +239,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["cipher"] = cipher
 	case model.VLESS:
 		proxy["type"] = "vless"
-		proxy["uuid"] = client.ID
+		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
 		var inboundSettings map[string]any
 		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 		streamSecurity, _ := stream["security"].(string)

+ 2 - 2
internal/sub/endpoint.go

@@ -77,7 +77,7 @@ func (s *SubService) buildEndpointLinks(
 	eps []ShareEndpoint,
 	params map[string]string,
 	baseSecurity string,
-	makeLink func(dest string, port int) string,
+	makeLink func(e ShareEndpoint) string,
 	makeRemark func(e ShareEndpoint) string,
 ) string {
 	links := make([]string, 0, len(eps))
@@ -92,7 +92,7 @@ func (s *SubService) buildEndpointLinks(
 		applyEndpointHostPath(e, nextParams)
 		applyEndpointAllowInsecure(e, nextParams, securityToApply)
 		links = append(links, buildLinkWithParamsAndSecurity(
-			makeLink(e.Address, e.Port),
+			makeLink(e),
 			nextParams,
 			makeRemark(e),
 			securityToApply,

+ 1 - 1
internal/sub/endpoint_test.go

@@ -80,7 +80,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 	}
 	got := s.buildEndpointLinks(eps, params, "tls",
-		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
+		func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
 		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
 	)
 	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +

+ 3 - 0
internal/sub/host_sub.go

@@ -104,6 +104,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
 	if h.FinalMask != "" {
 		ep["finalMask"] = h.FinalMask
 	}
+	if h.VlessRoute != "" {
+		ep["vlessRoute"] = h.VlessRoute
+	}
 	return ep
 }
 

+ 3 - 1
internal/sub/json_service.go

@@ -210,7 +210,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		case "vmess":
 			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "vless":
-			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, jsonMux(mux, hostMux)))
+			vc := client
+			vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
+			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, vc, jsonMux(mux, hostMux)))
 		case "trojan", "shadowsocks":
 			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "hysteria":

+ 8 - 6
internal/sub/service.go

@@ -698,8 +698,8 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 			externalProxies,
 			params,
 			security,
-			func(dest string, port int) string {
-				return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
+			func(ep map[string]any, dest string, port int) string {
+				return fmt.Sprintf("vless://%s@%s", applyVlessRoute(uuid, hostVlessRoute(ep)), joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.endpointRemark(inbound, email, ep, streamNetwork)
@@ -749,7 +749,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 			externalProxies,
 			params,
 			security,
-			func(dest string, port int) string {
+			func(_ map[string]any, dest string, port int) string {
 				return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
@@ -842,7 +842,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			externalProxies,
 			proxyParams,
 			security,
-			func(dest string, port int) string {
+			func(_ map[string]any, dest string, port int) string {
 				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
@@ -1697,7 +1697,7 @@ func (s *SubService) buildExternalProxyURLLinks(
 	externalProxies []any,
 	params map[string]string,
 	baseSecurity string,
-	makeLink func(dest string, port int) string,
+	makeLink func(ep map[string]any, dest string, port int) string,
 	makeRemark func(ep map[string]any) string,
 ) string {
 	eps := make([]ShareEndpoint, 0, len(externalProxies))
@@ -1705,7 +1705,9 @@ func (s *SubService) buildExternalProxyURLLinks(
 		ep, _ := externalProxy.(map[string]any)
 		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return s.buildEndpointLinks(eps, params, baseSecurity, makeLink, func(e ShareEndpoint) string {
+	return s.buildEndpointLinks(eps, params, baseSecurity, func(e ShareEndpoint) string {
+		return makeLink(e.ep, e.Address, e.Port)
+	}, func(e ShareEndpoint) string {
 		return makeRemark(e.ep)
 	})
 }

+ 34 - 0
internal/sub/vless_route.go

@@ -0,0 +1,34 @@
+package sub
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/google/uuid"
+)
+
+// xray reads the route from UUID bytes 6-7 (net.PortFromBytes) and masks them to
+// zero before auth, so baking a 0-65535 value into the 3rd group routes without
+// breaking the user match. Empty/invalid/non-UUID input is returned unchanged.
+func applyVlessRoute(id, route string) string {
+	route = strings.TrimSpace(route)
+	if route == "" {
+		return id
+	}
+	n, err := strconv.Atoi(route)
+	if err != nil || n < 0 || n > 65535 {
+		return id
+	}
+	u, err := uuid.Parse(id)
+	if err != nil {
+		return id
+	}
+	u[6] = byte(n >> 8)
+	u[7] = byte(n)
+	return u.String()
+}
+
+func hostVlessRoute(ep map[string]any) string {
+	v, _ := ep["vlessRoute"].(string)
+	return v
+}

+ 83 - 0
internal/sub/vless_route_sub_test.go

@@ -0,0 +1,83 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestHostToExternalProxyMap_VlessRoute(t *testing.T) {
+	with := hostToExternalProxyMap(&model.Host{VlessRoute: "443"}, "d.example.com", 443)
+	if with["vlessRoute"] != "443" {
+		t.Fatalf(`ep["vlessRoute"] = %v, want "443"`, with["vlessRoute"])
+	}
+	without := hostToExternalProxyMap(&model.Host{}, "d.example.com", 443)
+	if _, ok := without["vlessRoute"]; ok {
+		t.Fatalf("empty VlessRoute must not add the key: %v", without["vlessRoute"])
+	}
+}
+
+// seedSubInbound's client UUID is 11111111-2222-4333-8444-<port>, so route 443
+// -> 01bb, 53 -> 0035, and a route-less host keeps 4333.
+func TestSub_HostVlessRoute_RawMultiHost(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vr", 4500, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", VlessRoute: "53"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 3, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	parts := strings.Split(strings.Join(links, "\n"), "\n")
+	if len(parts) != 3 {
+		t.Fatalf("want 3 host links, got %d: %v", len(parts), parts)
+	}
+	if !strings.Contains(parts[0], "vless://11111111-2222-01bb-8444-") {
+		t.Fatalf("host A (route 443) must encode 01bb: %s", parts[0])
+	}
+	if !strings.Contains(parts[1], "vless://11111111-2222-0035-8444-") {
+		t.Fatalf("host B (route 53) must encode 0035: %s", parts[1])
+	}
+	if !strings.Contains(parts[2], "vless://11111111-2222-4333-8444-") {
+		t.Fatalf("host C (no route) must keep the original 3rd group: %s", parts[2])
+	}
+}
+
+func TestSub_HostVlessRoute_JSON(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, "11111111-2222-01bb-8444-") {
+		t.Fatalf("json outbound id should encode route 443 (01bb):\n%s", out)
+	}
+	if strings.Contains(out, "11111111-2222-4333-8444-") {
+		t.Fatalf("original id 3rd group must be replaced in json:\n%s", out)
+	}
+}
+
+func TestSub_HostVlessRoute_Clash(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "11111111-2222-01bb-8444-") {
+		t.Fatalf("clash proxy uuid should encode route 443 (01bb):\n%s", yaml)
+	}
+	if strings.Contains(yaml, "11111111-2222-4333-8444-") {
+		t.Fatalf("original uuid 3rd group must be replaced in clash:\n%s", yaml)
+	}
+}

+ 43 - 0
internal/sub/vless_route_test.go

@@ -0,0 +1,43 @@
+package sub
+
+import "testing"
+
+func TestApplyVlessRoute(t *testing.T) {
+	const id = "11111111-2222-4333-8444-555555555555"
+	tests := []struct {
+		name  string
+		id    string
+		route string
+		want  string
+	}{
+		{"empty route unchanged", id, "", id},
+		{"whitespace route unchanged", id, "   ", id},
+		{"443 -> 01bb", id, "443", "11111111-2222-01bb-8444-555555555555"},
+		{"53 -> 0035", id, "53", "11111111-2222-0035-8444-555555555555"},
+		{"0 -> 0000", id, "0", "11111111-2222-0000-8444-555555555555"},
+		{"65535 -> ffff", id, "65535", "11111111-2222-ffff-8444-555555555555"},
+		{"trimmed value", id, "  443 ", "11111111-2222-01bb-8444-555555555555"},
+		{"out of range high unchanged", id, "65536", id},
+		{"negative unchanged", id, "-1", id},
+		{"non-numeric unchanged", id, "abc", id},
+		{"legacy multi-segment unchanged", id, "53,443", id},
+		{"non-uuid id unchanged", "short", "443", "short"},
+		{"empty id unchanged", "", "443", ""},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := applyVlessRoute(tt.id, tt.route); got != tt.want {
+				t.Fatalf("applyVlessRoute(%q, %q) = %q, want %q", tt.id, tt.route, got, tt.want)
+			}
+		})
+	}
+}
+
+func TestHostVlessRoute(t *testing.T) {
+	if got := hostVlessRoute(map[string]any{"vlessRoute": "443"}); got != "443" {
+		t.Fatalf(`hostVlessRoute = %q, want "443"`, got)
+	}
+	if got := hostVlessRoute(map[string]any{}); got != "" {
+		t.Fatalf(`hostVlessRoute(missing) = %q, want ""`, got)
+	}
+}

+ 2 - 2
internal/web/translation/ar-EG.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "اختر النودز التي تم تحليلها من هذا المضيف. تعيين بصري فقط.",
         "serverDescription": "ملاحظة اختيارية تظهر تحت الملاحظة.",
         "allowInsecure": "تخطّي التحقق من شهادة TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "المنافذ/النطاقات الموجَّهة عبر VLESS، مثل 53,443,1000-2000. اتركه فارغاً لعدم وجود أي منها.",
+        "vlessRoute": "قيمة مسار VLESS واحدة (0-65535) تُدمَج في UUID، مثل 443. اتركه فارغاً لعدم وجود أي منها.",
         "remark": "تسمية بسيطة لهذا المضيف. تظهر كاسم للإعداد فقط عندما لا يكون للوارد ملاحظة خاصة به."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "تحديث المضيف",
         "delete": "حذف المضيف",
         "badTag": "وسم غير صالح",
-        "badVlessRoute": "استخدم منافذ/نطاقات مثل 53,443,1000-2000"
+        "badVlessRoute": "أدخل رقماً واحداً بين 0 و65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/en-US.json

@@ -1032,7 +1032,7 @@
         "nodeGuids": "Pick nodes which resolved from this host. Only visual assignment.",
         "serverDescription": "Optional note shown under the remark.",
         "allowInsecure": "Skip TLS certificate verification (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Ports/ranges routed via VLESS, e.g. 53,443,1000-2000. Leave blank for none.",
+        "vlessRoute": "Single VLESS route value (0-65535) baked into the UUID, e.g. 443. Leave blank for none.",
         "remark": "A plain label for this host. Shown as the config name only when the inbound has no remark of its own."
       },
       "remarkVars": {
@@ -1082,7 +1082,7 @@
         "update": "Update host",
         "delete": "Delete host",
         "badTag": "Invalid tag",
-        "badVlessRoute": "Use ports/ranges like 53,443,1000-2000"
+        "badVlessRoute": "Enter a single number between 0 and 65535"
       }
     },
     "nodes": {

+ 2 - 2
internal/web/translation/es-ES.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Elige los nodos que se resolvieron desde este host. Solo asignación visual.",
         "serverDescription": "Nota opcional que se muestra bajo las notas.",
         "allowInsecure": "Omitir la verificación del certificado TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Puertos/rangos enrutados a través de VLESS, p. ej. 53,443,1000-2000. Déjalo en blanco para ninguno.",
+        "vlessRoute": "Un único valor de ruta VLESS (0-65535) incrustado en el UUID, p. ej. 443. Déjalo en blanco para ninguno.",
         "remark": "Una etiqueta simple para este host. Se muestra como nombre de la configuración solo cuando el inbound no tiene notas propias."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Actualizar host",
         "delete": "Eliminar host",
         "badTag": "Etiqueta no válida",
-        "badVlessRoute": "Usa puertos/rangos como 53,443,1000-2000"
+        "badVlessRoute": "Introduce un único número entre 0 y 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/fa-IR.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "نودهایی را که از این میزبان resolve می‌شوند انتخاب کنید. صرفاً انتساب نمایشی است.",
         "serverDescription": "یادداشت اختیاری که زیر نام نمایش داده می‌شود.",
         "allowInsecure": "رد کردن بررسی گواهی TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "پورت‌ها/بازه‌هایی که از طریق VLESS مسیریابی می‌شوند، مثلاً 53,443,1000-2000. برای هیچ‌کدام خالی بگذارید.",
+        "vlessRoute": "یک مقدار مسیر VLESS (0 تا 65535) که در UUID جاسازی می‌شود، مثلاً 443. برای هیچ‌کدام خالی بگذارید.",
         "remark": "یک برچسب ساده برای این میزبان. تنها زمانی به‌عنوان نام کانفیگ نمایش داده می‌شود که اینباند نام مخصوص خود را نداشته باشد."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "به‌روزرسانی میزبان",
         "delete": "حذف میزبان",
         "badTag": "برچسب نامعتبر",
-        "badVlessRoute": "از پورت‌ها/بازه‌هایی مانند 53,443,1000-2000 استفاده کنید"
+        "badVlessRoute": "یک عدد بین 0 تا 65535 وارد کنید"
       }
     }
   },

+ 2 - 2
internal/web/translation/id-ID.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Pilih node yang teresolusi dari host ini. Hanya penetapan visual.",
         "serverDescription": "Catatan opsional yang ditampilkan di bawah catatan.",
         "allowInsecure": "Lewati verifikasi sertifikat TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Port/rentang yang dirutekan melalui VLESS, mis. 53,443,1000-2000. Biarkan kosong jika tidak ada.",
+        "vlessRoute": "Satu nilai rute VLESS (0-65535) yang disisipkan ke UUID, mis. 443. Biarkan kosong jika tidak ada.",
         "remark": "Label sederhana untuk host ini. Ditampilkan sebagai nama konfigurasi hanya ketika inbound tidak memiliki catatan tersendiri."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Perbarui host",
         "delete": "Hapus host",
         "badTag": "Tag tidak valid",
-        "badVlessRoute": "Gunakan port/rentang seperti 53,443,1000-2000"
+        "badVlessRoute": "Masukkan satu angka antara 0 dan 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/ja-JP.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "このホストから解決されたノードを選択します。視覚的な割り当てのみです。",
         "serverDescription": "備考の下に表示される任意のメモ。",
         "allowInsecure": "TLS 証明書の検証をスキップします(allowInsecure / skip-cert-verify)。",
-        "vlessRoute": "VLESS 経由でルーティングするポート/範囲。例: 53,443,1000-2000。なしの場合は空欄にします。",
+        "vlessRoute": "UUID に埋め込まれる単一の VLESS ルート値(0〜65535)。例: 443。なしの場合は空欄にします。",
         "remark": "このホストのプレーンなラベル。インバウンド自身に備考がない場合にのみ設定名として表示されます。"
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "ホストを更新",
         "delete": "ホストを削除",
         "badTag": "無効なタグ",
-        "badVlessRoute": "53,443,1000-2000 のようにポート/範囲を指定してください"
+        "badVlessRoute": "0〜65535 の単一の数値を入力してください"
       }
     }
   },

+ 2 - 2
internal/web/translation/pt-BR.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Escolha os nós que foram resolvidos a partir deste host. Apenas atribuição visual.",
         "serverDescription": "Nota opcional exibida abaixo da observação.",
         "allowInsecure": "Ignorar a verificação do certificado TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Portas/intervalos roteados via VLESS, ex.: 53,443,1000-2000. Deixe em branco para nenhum.",
+        "vlessRoute": "Um único valor de rota VLESS (0-65535) embutido no UUID, ex.: 443. Deixe em branco para nenhum.",
         "remark": "Um rótulo simples para este host. Mostrado como nome da configuração apenas quando a entrada não tem observação própria."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Atualizar host",
         "delete": "Excluir host",
         "badTag": "Tag inválida",
-        "badVlessRoute": "Use portas/intervalos como 53,443,1000-2000"
+        "badVlessRoute": "Insira um único número entre 0 e 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/ru-RU.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Выберите узлы, которые разрешаются с этого хоста. Только визуальное назначение.",
         "serverDescription": "Необязательная заметка, отображаемая под примечанием.",
         "allowInsecure": "Пропустить проверку TLS-сертификата (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Порты/диапазоны, маршрутизируемые через VLESS, напр. 53,443,1000-2000. Оставьте пустым, чтобы отключить.",
+        "vlessRoute": "Одно значение маршрута VLESS (0-65535), встраиваемое в UUID, напр. 443. Оставьте пустым, чтобы отключить.",
         "remark": "Обычная метка для этого хоста. Используется как имя конфигурации, только если у входящего нет собственного примечания."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Обновить хост",
         "delete": "Удалить хост",
         "badTag": "Недопустимый тег",
-        "badVlessRoute": "Используйте порты/диапазоны, например 53,443,1000-2000"
+        "badVlessRoute": "Введите одно число от 0 до 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/tr-TR.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Bu host'tan çözümlenen düğümleri seçin. Yalnızca görsel atama.",
         "serverDescription": "Açıklamanın altında gösterilen isteğe bağlı not.",
         "allowInsecure": "TLS sertifika doğrulamasını atla (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "VLESS üzerinden yönlendirilen portlar/aralıklar, örn. 53,443,1000-2000. Hiçbiri için boş bırakın.",
+        "vlessRoute": "UUID'ye gömülen tek bir VLESS rota değeri (0-65535), örn. 443. Hiçbiri için boş bırakın.",
         "remark": "Bu host için düz bir etiket. Yalnızca gelen bağlantının kendi açıklaması yoksa yapılandırma adı olarak gösterilir."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Host'u güncelle",
         "delete": "Host'u sil",
         "badTag": "Geçersiz etiket",
-        "badVlessRoute": "53,443,1000-2000 gibi portlar/aralıklar kullanın"
+        "badVlessRoute": "0 ile 65535 arasında tek bir sayı girin"
       }
     }
   },

+ 2 - 2
internal/web/translation/uk-UA.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Виберіть вузли, які розв'язуються з цього хоста. Лише візуальне призначення.",
         "serverDescription": "Необов'язкова примітка, що показується під приміткою.",
         "allowInsecure": "Пропускати перевірку TLS-сертифіката (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Порти/діапазони, що маршрутизуються через VLESS, напр. 53,443,1000-2000. Залиште порожнім, щоб не використовувати.",
+        "vlessRoute": "Одне значення маршруту VLESS (0-65535), що вбудовується в UUID, напр. 443. Залиште порожнім, щоб не використовувати.",
         "remark": "Звичайна мітка для цього хоста. Показується як назва конфігурації лише тоді, коли вхідний не має власної примітки."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Оновити хост",
         "delete": "Видалити хост",
         "badTag": "Недійсний тег",
-        "badVlessRoute": "Використовуйте порти/діапазони, напр. 53,443,1000-2000"
+        "badVlessRoute": "Введіть одне число від 0 до 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/vi-VN.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "Chọn các nút được phân giải từ host này. Chỉ là gán trực quan.",
         "serverDescription": "Ghi chú tùy chọn hiển thị bên dưới ghi chú.",
         "allowInsecure": "Bỏ qua xác minh chứng chỉ TLS (allowInsecure / skip-cert-verify).",
-        "vlessRoute": "Cổng/dải cổng định tuyến qua VLESS, ví dụ 53,443,1000-2000. Để trống nếu không dùng.",
+        "vlessRoute": "Một giá trị định tuyến VLESS (0-65535) được nhúng vào UUID, ví dụ 443. Để trống nếu không dùng.",
         "remark": "Nhãn đơn giản cho host này. Chỉ hiển thị làm tên cấu hình khi inbound không có ghi chú riêng."
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "Cập nhật host",
         "delete": "Xóa host",
         "badTag": "Tag không hợp lệ",
-        "badVlessRoute": "Dùng cổng/dải cổng như 53,443,1000-2000"
+        "badVlessRoute": "Nhập một số duy nhất từ 0 đến 65535"
       }
     }
   },

+ 2 - 2
internal/web/translation/zh-CN.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "选择从此主机解析的节点。仅用于可视化关联。",
         "serverDescription": "可选备注,显示在备注下方。",
         "allowInsecure": "跳过 TLS 证书验证(allowInsecure / skip-cert-verify)。",
-        "vlessRoute": "通过 VLESS 路由的端口/范围,例如 53,443,1000-2000。留空表示不路由。",
+        "vlessRoute": "嵌入 UUID 的单个 VLESS 路由值(0-65535),例如 443。留空表示不路由。",
         "remark": "此主机的纯文本标签。仅当入站没有自己的备注时,才作为配置名称显示。"
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "更新主机",
         "delete": "删除主机",
         "badTag": "无效的标签",
-        "badVlessRoute": "请使用端口/范围格式,如 53,443,1000-2000"
+        "badVlessRoute": "请输入 0 到 65535 之间的单个数字"
       }
     }
   },

+ 2 - 2
internal/web/translation/zh-TW.json

@@ -1901,7 +1901,7 @@
         "nodeGuids": "選擇由此 Host 解析而來的節點。僅為視覺上的指派。",
         "serverDescription": "顯示於備註下方的選填註記。",
         "allowInsecure": "略過 TLS 憑證驗證(allowInsecure / skip-cert-verify)。",
-        "vlessRoute": "透過 VLESS 路由的連接埠/範圍,例如 53,443,1000-2000。留空表示無。",
+        "vlessRoute": "嵌入 UUID 的單一 VLESS 路由值(0-65535),例如 443。留空表示無。",
         "remark": "此 Host 的純文字標籤。僅當入站本身沒有備註時,才作為配置名稱顯示。"
       },
       "remarkVars": {
@@ -1951,7 +1951,7 @@
         "update": "更新 Host",
         "delete": "刪除 Host",
         "badTag": "無效的標籤",
-        "badVlessRoute": "請使用連接埠/範圍,例如 53,443,1000-2000"
+        "badVlessRoute": "請輸入 0 到 65535 之間的單一數字"
       }
     }
   },