10 Commits 3fa4eddae3 ... 93ff60e568

Autor SHA1 Mensagem Data
  MHSanaei 93ff60e568 fix(tgbot): reload bot on settings save so a new token takes effect without a panel restart há 5 horas atrás
  MHSanaei 23e73cd4a3 fix(clients): use new email after rename and de-duplicate save toast há 5 horas atrás
  MHSanaei b0c1156dd6 fix(sub): drive display remarks from the template and split multi-host subpage links há 6 horas atrás
  MHSanaei 5dbd5b1d12 fix(sub): restore client email in panel copy/QR link remark (#5532) há 7 horas atrás
  MHSanaei bd60e770f4 fix(outbound): preserve custom headers for HTTP outbounds (#5519) há 8 horas atrás
  MHSanaei a5e865c109 fix(backup): name Telegram backups after webDomain/IP instead of x-ui há 8 horas atrás
  Rouzbeh† 82600936d6 fix(flow): restore XTLS Vision when an inbound becomes flow-eligible (#5520) há 9 horas atrás
  Rouzbeh† 14de0557f9 feat(clients): bulk-set XTLS flow from the Adjust dialog (#5524) há 9 horas atrás
  Rouzbeh† c93beef267 fix(inbounds): accept null rewritePort in tunnel settings (#5516) (#5525) há 9 horas atrás
  MHSanaei 48c2fb27b8 feat(sub): add Incy client integration and routing tab há 9 horas atrás
60 ficheiros alterados com 1225 adições e 200 exclusões
  1. 23 2
      frontend/public/openapi.json
  2. 4 0
      frontend/src/generated/examples.ts
  3. 20 0
      frontend/src/generated/schemas.ts
  4. 4 0
      frontend/src/generated/types.ts
  5. 4 0
      frontend/src/generated/zod.ts
  6. 6 6
      frontend/src/hooks/useClients.ts
  7. 31 2
      frontend/src/lib/xray/outbound-form-adapter.ts
  8. 3 1
      frontend/src/models/setting.ts
  9. 2 2
      frontend/src/pages/api-docs/endpoints.ts
  10. 22 4
      frontend/src/pages/clients/ClientBulkAdjustModal.tsx
  11. 1 1
      frontend/src/pages/clients/ClientInfoModal.tsx
  12. 1 1
      frontend/src/pages/clients/ClientQrModal.tsx
  13. 7 5
      frontend/src/pages/clients/ClientsPage.tsx
  14. 16 1
      frontend/src/pages/settings/SubscriptionGeneralTab.tsx
  15. 5 2
      frontend/src/pages/sub/SubPage.tsx
  16. 5 0
      frontend/src/pages/xray/outbounds/protocols/http.tsx
  17. 2 1
      frontend/src/schemas/client.ts
  18. 1 0
      frontend/src/schemas/forms/outbound-form.ts
  19. 6 1
      frontend/src/schemas/protocols/inbound/tunnel.ts
  20. 1 0
      frontend/src/schemas/protocols/outbound/http.ts
  21. 2 0
      frontend/src/schemas/setting.ts
  22. 23 0
      frontend/src/test/outbound-form-adapter.test.ts
  23. 26 0
      frontend/src/test/tunnel-rewriteport.test.ts
  24. 19 14
      frontend/src/utils/index.ts
  25. 6 6
      internal/sub/characterization_test.go
  26. 29 14
      internal/sub/controller.go
  27. 4 4
      internal/sub/endpoint_test.go
  28. 37 0
      internal/sub/page_data_test.go
  29. 14 11
      internal/sub/remark_vars.go
  30. 25 27
      internal/sub/remark_vars_test.go
  31. 30 23
      internal/sub/service.go
  32. 12 1
      internal/sub/sub.go
  33. 2 1
      internal/web/controller/client.go
  34. 19 0
      internal/web/controller/setting.go
  35. 2 0
      internal/web/entity/entity.go
  36. 102 14
      internal/web/service/client_bulk.go
  37. 217 0
      internal/web/service/client_bulk_flow_test.go
  38. 64 0
      internal/web/service/client_effective_flow_test.go
  39. 38 0
      internal/web/service/client_lookup.go
  40. 8 0
      internal/web/service/inbound.go
  41. 90 0
      internal/web/service/inbound_flow_restore.go
  42. 96 0
      internal/web/service/inbound_flow_restore_test.go
  43. 41 0
      internal/web/service/inbound_migration.go
  44. 65 53
      internal/web/service/server.go
  45. 11 1
      internal/web/service/setting.go
  46. 1 1
      internal/web/service/sync_scale_postgres_test.go
  47. 4 0
      internal/web/translation/ar-EG.json
  48. 8 1
      internal/web/translation/en-US.json
  49. 4 0
      internal/web/translation/es-ES.json
  50. 4 0
      internal/web/translation/fa-IR.json
  51. 4 0
      internal/web/translation/id-ID.json
  52. 4 0
      internal/web/translation/ja-JP.json
  53. 4 0
      internal/web/translation/pt-BR.json
  54. 4 0
      internal/web/translation/ru-RU.json
  55. 4 0
      internal/web/translation/tr-TR.json
  56. 4 0
      internal/web/translation/uk-UA.json
  57. 4 0
      internal/web/translation/vi-VN.json
  58. 4 0
      internal/web/translation/zh-CN.json
  59. 4 0
      internal/web/translation/zh-TW.json
  60. 22 0
      internal/web/web.go

+ 23 - 2
frontend/public/openapi.json

@@ -232,6 +232,14 @@
             "description": "Hide server settings in happ subscription (Only for Happ)",
             "description": "Hide server settings in happ subscription (Only for Happ)",
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subIncyEnableRouting": {
+            "description": "Enable routing injection for the Incy client",
+            "type": "boolean"
+          },
+          "subIncyRoutingRules": {
+            "description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
+            "type": "string"
+          },
           "subJsonEnable": {
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
             "type": "boolean"
@@ -457,6 +465,8 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subHideSettings",
           "subHideSettings",
+          "subIncyEnableRouting",
+          "subIncyRoutingRules",
           "subJsonEnable",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonMux",
@@ -727,6 +737,14 @@
             "description": "Hide server settings in happ subscription (Only for Happ)",
             "description": "Hide server settings in happ subscription (Only for Happ)",
             "type": "boolean"
             "type": "boolean"
           },
           },
+          "subIncyEnableRouting": {
+            "description": "Enable routing injection for the Incy client",
+            "type": "boolean"
+          },
+          "subIncyRoutingRules": {
+            "description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
+            "type": "string"
+          },
           "subJsonEnable": {
           "subJsonEnable": {
             "description": "Enable JSON subscription endpoint",
             "description": "Enable JSON subscription endpoint",
             "type": "boolean"
             "type": "boolean"
@@ -959,6 +977,8 @@
           "subEnableRouting",
           "subEnableRouting",
           "subEncrypt",
           "subEncrypt",
           "subHideSettings",
           "subHideSettings",
+          "subIncyEnableRouting",
+          "subIncyRoutingRules",
           "subJsonEnable",
           "subJsonEnable",
           "subJsonFinalMask",
           "subJsonFinalMask",
           "subJsonMux",
           "subJsonMux",
@@ -5456,7 +5476,7 @@
         "tags": [
         "tags": [
           "Clients"
           "Clients"
         ],
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
         "requestBody": {
           "required": true,
           "required": true,
@@ -5471,7 +5491,8 @@
                   "bob"
                   "bob"
                 ],
                 ],
                 "addDays": 30,
                 "addDays": 30,
-                "addBytes": 53687091200
+                "addBytes": 53687091200,
+                "flow": "xtls-rprx-vision"
               }
               }
             }
             }
           }
           }

+ 4 - 0
frontend/src/generated/examples.ts

@@ -52,6 +52,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
     "subHideSettings": false,
     "subHideSettings": false,
+    "subIncyEnableRouting": false,
+    "subIncyRoutingRules": "",
     "subJsonEnable": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonMux": "",
@@ -152,6 +154,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "subEnableRouting": false,
     "subEnableRouting": false,
     "subEncrypt": false,
     "subEncrypt": false,
     "subHideSettings": false,
     "subHideSettings": false,
+    "subIncyEnableRouting": false,
+    "subIncyRoutingRules": "",
     "subJsonEnable": false,
     "subJsonEnable": false,
     "subJsonFinalMask": "",
     "subJsonFinalMask": "",
     "subJsonMux": "",
     "subJsonMux": "",

+ 20 - 0
frontend/src/generated/schemas.ts

@@ -206,6 +206,14 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Hide server settings in happ subscription (Only for Happ)",
         "description": "Hide server settings in happ subscription (Only for Happ)",
         "type": "boolean"
         "type": "boolean"
       },
       },
+      "subIncyEnableRouting": {
+        "description": "Enable routing injection for the Incy client",
+        "type": "boolean"
+      },
+      "subIncyRoutingRules": {
+        "description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
+        "type": "string"
+      },
       "subJsonEnable": {
       "subJsonEnable": {
         "description": "Enable JSON subscription endpoint",
         "description": "Enable JSON subscription endpoint",
         "type": "boolean"
         "type": "boolean"
@@ -431,6 +439,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
       "subHideSettings",
       "subHideSettings",
+      "subIncyEnableRouting",
+      "subIncyRoutingRules",
       "subJsonEnable",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonMux",
@@ -701,6 +711,14 @@ export const SCHEMAS: Record<string, unknown> = {
         "description": "Hide server settings in happ subscription (Only for Happ)",
         "description": "Hide server settings in happ subscription (Only for Happ)",
         "type": "boolean"
         "type": "boolean"
       },
       },
+      "subIncyEnableRouting": {
+        "description": "Enable routing injection for the Incy client",
+        "type": "boolean"
+      },
+      "subIncyRoutingRules": {
+        "description": "Incy routing deep-link injected into the subscription body (Only for Incy)",
+        "type": "string"
+      },
       "subJsonEnable": {
       "subJsonEnable": {
         "description": "Enable JSON subscription endpoint",
         "description": "Enable JSON subscription endpoint",
         "type": "boolean"
         "type": "boolean"
@@ -933,6 +951,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "subEnableRouting",
       "subEnableRouting",
       "subEncrypt",
       "subEncrypt",
       "subHideSettings",
       "subHideSettings",
+      "subIncyEnableRouting",
+      "subIncyRoutingRules",
       "subJsonEnable",
       "subJsonEnable",
       "subJsonFinalMask",
       "subJsonFinalMask",
       "subJsonMux",
       "subJsonMux",

+ 4 - 0
frontend/src/generated/types.ts

@@ -58,6 +58,8 @@ export interface AllSetting {
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
   subHideSettings: boolean;
   subHideSettings: boolean;
+  subIncyEnableRouting: boolean;
+  subIncyRoutingRules: string;
   subJsonEnable: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonMux: string;
@@ -159,6 +161,8 @@ export interface AllSettingView {
   subEnableRouting: boolean;
   subEnableRouting: boolean;
   subEncrypt: boolean;
   subEncrypt: boolean;
   subHideSettings: boolean;
   subHideSettings: boolean;
+  subIncyEnableRouting: boolean;
+  subIncyRoutingRules: string;
   subJsonEnable: boolean;
   subJsonEnable: boolean;
   subJsonFinalMask: string;
   subJsonFinalMask: string;
   subJsonMux: string;
   subJsonMux: string;

+ 4 - 0
frontend/src/generated/zod.ts

@@ -70,6 +70,8 @@ export const AllSettingSchema = z.object({
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
+  subIncyEnableRouting: z.boolean(),
+  subIncyRoutingRules: z.string(),
   subJsonEnable: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonMux: z.string(),
@@ -172,6 +174,8 @@ export const AllSettingViewSchema = z.object({
   subEnableRouting: z.boolean(),
   subEnableRouting: z.boolean(),
   subEncrypt: z.boolean(),
   subEncrypt: z.boolean(),
   subHideSettings: z.boolean(),
   subHideSettings: z.boolean(),
+  subIncyEnableRouting: z.boolean(),
+  subIncyRoutingRules: z.string(),
   subJsonEnable: z.boolean(),
   subJsonEnable: z.boolean(),
   subJsonFinalMask: z.string(),
   subJsonFinalMask: z.string(),
   subJsonMux: z.string(),
   subJsonMux: z.string(),

+ 6 - 6
frontend/src/hooks/useClients.ts

@@ -341,7 +341,7 @@ export function useClients() {
   });
   });
 
 
   const bulkAdjustMut = useMutation({
   const bulkAdjustMut = useMutation({
-    mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number }): Promise<Msg<BulkAdjustResult>> => {
+    mutationFn: async (payload: { emails: string[]; addDays: number; addBytes: number; flow: string }): Promise<Msg<BulkAdjustResult>> => {
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       const raw = await HttpUtil.post('/panel/api/clients/bulkAdjust', payload, JSON_HEADERS);
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
       return parseMsg(raw, BulkAdjustResultSchema, 'clients/bulkAdjust');
     },
     },
@@ -350,13 +350,13 @@ export function useClients() {
 
 
   const attachMut = useMutation({
   const attachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, JSON_HEADERS),
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/attach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
   const setExternalLinksMut = useMutation({
   const setExternalLinksMut = useMutation({
     mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
     mutationFn: ({ email, externalLinks }: { email: string; externalLinks: ExternalLinkInput[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, JSON_HEADERS),
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/externalLinks`, { externalLinks }, { ...JSON_HEADERS, silentSuccess: true }),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
@@ -370,7 +370,7 @@ export function useClients() {
 
 
   const detachMut = useMutation({
   const detachMut = useMutation({
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
     mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) =>
-      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS),
+      HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, { ...JSON_HEADERS, silentSuccess: true }),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
   });
 
 
@@ -435,9 +435,9 @@ export function useClients() {
     if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
     if (!Array.isArray(payloads) || payloads.length === 0) return Promise.resolve(null as unknown as Msg<BulkCreateResult>);
     return bulkCreateMut.mutateAsync(payloads);
     return bulkCreateMut.mutateAsync(payloads);
   }, [bulkCreateMut]);
   }, [bulkCreateMut]);
-  const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number) => {
+  const bulkAdjust = useCallback((emails: string[], addDays: number, addBytes: number, flow = '') => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-    return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
+    return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes, flow });
   }, [bulkAdjustMut]);
   }, [bulkAdjustMut]);
   const bulkAddToGroup = useCallback((emails: string[], group: string) => {
   const bulkAddToGroup = useCallback((emails: string[], group: string) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);

+ 31 - 2
frontend/src/lib/xray/outbound-form-adapter.ts

@@ -8,6 +8,7 @@ import type {
   DnsRuleForm,
   DnsRuleForm,
   FreedomFinalRuleForm,
   FreedomFinalRuleForm,
   FreedomOutboundFormSettings,
   FreedomOutboundFormSettings,
+  HttpOutboundFormSettings,
   HysteriaOutboundFormSettings,
   HysteriaOutboundFormSettings,
   LoopbackOutboundFormSettings,
   LoopbackOutboundFormSettings,
   MuxForm,
   MuxForm,
@@ -178,6 +179,26 @@ function simpleAuthFromWire(raw: Raw, defaultPort: number): SimpleAuthFormSettin
   };
   };
 }
 }
 
 
+function stringRecordFromWire(raw: unknown): Record<string, string> {
+  const obj = asObject(raw);
+  const out: Record<string, string> = {};
+  for (const [k, v] of Object.entries(obj)) {
+    if (typeof v === 'string') out[k] = v;
+  }
+  return out;
+}
+
+// HTTP outbound reuses the SOCKS server/user shape but also carries xray's
+// top-level `settings.headers` (HTTPClientConfig.Headers), the CONNECT
+// headers sent to the upstream proxy. xray ignores per-server `headers`,
+// so only the settings-level map round-trips (issue #5519).
+function httpFromWire(raw: Raw): HttpOutboundFormSettings {
+  return {
+    ...simpleAuthFromWire(raw, 8080),
+    headers: stringRecordFromWire(raw.headers),
+  };
+}
+
 function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
 function wireguardFromWire(raw: Raw): WireguardOutboundFormSettings {
   const secretKey = asString(raw.secretKey);
   const secretKey = asString(raw.secretKey);
   const pubKey = secretKey.length > 0
   const pubKey = secretKey.length > 0
@@ -395,7 +416,7 @@ export function rawOutboundToFormValues(raw: RawOutboundRow): OutboundFormValues
     case 'trojan':      typed = { protocol: 'trojan',      settings: trojanFromWire(settings) }; break;
     case 'trojan':      typed = { protocol: 'trojan',      settings: trojanFromWire(settings) }; break;
     case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
     case 'shadowsocks': typed = { protocol: 'shadowsocks', settings: shadowsocksFromWire(settings) }; break;
     case 'socks':       typed = { protocol: 'socks',       settings: simpleAuthFromWire(settings, 1080) }; break;
     case 'socks':       typed = { protocol: 'socks',       settings: simpleAuthFromWire(settings, 1080) }; break;
-    case 'http':        typed = { protocol: 'http',        settings: simpleAuthFromWire(settings, 8080) }; break;
+    case 'http':        typed = { protocol: 'http',        settings: httpFromWire(settings) }; break;
     case 'wireguard':   typed = { protocol: 'wireguard',   settings: wireguardFromWire(settings) }; break;
     case 'wireguard':   typed = { protocol: 'wireguard',   settings: wireguardFromWire(settings) }; break;
     case 'hysteria':    typed = { protocol: 'hysteria',    settings: hysteriaFromWire(settings) }; break;
     case 'hysteria':    typed = { protocol: 'hysteria',    settings: hysteriaFromWire(settings) }; break;
     case 'freedom':     typed = { protocol: 'freedom',     settings: freedomFromWire(settings) }; break;
     case 'freedom':     typed = { protocol: 'freedom',     settings: freedomFromWire(settings) }; break;
@@ -489,6 +510,14 @@ function simpleAuthToWire(s: SimpleAuthFormSettings) {
   };
   };
 }
 }
 
 
+function httpToWire(s: HttpOutboundFormSettings): Raw {
+  const wire: Raw = simpleAuthToWire(s);
+  if (s.headers && Object.keys(s.headers).length > 0) {
+    wire.headers = s.headers;
+  }
+  return wire;
+}
+
 function wireguardToWire(s: WireguardOutboundFormSettings) {
 function wireguardToWire(s: WireguardOutboundFormSettings) {
   return {
   return {
     mtu: s.mtu || undefined,
     mtu: s.mtu || undefined,
@@ -629,7 +658,7 @@ export function formValuesToWirePayload(values: OutboundFormValues): WireOutboun
     case 'trojan':      settings = trojanToWire(values.settings); break;
     case 'trojan':      settings = trojanToWire(values.settings); break;
     case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
     case 'shadowsocks': settings = shadowsocksToWire(values.settings); break;
     case 'socks':       settings = simpleAuthToWire(values.settings); break;
     case 'socks':       settings = simpleAuthToWire(values.settings); break;
-    case 'http':        settings = simpleAuthToWire(values.settings); break;
+    case 'http':        settings = httpToWire(values.settings); break;
     case 'wireguard':   settings = wireguardToWire(values.settings); break;
     case 'wireguard':   settings = wireguardToWire(values.settings); break;
     case 'hysteria':    settings = hysteriaToWire(values.settings); break;
     case 'hysteria':    settings = hysteriaToWire(values.settings); break;
     case 'freedom':     settings = freedomToWire(values.settings); break;
     case 'freedom':     settings = freedomToWire(values.settings); break;

+ 3 - 1
frontend/src/models/setting.ts

@@ -13,7 +13,7 @@ export class AllSetting {
   pageSize = 25;
   pageSize = 25;
   expireDiff = 0;
   expireDiff = 0;
   trafficDiff = 0;
   trafficDiff = 0;
-  remarkTemplate = '{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
+  remarkTemplate = '{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   datepicker: 'gregorian' | 'jalalian' = 'gregorian';
   tgBotEnable = false;
   tgBotEnable = false;
   tgBotToken = '';
   tgBotToken = '';
@@ -35,6 +35,8 @@ export class AllSetting {
   subAnnounce = '';
   subAnnounce = '';
   subEnableRouting = false;
   subEnableRouting = false;
   subRoutingRules = '';
   subRoutingRules = '';
+  subIncyEnableRouting = false;
+  subIncyRoutingRules = '';
   subListen = '';
   subListen = '';
   subPort = 2096;
   subPort = 2096;
   subPath = '/sub/';
   subPath = '/sub/';

+ 2 - 2
frontend/src/pages/api-docs/endpoints.ts

@@ -635,8 +635,8 @@ export const sections: readonly Section[] = [
       {
       {
         method: 'POST',
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
         path: '/panel/api/clients/bulkAdjust',
-        summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. Returns the adjusted count and per-email skip reasons.',
-        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200\n}',
+        summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
+        body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },
       },
       {
       {

+ 22 - 4
frontend/src/pages/clients/ClientBulkAdjustModal.tsx

@@ -1,16 +1,19 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
-import { Alert, Form, InputNumber, Modal, message } from 'antd';
+import { Alert, Form, InputNumber, Modal, Select, message } from 'antd';
 
 
 import { ClientBulkAdjustFormSchema } from '@/schemas/client';
 import { ClientBulkAdjustFormSchema } from '@/schemas/client';
+import { TLS_FLOW_CONTROL } from '@/schemas/primitives/flow';
 
 
 const GB = 1024 * 1024 * 1024;
 const GB = 1024 * 1024 * 1024;
 
 
+const FLOW_CLEAR = 'none';
+
 interface ClientBulkAdjustModalProps {
 interface ClientBulkAdjustModalProps {
   open: boolean;
   open: boolean;
   count: number;
   count: number;
   onOpenChange: (open: boolean) => void;
   onOpenChange: (open: boolean) => void;
-  onSubmit: (addDays: number, addBytes: number) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
+  onSubmit: (addDays: number, addBytes: number, flow: string) => Promise<{ adjusted: number; skipped?: { email: string; reason: string }[] } | null>;
 }
 }
 
 
 export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
 export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSubmit }: ClientBulkAdjustModalProps) {
@@ -18,12 +21,14 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
   const [messageApi, messageContextHolder] = message.useMessage();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [addDays, setAddDays] = useState<number>(0);
   const [addDays, setAddDays] = useState<number>(0);
   const [addGB, setAddGB] = useState<number>(0);
   const [addGB, setAddGB] = useState<number>(0);
+  const [flow, setFlow] = useState<string>('');
   const [submitting, setSubmitting] = useState(false);
   const [submitting, setSubmitting] = useState(false);
 
 
   useEffect(() => {
   useEffect(() => {
     if (open) {
     if (open) {
       setAddDays(0);
       setAddDays(0);
       setAddGB(0);
       setAddGB(0);
+      setFlow('');
     }
     }
   }, [open]);
   }, [open]);
 
 
@@ -31,16 +36,17 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
     const validated = ClientBulkAdjustFormSchema.safeParse({
     const validated = ClientBulkAdjustFormSchema.safeParse({
       addDays: Math.trunc(Number(addDays) || 0),
       addDays: Math.trunc(Number(addDays) || 0),
       addGB: Number(addGB) || 0,
       addGB: Number(addGB) || 0,
+      flow,
     });
     });
     if (!validated.success) {
     if (!validated.success) {
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       messageApi.warning(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
       return;
       return;
     }
     }
-    const { addDays: days, addGB: gb } = validated.data;
+    const { addDays: days, addGB: gb, flow: flowValue } = validated.data;
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
       const bytes = Math.trunc(gb * GB);
       const bytes = Math.trunc(gb * GB);
-      const result = await onSubmit(days, bytes);
+      const result = await onSubmit(days, bytes, flowValue);
       if (!result) return;
       if (!result) return;
       const ok = result.adjusted ?? 0;
       const ok = result.adjusted ?? 0;
       const skipped = result.skipped?.length ?? 0;
       const skipped = result.skipped?.length ?? 0;
@@ -95,6 +101,18 @@ export default function ClientBulkAdjustModal({ open, count, onOpenChange, onSub
               step={1}
               step={1}
             />
             />
           </Form.Item>
           </Form.Item>
+          <Form.Item label={t('pages.clients.bulkFlow')}>
+            <Select
+              value={flow}
+              onChange={setFlow}
+              style={{ width: '100%' }}
+              options={[
+                { value: '', label: t('pages.clients.bulkFlowNoChange') },
+                { value: FLOW_CLEAR, label: t('pages.clients.bulkFlowDisable') },
+                ...Object.values(TLS_FLOW_CONTROL).map((k) => ({ value: k, label: k })),
+              ]}
+            />
+          </Form.Item>
         </Form>
         </Form>
       </Modal>
       </Modal>
     </>
     </>

+ 1 - 1
frontend/src/pages/clients/ClientInfoModal.tsx

@@ -357,7 +357,7 @@ export default function ClientInfoModal({
                   const parts = parseLinkParts(link);
                   const parts = parseLinkParts(link);
                   const fallback = `${t('pages.clients.link')} ${idx + 1}`;
                   const fallback = `${t('pages.clients.link')} ${idx + 1}`;
                   const rowTitle = (parts && linkMetaText(parts)) || fallback;
                   const rowTitle = (parts && linkMetaText(parts)) || fallback;
-                  const qrRemark = [parts?.remark, client.email].filter(Boolean).join('-') || rowTitle;
+                  const qrRemark = parts?.remark || rowTitle;
                   const canQr = !isPostQuantumLink(link);
                   const canQr = !isPostQuantumLink(link);
                   return (
                   return (
                     <div key={idx} className="link-row">
                     <div key={idx} className="link-row">

+ 1 - 1
frontend/src/pages/clients/ClientQrModal.tsx

@@ -106,7 +106,7 @@ export default function ClientQrModal({
         children: (
         children: (
           <QrPanel
           <QrPanel
             value={link}
             value={link}
-            remark={`${client?.email || ''} #${idx + 1}`}
+            remark={parts?.remark || `${client?.email || ''} #${idx + 1}`}
             showQr={!isPostQuantumLink(link)}
             showQr={!isPostQuantumLink(link)}
           />
           />
         ),
         ),

+ 7 - 5
frontend/src/pages/clients/ClientsPage.tsx

@@ -685,16 +685,18 @@ export default function ClientsPage() {
     }
     }
     const updateMsg = await update(meta.email, payload);
     const updateMsg = await update(meta.email, payload);
     if (!updateMsg?.success) return updateMsg;
     if (!updateMsg?.success) return updateMsg;
+    const rawEmail = (payload as { email?: unknown }).email;
+    const emailKey = typeof rawEmail === 'string' && rawEmail.trim() ? rawEmail.trim() : meta.email;
     if (Array.isArray(meta.attach) && meta.attach.length > 0) {
     if (Array.isArray(meta.attach) && meta.attach.length > 0) {
-      const r = await attach(meta.email, meta.attach);
+      const r = await attach(emailKey, meta.attach);
       if (!r?.success) return r;
       if (!r?.success) return r;
     }
     }
     if (Array.isArray(meta.detach) && meta.detach.length > 0) {
     if (Array.isArray(meta.detach) && meta.detach.length > 0) {
-      const r = await detach(meta.email, meta.detach);
+      const r = await detach(emailKey, meta.detach);
       if (!r?.success) return r;
       if (!r?.success) return r;
     }
     }
     // Always replace the client's external links (an empty set clears them).
     // Always replace the client's external links (an empty set clears them).
-    const r = await setExternalLinks(meta.email, meta.externalLinks);
+    const r = await setExternalLinks(emailKey, meta.externalLinks);
     if (!r?.success) return r;
     if (!r?.success) return r;
     return updateMsg;
     return updateMsg;
   }, [create, update, attach, detach, setExternalLinks]);
   }, [create, update, attach, detach, setExternalLinks]);
@@ -1418,8 +1420,8 @@ export default function ClientsPage() {
             open={bulkAdjustOpen}
             open={bulkAdjustOpen}
             count={selectedRowKeys.length}
             count={selectedRowKeys.length}
             onOpenChange={setBulkAdjustOpen}
             onOpenChange={setBulkAdjustOpen}
-            onSubmit={async (addDays, addBytes) => {
-              const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes);
+            onSubmit={async (addDays, addBytes, flow) => {
+              const msg = await bulkAdjust([...selectedRowKeys], addDays, addBytes, flow);
               if (msg?.success) {
               if (msg?.success) {
                 setSelectedRowKeys([]);
                 setSelectedRowKeys([]);
                 return msg.obj ?? { adjusted: 0 };
                 return msg.obj ?? { adjusted: 0 };

+ 16 - 1
frontend/src/pages/settings/SubscriptionGeneralTab.tsx

@@ -1,5 +1,5 @@
 import { Input, InputNumber, Switch, Tabs } from 'antd';
 import { Input, InputNumber, Switch, Tabs } from 'antd';
-import { BranchesOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
+import { BranchesOutlined, CompassOutlined, IdcardOutlined, InfoCircleOutlined, NodeIndexOutlined, SafetyCertificateOutlined, SettingOutlined } from '@ant-design/icons';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import type { AllSetting } from '@/models/setting';
 import type { AllSetting } from '@/models/setting';
 import { SettingListItem } from '@/components/ui';
 import { SettingListItem } from '@/components/ui';
@@ -178,6 +178,21 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
           </>
           </>
         ),
         ),
       },
       },
+      {
+        key: '7',
+        label: catTabLabel(<CompassOutlined />, 'Incy', isMobile),
+        children: (
+          <>
+            <SettingListItem paddings="small" title={t('pages.settings.subIncyEnableRouting')} description={t('pages.settings.subIncyEnableRoutingDesc')}>
+              <Switch checked={allSetting.subIncyEnableRouting} onChange={(v) => updateSetting({ subIncyEnableRouting: v })} />
+            </SettingListItem>
+            <SettingListItem paddings="small" title={t('pages.settings.subIncyRoutingRules')} description={t('pages.settings.subIncyRoutingRulesDesc')}>
+              <Input.TextArea value={allSetting.subIncyRoutingRules} placeholder="incy://routing/onadd/..."
+                onChange={(e) => updateSetting({ subIncyRoutingRules: e.target.value })} />
+            </SettingListItem>
+          </>
+        ),
+      },
     ]} />
     ]} />
   );
   );
 }
 }

+ 5 - 2
frontend/src/pages/sub/SubPage.tsx

@@ -139,6 +139,7 @@ export default function SubPage() {
   );
   );
   const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
   const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
   const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
   const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
+  const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
 
 
   const pageClass = useMemo(() => {
   const pageClass = useMemo(() => {
     const classes = ['subscription-page'];
     const classes = ['subscription-page'];
@@ -200,6 +201,7 @@ export default function SubPage() {
     { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
     { key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
     { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
     { key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
     { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
     { key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
+    { key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
   ], [copy, open]);
   ], [copy, open]);
 
 
   const iosMenuItems = useMemo(() => [
   const iosMenuItems = useMemo(() => [
@@ -209,7 +211,8 @@ export default function SubPage() {
     { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
     { key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
     { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
     { key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
     { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
     { key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
-  ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl]);
+    { key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
+  ], [copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl]);
 
 
   const langMenuItems = useMemo(
   const langMenuItems = useMemo(
     () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
     () => (LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map((l) => ({
@@ -418,7 +421,7 @@ export default function SubPage() {
                         const parts = parseLinkParts(link);
                         const parts = parseLinkParts(link);
                         const fallback = `Link ${idx + 1}`;
                         const fallback = `Link ${idx + 1}`;
                         const rowTitle = parts?.remark || fallback;
                         const rowTitle = parts?.remark || fallback;
-                        const qrLabel = [parts?.remark, linkEmails[idx]].filter(Boolean).join('-') || rowTitle;
+                        const qrLabel = parts?.remark || rowTitle;
                         const canQr = !isPostQuantumLink(link);
                         const canQr = !isPostQuantumLink(link);
                         return (
                         return (
                           <div key={link} className="sub-link-row">
                           <div key={link} className="sub-link-row">

+ 5 - 0
frontend/src/pages/xray/outbounds/protocols/http.tsx

@@ -1,6 +1,8 @@
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Form, Input } from 'antd';
 import { Form, Input } from 'antd';
 
 
+import { HeaderMapEditor } from '@/components/form';
+
 export default function HttpFields() {
 export default function HttpFields() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   return (
   return (
@@ -11,6 +13,9 @@ export default function HttpFields() {
       <Form.Item label={t('password')} name={['settings', 'pass']}>
       <Form.Item label={t('password')} name={['settings', 'pass']}>
         <Input />
         <Input />
       </Form.Item>
       </Form.Item>
+      <Form.Item label={t('pages.inbounds.form.headers')} name={['settings', 'headers']}>
+        <HeaderMapEditor mode="v1" />
+      </Form.Item>
     </>
     </>
   );
   );
 }
 }

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

@@ -188,8 +188,9 @@ export const ClientBulkAdjustFormSchema = z
   .object({
   .object({
     addDays: z.number().int(),
     addDays: z.number().int(),
     addGB: z.number(),
     addGB: z.number(),
+    flow: z.string().optional().default(''),
   })
   })
-  .refine((v) => v.addDays !== 0 || v.addGB !== 0, {
+  .refine((v) => v.addDays !== 0 || v.addGB !== 0 || v.flow !== '', {
     message: 'pages.clients.bulkAdjustNothing',
     message: 'pages.clients.bulkAdjustNothing',
   });
   });
 
 

+ 1 - 0
frontend/src/schemas/forms/outbound-form.ts

@@ -80,6 +80,7 @@ export const HttpOutboundFormSettingsSchema = z.object({
   port: PortSchema.default(8080),
   port: PortSchema.default(8080),
   user: z.string().default(''),
   user: z.string().default(''),
   pass: z.string().default(''),
   pass: z.string().default(''),
+  headers: z.record(z.string(), z.string()).default({}),
 });
 });
 export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
 export type HttpOutboundFormSettings = z.infer<typeof HttpOutboundFormSettingsSchema>;
 
 

+ 6 - 1
frontend/src/schemas/protocols/inbound/tunnel.ts

@@ -11,7 +11,12 @@ export type TunnelNetwork = z.infer<typeof TunnelNetworkSchema>;
 // with arr=false.
 // with arr=false.
 export const TunnelInboundSettingsSchema = z.object({
 export const TunnelInboundSettingsSchema = z.object({
   rewriteAddress: z.string().optional(),
   rewriteAddress: z.string().optional(),
-  rewritePort: PortSchema.optional(),
+  // AntD InputNumber writes null when cleared; accept it and collapse to
+  // undefined so the field is omitted from the payload instead of crashing
+  // validation with "Invalid input" (issue #5516). The trailing .optional()
+  // keeps the key optional in the inferred type (a bare .transform() would
+  // make it required).
+  rewritePort: PortSchema.nullable().transform((v) => v ?? undefined).optional(),
   portMap: z.record(z.string(), z.string()).default({}),
   portMap: z.record(z.string(), z.string()).default({}),
   allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
   allowedNetwork: TunnelNetworkSchema.default('tcp,udp'),
   followRedirect: z.boolean().default(false),
   followRedirect: z.boolean().default(false),

+ 1 - 0
frontend/src/schemas/protocols/outbound/http.ts

@@ -21,5 +21,6 @@ export type HttpOutboundServer = z.infer<typeof HttpOutboundServerSchema>;
 
 
 export const HttpOutboundSettingsSchema = z.object({
 export const HttpOutboundSettingsSchema = z.object({
   servers: z.array(HttpOutboundServerSchema).min(1),
   servers: z.array(HttpOutboundServerSchema).min(1),
+  headers: z.record(z.string(), z.string()).optional(),
 });
 });
 export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;
 export type HttpOutboundSettings = z.infer<typeof HttpOutboundSettingsSchema>;

+ 2 - 0
frontend/src/schemas/setting.ts

@@ -39,6 +39,8 @@ export const AllSettingSchema = z.object({
   subAnnounce: z.string().optional(),
   subAnnounce: z.string().optional(),
   subEnableRouting: z.boolean().optional(),
   subEnableRouting: z.boolean().optional(),
   subRoutingRules: z.string().optional(),
   subRoutingRules: z.string().optional(),
+  subIncyEnableRouting: z.boolean().optional(),
+  subIncyRoutingRules: z.string().optional(),
   subListen: z.string().optional(),
   subListen: z.string().optional(),
   subPort: port.optional(),
   subPort: port.optional(),
   subPath: absolutePath.optional(),
   subPath: absolutePath.optional(),

+ 23 - 0
frontend/src/test/outbound-form-adapter.test.ts

@@ -175,6 +175,29 @@ describe('outbound-form-adapter: round-trip', () => {
     });
     });
   });
   });
 
 
+  it('http preserves top-level settings.headers across wire → form → wire (#5519)', () => {
+    const headers = { 'X-T5-Auth': '683556433', Host: '153.3.236.22:443' };
+    const form = rawOutboundToFormValues({
+      protocol: 'http',
+      tag: 'h',
+      settings: { servers: [{ address: 'a', port: 443, users: [] }], headers },
+    });
+    expect(form.protocol).toBe('http');
+    if (form.protocol === 'http') {
+      expect(form.settings.headers).toEqual(headers);
+    }
+    const back = formValuesToWirePayload(form);
+    expect(back.settings).toMatchObject({ headers });
+  });
+
+  it('http omits headers when empty', () => {
+    const back = formValuesToWirePayload(rawOutboundToFormValues({
+      protocol: 'http',
+      settings: { servers: [{ address: 'a', port: 8080, users: [] }] },
+    }));
+    expect(back.settings).not.toHaveProperty('headers');
+  });
+
   it('wireguard csv-joins address and reserved on read, splits on write', () => {
   it('wireguard csv-joins address and reserved on read, splits on write', () => {
     const wire = {
     const wire = {
       protocol: 'wireguard',
       protocol: 'wireguard',

+ 26 - 0
frontend/src/test/tunnel-rewriteport.test.ts

@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+
+import { TunnelInboundSettingsSchema } from '@/schemas/protocols/inbound/tunnel';
+
+// Regression for issue #5516: AntD InputNumber writes null when the Rewrite
+// port field is cleared, which used to crash validation with "Invalid input".
+describe('TunnelInboundSettingsSchema rewritePort', () => {
+  it('accepts null (cleared field) and omits the port', () => {
+    const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: null });
+    expect(parsed.rewritePort).toBeUndefined();
+  });
+
+  it('accepts a missing field', () => {
+    const parsed = TunnelInboundSettingsSchema.parse({});
+    expect(parsed.rewritePort).toBeUndefined();
+  });
+
+  it('preserves a valid port', () => {
+    const parsed = TunnelInboundSettingsSchema.parse({ rewritePort: 8443 });
+    expect(parsed.rewritePort).toBe(8443);
+  });
+
+  it('still rejects out-of-range ports', () => {
+    expect(() => TunnelInboundSettingsSchema.parse({ rewritePort: 70000 })).toThrow();
+  });
+});

+ 19 - 14
frontend/src/utils/index.ts

@@ -19,6 +19,7 @@ export class Msg<T = unknown> {
 
 
 export interface HttpOptions extends AxiosRequestConfig {
 export interface HttpOptions extends AxiosRequestConfig {
   silent?: boolean;
   silent?: boolean;
+  silentSuccess?: boolean;
 }
 }
 
 
 export interface HttpModal {
 export interface HttpModal {
@@ -27,20 +28,24 @@ export interface HttpModal {
 }
 }
 
 
 export class HttpUtil {
 export class HttpUtil {
-  static _handleMsg(msg: unknown): void {
+  static _handleMsg(msg: unknown, silentSuccess = false): void {
     if (!(msg instanceof Msg) || msg.msg === '') {
     if (!(msg instanceof Msg) || msg.msg === '') {
       return;
       return;
     }
     }
-    const messageType = msg.success ? 'success' : 'error';
-    getMessage()[messageType](msg.msg);
-    if (
-      msg.success &&
-      msg.obj &&
-      typeof msg.obj === 'object' &&
-      (msg.obj as { nodePending?: unknown }).nodePending === true
-    ) {
-      getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
+    if (msg.success) {
+      if (!silentSuccess) {
+        getMessage().success(msg.msg);
+      }
+      if (
+        msg.obj &&
+        typeof msg.obj === 'object' &&
+        (msg.obj as { nodePending?: unknown }).nodePending === true
+      ) {
+        getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
+      }
+      return;
     }
     }
+    getMessage().error(msg.msg);
   }
   }
 
 
   static _respToMsg(resp: AxiosResponse | undefined): Msg {
   static _respToMsg(resp: AxiosResponse | undefined): Msg {
@@ -59,11 +64,11 @@ export class HttpUtil {
   }
   }
 
 
   static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
   static async get<T = unknown>(url: string, params?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
-    const { silent, ...axiosOpts } = options;
+    const { silent, silentSuccess, ...axiosOpts } = options;
     try {
     try {
       const resp = await axios.get(url, { params, ...axiosOpts });
       const resp = await axios.get(url, { params, ...axiosOpts });
       const msg = this._respToMsg(resp) as Msg<T>;
       const msg = this._respToMsg(resp) as Msg<T>;
-      if (!silent) this._handleMsg(msg);
+      if (!silent) this._handleMsg(msg, silentSuccess);
       return msg;
       return msg;
     } catch (error) {
     } catch (error) {
       console.error('GET request failed:', error);
       console.error('GET request failed:', error);
@@ -75,11 +80,11 @@ export class HttpUtil {
   }
   }
 
 
   static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
   static async post<T = unknown>(url: string, data?: unknown, options: HttpOptions = {}): Promise<Msg<T>> {
-    const { silent, ...axiosOpts } = options;
+    const { silent, silentSuccess, ...axiosOpts } = options;
     try {
     try {
       const resp = await axios.post(url, data, axiosOpts);
       const resp = await axios.post(url, data, axiosOpts);
       const msg = this._respToMsg(resp) as Msg<T>;
       const msg = this._respToMsg(resp) as Msg<T>;
-      if (!silent) this._handleMsg(msg);
+      if (!silent) this._handleMsg(msg, silentSuccess);
       return msg;
       return msg;
     } catch (error) {
     } catch (error) {
       console.error('POST request failed:', error);
       console.error('POST request failed:', error);

+ 6 - 6
internal/sub/characterization_test.go

@@ -45,8 +45,8 @@ func TestChar_C1_VlessExternalProxy(t *testing.T) {
 	}`
 	}`
 	s := &SubService{}
 	s := &SubService{}
 	got := s.genVlessLink(charVlessInbound(stream), "user")
 	got := s.genVlessLink(charVlessInbound(stream), "user")
-	want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1\n" +
-		"vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2"
+	want := "vless://[email protected]:8443?alpn=h3%2Ch2&encryption=none&fp=firefox&pcs=UElO&security=tls&sni=sni1.example.com&type=tcp#char-R1-user\n" +
+		"vless://[email protected]:80?encryption=none&security=none&type=tcp#char-R2-user"
 	if got != want {
 	if got != want {
 		t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("C1 mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}
@@ -106,8 +106,8 @@ func TestChar_C2_VmessExternalProxy(t *testing.T) {
 	}
 	}
 	s := &SubService{}
 	s := &SubService{}
 	got := s.genVmessLink(in, "user")
 	got := s.genVmessLink(in, "user")
-	want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMSIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogInNuaTEuZXhhbXBsZS5jb20iLAogICJ0bHMiOiAidGxzIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9\n" +
-		"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMiIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
+	want := "vmess://ewogICJhZGQiOiAidm0xLmV4YW1wbGUuY29tIiwKICAiYWxwbiI6ICJoMiIsCiAgImZwIjogImNocm9tZSIsCiAgImlkIjogIjExMTExMTExLTIyMjItNDMzMy04NDQ0LTU1NTU1NTU1NTU1NSIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODQ0MywKICAicHMiOiAiY2hhci1WMS11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJzbmkiOiAic25pMS5leGFtcGxlLmNvbSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
+		"vmess://ewogICJhZGQiOiAidm0yLmV4YW1wbGUuY29tIiwKICAiaWQiOiAiMTExMTExMTEtMjIyMi00MzMzLTg0NDQtNTU1NTU1NTU1NTU1IiwKICAibmV0IjogInRjcCIsCiAgInBvcnQiOiA4MCwKICAicHMiOiAiY2hhci1WMi11c2VyIiwKICAic2N5IjogImF1dG8iLAogICJ0bHMiOiAibm9uZSIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ=="
 	if got != want {
 	if got != want {
 		t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("C2 mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}
@@ -143,7 +143,7 @@ func TestChar_C3_TrojanExternalProxy(t *testing.T) {
 	}
 	}
 	s := &SubService{}
 	s := &SubService{}
 	got := s.genTrojanLink(in, "user")
 	got := s.genTrojanLink(in, "user")
-	want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ"
+	want := "trojan://p%40ss%2Fw%2Brd%[email protected]:8443?fp=chrome&security=tls&sni=tj.sni&type=tcp#char-TJ-user"
 	if got != want {
 	if got != want {
 		t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("C3-Trojan mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}
@@ -168,7 +168,7 @@ func TestChar_C3_ShadowsocksExternalProxy(t *testing.T) {
 	}
 	}
 	s := &SubService{}
 	s := &SubService{}
 	got := s.genShadowsocksLink(in, "user")
 	got := s.genShadowsocksLink(in, "user")
-	want := "ss://2022-blake3-aes-256-gcm:inboundpw:[email protected]:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS"
+	want := "ss://2022-blake3-aes-256-gcm:inboundpw:[email protected]:8443?fp=chrome&security=tls&sni=ss.sni&type=tcp#char-SS-user"
 	if got != want {
 	if got != want {
 		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("C3-SS mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}

+ 29 - 14
internal/sub/controller.go

@@ -49,13 +49,17 @@ type SUBController struct {
 	subEnableRouting bool
 	subEnableRouting bool
 	subRoutingRules  string
 	subRoutingRules  string
 	subHideSettings  bool
 	subHideSettings  bool
-	subPath          string
-	subJsonPath      string
-	subClashPath     string
-	jsonEnabled      bool
-	clashEnabled     bool
-	subEncrypt       bool
-	updateInterval   string
+
+	subIncyEnableRouting bool
+	subIncyRoutingRules  string
+
+	subPath        string
+	subJsonPath    string
+	subClashPath   string
+	jsonEnabled    bool
+	clashEnabled   bool
+	subEncrypt     bool
+	updateInterval string
 
 
 	subService      *SubService
 	subService      *SubService
 	subJsonService  *SubJsonService
 	subJsonService  *SubJsonService
@@ -89,6 +93,8 @@ func NewSUBController(
 	subEnableRouting bool,
 	subEnableRouting bool,
 	subRoutingRules string,
 	subRoutingRules string,
 	subHideSettings bool,
 	subHideSettings bool,
+	subIncyEnableRouting bool,
+	subIncyRoutingRules string,
 ) *SUBController {
 ) *SUBController {
 	sub := NewSubService(remarkTemplate)
 	sub := NewSubService(remarkTemplate)
 	a := &SUBController{
 	a := &SUBController{
@@ -99,13 +105,17 @@ func NewSUBController(
 		subEnableRouting: subEnableRouting,
 		subEnableRouting: subEnableRouting,
 		subRoutingRules:  subRoutingRules,
 		subRoutingRules:  subRoutingRules,
 		subHideSettings:  subHideSettings,
 		subHideSettings:  subHideSettings,
-		subPath:          subPath,
-		subJsonPath:      jsonPath,
-		subClashPath:     clashPath,
-		jsonEnabled:      jsonEnabled,
-		clashEnabled:     clashEnabled,
-		subEncrypt:       encrypt,
-		updateInterval:   update,
+
+		subIncyEnableRouting: subIncyEnableRouting,
+		subIncyRoutingRules:  subIncyRoutingRules,
+
+		subPath:        subPath,
+		subJsonPath:    jsonPath,
+		subClashPath:   clashPath,
+		jsonEnabled:    jsonEnabled,
+		clashEnabled:   clashEnabled,
+		subEncrypt:     encrypt,
+		updateInterval: update,
 
 
 		subService:      sub,
 		subService:      sub,
 		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
 		subJsonService:  NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
@@ -183,6 +193,11 @@ func (a *SUBController) subs(c *gin.Context) {
 		}
 		}
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 		a.ApplyCommonHeaders(c, header, a.updateInterval, a.subTitle, a.subSupportUrl, profileUrl, a.subAnnounce, a.subEnableRouting, a.subRoutingRules, a.subHideSettings)
 
 
+		if a.subIncyEnableRouting && a.subIncyRoutingRules != "" {
+			result.WriteString(a.subIncyRoutingRules)
+			result.WriteString("\n")
+		}
+
 		if a.subEncrypt {
 		if a.subEncrypt {
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
 			c.String(200, base64.StdEncoding.EncodeToString([]byte(result.String())))
 		} else {
 		} else {

+ 4 - 4
internal/sub/endpoint_test.go

@@ -83,8 +83,8 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
 		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
 		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
 		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\n" +
-		"vless://[email protected]:80?security=none&type=tcp#ib-B"
+	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
+		"vless://[email protected]:80?security=none&type=tcp#ib-B-user"
 	if got != want {
 	if got != want {
 		t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}
@@ -105,8 +105,8 @@ func TestBuildEndpointVmessLinks(t *testing.T) {
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 	}
 	}
 	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
 	got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
-	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEiLAogICJzY3kiOiAiYXV0byIsCiAgInNuaSI6ICJhLnNuaSIsCiAgInRscyI6ICJ0bHMiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0=\n" +
-		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUIiLAogICJzY3kiOiAiYXV0byIsCiAgInRscyI6ICJub25lIiwKICAidHlwZSI6ICJub25lIiwKICAidiI6ICIyIgp9"
+	want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
+		"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
 	if got != want {
 	if got != want {
 		t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
 		t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
 	}
 	}

+ 37 - 0
internal/sub/page_data_test.go

@@ -0,0 +1,37 @@
+package sub
+
+import (
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// A single getSubs entry can hold several links (one per host of an inbound)
+// joined by newlines. BuildPageData must split them into one entry per link, with
+// the email replicated, so the subpage renders one row per host instead of
+// collapsing them onto a single mangled line.
+func TestBuildPageData_SplitsMultiHostLinks(t *testing.T) {
+	s := &SubService{}
+	subs := []string{
+		"vless://a@h1:443?type=tcp#DE-john@x\nvless://a@h2:443?type=tcp#DE-john@x\nvless://a@h3:443?type=tcp#DE-john@x",
+		"vless://b@h:443?type=tcp#FR-alice@x",
+	}
+	emails := []string{"john@x", "alice@x"}
+
+	page := s.BuildPageData("s1", "", xray.ClientTraffic{}, 0, subs, emails, "", "", "", "/", "", "")
+
+	if len(page.Result) != 4 {
+		t.Fatalf("Result len = %d, want 4 (3 host links + 1 single link)", len(page.Result))
+	}
+	for i, link := range page.Result {
+		if strings.Contains(link, "\n") {
+			t.Fatalf("Result[%d] still multi-line: %q", i, link)
+		}
+	}
+	wantEmails := []string{"john@x", "john@x", "john@x", "alice@x"}
+	if !reflect.DeepEqual(page.Emails, wantEmails) {
+		t.Fatalf("Emails = %v, want %v", page.Emails, wantEmails)
+	}
+}

+ 14 - 11
internal/sub/remark_vars.go

@@ -491,7 +491,7 @@ func nameOnlyTemplate(template string) string {
 // effectiveTemplate picks which template to expand for one body link: the full
 // effectiveTemplate picks which template to expand for one body link: the full
 // template (with the per-client info) for a client's first link, and the
 // template (with the per-client info) for a client's first link, and the
 // name-only template for every link thereafter — so the info shows once. Only
 // name-only template for every link thereafter — so the info shows once. Only
-// called in the subscription-body context (displays bypass the template).
+// called in the subscription-body context (displays render name-only directly).
 func (s *SubService) effectiveTemplate(email string) string {
 func (s *SubService) effectiveTemplate(email string) string {
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
 	if s.usageShown == nil {
@@ -515,22 +515,25 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 		hostRemark: hostRemark,
 		hostRemark: hostRemark,
 		transport:  transport,
 		transport:  transport,
 	}
 	}
-	tmpl := s.effectiveTemplate(client.Email)
-	// Fall back to the config name when the template is empty or expands to
-	// nothing (e.g. an all-unlimited template whose only segments dropped out).
+	var tmpl string
+	if s.subscriptionBody {
+		tmpl = s.effectiveTemplate(client.Email)
+	} else {
+		tmpl = nameOnlyTemplate(translateUISingleBrackets(s.remarkTemplate))
+	}
 	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
 	if out := expandRemarkVars(tmpl, ctx); strings.TrimSpace(out) != "" {
 		return out
 		return out
 	}
 	}
 	return ctx.configName()
 	return ctx.configName()
 }
 }
 
 
-// genHostRemark builds one host endpoint's remark for a specific client. The
-// config name is always the inbound's own remark; the host's remark is surfaced
-// only through the {{HOST}} token. In the subscription body the rest of the
-// remark template still applies; displays show just the config name.
+// genHostRemark builds one host endpoint's remark for a specific client. With a
+// remark template set it is template-driven (body shows the full template on the
+// first link and the name-only part thereafter; displays render the name-only
+// part). With no template it falls back to inbound, host and email joined by "-".
 func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
 func (s *SubService) genHostRemark(inbound *model.Inbound, client model.Client, hostRemark string, transport string) string {
-	if !s.subscriptionBody {
-		return remarkContext{inbound: inbound, hostRemark: hostRemark}.configName()
+	if s.remarkTemplate != "" {
+		return s.genTemplatedRemark(inbound, client, hostRemark, transport)
 	}
 	}
-	return s.genTemplatedRemark(inbound, client, hostRemark, transport)
+	return fallbackRemark(inbound.Remark, hostRemark, client.Email)
 }
 }

+ 25 - 27
internal/sub/remark_vars_test.go

@@ -164,15 +164,15 @@ func hostRemarkService(template string) (*SubService, *model.Inbound, model.Clie
 	return s, inbound, client
 	return s, inbound, client
 }
 }
 
 
-// The config name is always the inbound's own remark; the host endpoint's remark
-// never substitutes it (it is reachable only through {{HOST}}).
-func TestGenHostRemark_ConfigNameUsesInbound(t *testing.T) {
-	s, inbound, client := hostRemarkService("") // no template → config name only
-	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE" {
-		t.Fatalf("genHostRemark = %q, want %q (inbound remark, host ignored)", got, "DE")
+// With no template configured, genHostRemark falls back to the inbound remark,
+// host and email joined by "-".
+func TestGenHostRemark_NoTemplate_Fallback(t *testing.T) {
+	s, inbound, client := hostRemarkService("")
+	if got := s.genHostRemark(inbound, client, "Relay", ""); got != "DE[email protected]" {
+		t.Fatalf("genHostRemark = %q, want %q", got, "DE[email protected]")
 	}
 	}
-	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
-		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE")
+	if got := s.genHostRemark(inbound, client, "", ""); got != "DE[email protected]" {
+		t.Fatalf("genHostRemark (no host remark) = %q, want %q", got, "DE[email protected]")
 	}
 	}
 }
 }
 
 
@@ -206,12 +206,11 @@ func TestGenRemark_GlobalTemplate(t *testing.T) {
 	}
 	}
 }
 }
 
 
-// With no template, genRemark composes the fallback model and adds no suffix.
-func TestGenRemark_NoTemplate_NoSuffix(t *testing.T) {
+func TestGenRemark_NoTemplate_AppendsEmail(t *testing.T) {
 	s, inbound, _ := hostRemarkService("")
 	s, inbound, _ := hostRemarkService("")
 	got := s.genRemark(inbound, "[email protected]", "Relay", "")
 	got := s.genRemark(inbound, "[email protected]", "Relay", "")
-	if got != "DE-Relay" {
-		t.Fatalf("genRemark = %q, want %q (no suffix)", got, "DE-Relay")
+	if got != "DE-Relay[email protected]" {
+		t.Fatalf("genRemark = %q, want %q", got, "DE-Relay[email protected]")
 	}
 	}
 }
 }
 
 
@@ -232,31 +231,30 @@ func TestUsageOnFirstLinkOnly(t *testing.T) {
 	}
 	}
 }
 }
 
 
-// Outside the subscription body (panel link/QR displays, sub info page) the
-// template is bypassed entirely — links show just the config name, with no
-// per-client email or usage info.
 func TestRemarkInDisplayContext(t *testing.T) {
 func TestRemarkInDisplayContext(t *testing.T) {
-	s, inbound, client := hostRemarkService("{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
+	s, inbound, client := hostRemarkService("{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D")
 	s.subscriptionBody = false
 	s.subscriptionBody = false
-	// A host link in a display shows only the config name — the inbound's remark,
-	// with no per-client email or usage info and the host remark ignored.
-	if got := s.genHostRemark(inbound, client, "CDN", ""); got != "DE" {
-		t.Fatalf("display host link = %q, want config name %q", got, "DE")
+	const want = "[email protected]"
+	if got := s.genHostRemark(inbound, client, "CDN", ""); got != want {
+		t.Fatalf("display host link = %q, want %q", got, want)
+	}
+	if got := s.genHostRemark(inbound, client, "", ""); got != want {
+		t.Fatalf("display host link (no host) = %q, want %q", got, want)
 	}
 	}
-	// With no host remark, the config name is likewise the inbound's own remark.
-	if got := s.genHostRemark(inbound, client, "", ""); got != "DE" {
-		t.Fatalf("display host link (no host) = %q, want %q", got, "DE")
+	if got := s.genRemark(inbound, client.Email, "", ""); got != want {
+		t.Fatalf("display genRemark = %q, want %q", got, want)
 	}
 	}
-	// genRemark (non-host) likewise drops the template in display context.
-	if got := s.genRemark(inbound, client.Email, "", ""); got != "DE" {
-		t.Fatalf("display genRemark = %q, want %q", got, "DE")
+	s2, inbound2, client2 := hostRemarkService("{{INBOUND}}-{{HOST}}|📊{{TRAFFIC_LEFT}}")
+	s2.subscriptionBody = false
+	if got := s2.genHostRemark(inbound2, client2, "CDN", ""); got != "DE-CDN" {
+		t.Fatalf("display host link with HOST token = %q, want %q", got, "DE-CDN")
 	}
 	}
 }
 }
 
 
 // nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
 // nameOnlyTemplate drops the info part (and its leading decoration), keeping name.
 func TestNameOnlyTemplate(t *testing.T) {
 func TestNameOnlyTemplate(t *testing.T) {
 	cases := map[string]string{
 	cases := map[string]string{
-		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // the default → name only
+		"{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D": "{{INBOUND}}",           // usage tail stripped
 		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":          "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
 		"{{EMAIL}} {{INBOUND}} ⏳{{DAYS_LEFT}}":          "{{EMAIL}} {{INBOUND}}", // multi-token name survives the trim
 		"{{INBOUND}} | {{STATUS}}":                      "{{INBOUND}}",
 		"{{INBOUND}} | {{STATUS}}":                      "{{INBOUND}}",
 		"{{INBOUND}}-{{EMAIL}}":                         "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged
 		"{{INBOUND}}-{{EMAIL}}":                         "{{INBOUND}}-{{EMAIL}}", // no info tokens → unchanged

+ 30 - 23
internal/sub/service.go

@@ -1634,31 +1634,25 @@ func cloneStringMap(source map[string]string) map[string]string {
 }
 }
 
 
 // genRemark builds the remark for a non-host link (raw default / legacy
 // genRemark builds the remark for a non-host link (raw default / legacy
-// externalProxy / synthetic JSON-Clash entry). In the subscription body a set
-// remark template takes over; otherwise (and in every display context) the
-// remark is just the config name (inbound remark, then extra).
+// externalProxy / synthetic JSON-Clash entry). A set remark template drives it
+// in both the body and display contexts (genTemplatedRemark renders the
+// name-only part on displays); with no template it falls back to the inbound
+// remark, extra and email joined by "-".
 func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
 func (s *SubService) genRemark(inbound *model.Inbound, email string, extra string, transport string) string {
-	if s.remarkTemplate != "" && s.subscriptionBody {
+	if s.remarkTemplate != "" {
 		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 		return s.genTemplatedRemark(inbound, s.lookupClient(inbound, email), extra, transport)
 	}
 	}
-	// Sub info page + panel link/QR displays: just the config name (no template,
-	// so no per-client email/usage leaks into the shown remark).
-	return fallbackRemark(inbound.Remark, extra)
-}
-
-// fallbackRemark is the minimal remark used only when no template is configured
-// (an operator explicitly cleared it): the inbound remark and the host/extra
-// remark joined by "-", skipping empties. The configurable remark model was
-// removed in favour of the template, whose default already includes the email.
-func fallbackRemark(inboundRemark, extra string) string {
-	switch {
-	case inboundRemark == "":
-		return extra
-	case extra == "":
-		return inboundRemark
-	default:
-		return inboundRemark + "-" + extra
+	return fallbackRemark(inbound.Remark, extra, email)
+}
+
+func fallbackRemark(parts ...string) string {
+	out := make([]string, 0, len(parts))
+	for _, p := range parts {
+		if p != "" {
+			out = append(out, p)
+		}
 	}
 	}
+	return strings.Join(out, "-")
 }
 }
 
 
 // findClientStats returns the inbound's traffic record for email, if present.
 // findClientStats returns the inbound's traffic record for email, if present.
@@ -2343,6 +2337,19 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		datepicker = "gregorian"
 		datepicker = "gregorian"
 	}
 	}
 
 
+	pageLinks := make([]string, 0, len(subs))
+	pageEmails := make([]string, 0, len(subs))
+	for i, sub := range subs {
+		email := ""
+		if i < len(emails) {
+			email = emails[i]
+		}
+		for _, link := range splitLinkLines(sub) {
+			pageLinks = append(pageLinks, link)
+			pageEmails = append(pageEmails, email)
+		}
+	}
+
 	return PageData{
 	return PageData{
 		Host:          hostHeader,
 		Host:          hostHeader,
 		BasePath:      basePath,
 		BasePath:      basePath,
@@ -2364,8 +2371,8 @@ func (s *SubService) BuildPageData(subId string, hostHeader string, traffic xray
 		SubClashUrl:   subClashURL,
 		SubClashUrl:   subClashURL,
 		SubTitle:      subTitle,
 		SubTitle:      subTitle,
 		SubSupportUrl: subSupportUrl,
 		SubSupportUrl: subSupportUrl,
-		Result:        subs,
-		Emails:        emails,
+		Result:        pageLinks,
+		Emails:        pageEmails,
 	}
 	}
 }
 }
 
 

+ 12 - 1
internal/sub/sub.go

@@ -175,6 +175,16 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 		SubHideSettings = false
 		SubHideSettings = false
 	}
 	}
 
 
+	SubIncyEnableRouting, err := s.settingService.GetSubIncyEnableRouting()
+	if err != nil {
+		SubIncyEnableRouting = false
+	}
+
+	SubIncyRoutingRules, err := s.settingService.GetSubIncyRoutingRules()
+	if err != nil {
+		SubIncyRoutingRules = ""
+	}
+
 	// set per-request localizer from headers/cookies
 	// set per-request localizer from headers/cookies
 	engine.Use(locale.LocalizerMiddleware())
 	engine.Use(locale.LocalizerMiddleware())
 
 
@@ -232,7 +242,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	s.sub = NewSUBController(
 	s.sub = NewSUBController(
 		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
 		g, LinksPath, JsonPath, ClashPath, subJsonEnable, subClashEnable, Encrypt, RemarkTemplate, SubUpdates,
 		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
 		SubJsonMux, SubJsonRules, SubJsonFinalMask, SubClashEnableRouting, SubClashRules, SubTitle, SubSupportUrl,
-		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings)
+		SubProfileUrl, SubAnnounce, SubEnableRouting, SubRoutingRules, SubHideSettings,
+		SubIncyEnableRouting, SubIncyRoutingRules)
 
 
 	return engine, nil
 	return engine, nil
 }
 }

+ 2 - 1
internal/web/controller/client.go

@@ -248,6 +248,7 @@ type bulkAdjustRequest struct {
 	Emails   []string `json:"emails"`
 	Emails   []string `json:"emails"`
 	AddDays  int      `json:"addDays"`
 	AddDays  int      `json:"addDays"`
 	AddBytes int64    `json:"addBytes"`
 	AddBytes int64    `json:"addBytes"`
+	Flow     string   `json:"flow"`
 }
 }
 
 
 func (a *ClientController) bulkAdjust(c *gin.Context) {
 func (a *ClientController) bulkAdjust(c *gin.Context) {
@@ -256,7 +257,7 @@ func (a *ClientController) bulkAdjust(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return
 	}
 	}
-	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes)
+	result, needRestart, err := a.clientService.BulkAdjust(&a.inboundService, req.Emails, req.AddDays, req.AddBytes, req.Flow)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
 		return
 		return

+ 19 - 0
internal/web/controller/setting.go

@@ -88,6 +88,10 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 	}
 	}
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
 	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
 	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
+	oldTgEnable, _ := a.settingService.GetTgbotEnabled()
+	oldTgToken, _ := a.settingService.GetTgBotToken()
+	oldTgChatId, _ := a.settingService.GetTgBotChatId()
+	oldTgAPIServer, _ := a.settingService.GetTgBotAPIServer()
 	err := a.settingService.UpdateAllSetting(allSetting)
 	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
@@ -102,6 +106,16 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 			logger.Warning("apply panel outbound change failed:", applyErr)
 			logger.Warning("apply panel outbound change failed:", applyErr)
 		}
 		}
 	}
 	}
+	// UpdateAllSetting already restored a redacted-blank token, so allSetting.TgBotToken is the effective value to compare.
+	if err == nil && reloadTgbotFunc != nil {
+		tgChanged := oldTgEnable != allSetting.TgBotEnable ||
+			(allSetting.TgBotEnable && (oldTgToken != allSetting.TgBotToken ||
+				oldTgChatId != allSetting.TgBotChatId ||
+				oldTgAPIServer != allSetting.TgBotAPIServer))
+		if tgChanged {
+			reloadTgbotFunc()
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 }
 }
 
 
@@ -252,6 +266,11 @@ var testTgFunc func() error
 // SetTestTgFunc registers the function used to test Telegram sending.
 // SetTestTgFunc registers the function used to test Telegram sending.
 func SetTestTgFunc(fn func() error) { testTgFunc = fn }
 func SetTestTgFunc(fn func() error) { testTgFunc = fn }
 
 
+// reloadTgbotFunc is wired from the web layer; importing tgbot here would be a circular dependency.
+var reloadTgbotFunc func()
+
+func SetReloadTgbotFunc(fn func()) { reloadTgbotFunc = fn }
+
 // emailService is set from web layer.
 // emailService is set from web layer.
 var emailService *email.EmailService
 var emailService *email.EmailService
 
 

+ 2 - 0
internal/web/entity/entity.go

@@ -77,6 +77,8 @@ type AllSetting struct {
 	SubAnnounce                 string `json:"subAnnounce" form:"subAnnounce"`                                 // Subscription announce
 	SubAnnounce                 string `json:"subAnnounce" form:"subAnnounce"`                                 // Subscription announce
 	SubEnableRouting            bool   `json:"subEnableRouting" form:"subEnableRouting"`                       // Enable routing for subscription
 	SubEnableRouting            bool   `json:"subEnableRouting" form:"subEnableRouting"`                       // Enable routing for subscription
 	SubRoutingRules             string `json:"subRoutingRules" form:"subRoutingRules"`                         // Subscription global routing rules (Only for Happ)
 	SubRoutingRules             string `json:"subRoutingRules" form:"subRoutingRules"`                         // Subscription global routing rules (Only for Happ)
+	SubIncyEnableRouting        bool   `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`               // Enable routing injection for the Incy client
+	SubIncyRoutingRules         string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`                 // Incy routing deep-link injected into the subscription body (Only for Incy)
 	SubListen                   string `json:"subListen" form:"subListen"`                                     // Subscription server listen IP
 	SubListen                   string `json:"subListen" form:"subListen"`                                     // Subscription server listen IP
 	SubPort                     int    `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`              // Subscription server port
 	SubPort                     int    `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`              // Subscription server port
 	SubPath                     string `json:"subPath" form:"subPath"`                                         // Base path for subscription URLs
 	SubPath                     string `json:"subPath" form:"subPath"`                                         // Base path for subscription URLs

+ 102 - 14
internal/web/service/client_bulk.go

@@ -242,6 +242,22 @@ type bulkAdjustEntry struct {
 	newTotal    int64
 	newTotal    int64
 }
 }
 
 
+// bulkFlowClear is the directive that strips the XTLS flow from every selected
+// client. The vision values are the only positive flows xray accepts.
+const bulkFlowClear = "none"
+
+// bulkFlowAllowed whitelists the flow directives BulkAdjust accepts. Anything
+// outside this set is treated as "" (leave flow untouched) so a malformed or
+// hostile value can never be injected into a client's settings. The dropdown in
+// ClientBulkAdjustModal.tsx offers the same set ("" / "none" / TLS_FLOW_CONTROL);
+// keep the two in sync.
+var bulkFlowAllowed = map[string]struct{}{
+	"":                        {},
+	bulkFlowClear:             {},
+	"xtls-rprx-vision":        {},
+	"xtls-rprx-vision-udp443": {},
+}
+
 // BulkAdjust shifts ExpiryTime by addDays (days) and TotalGB by addBytes
 // BulkAdjust shifts ExpiryTime by addDays (days) and TotalGB by addBytes
 // for every email in the list. Clients whose corresponding field is
 // for every email in the list. Clients whose corresponding field is
 // unlimited (0) are skipped — bulk extend should not accidentally
 // unlimited (0) are skipped — bulk extend should not accidentally
@@ -250,12 +266,17 @@ type bulkAdjustEntry struct {
 // Like BulkDelete, the work is grouped by inbound so each inbound's
 // Like BulkDelete, the work is grouped by inbound so each inbound's
 // settings JSON is parsed and written exactly once regardless of how
 // settings JSON is parsed and written exactly once regardless of how
 // many target emails it contains.
 // many target emails it contains.
-func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64) (BulkAdjustResult, bool, error) {
+func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string, addDays int, addBytes int64, flow string) (BulkAdjustResult, bool, error) {
 	result := BulkAdjustResult{}
 	result := BulkAdjustResult{}
 	if len(emails) == 0 {
 	if len(emails) == 0 {
 		return result, false, nil
 		return result, false, nil
 	}
 	}
-	if addDays == 0 && addBytes == 0 {
+	flow = strings.TrimSpace(flow)
+	if _, ok := bulkFlowAllowed[flow]; !ok {
+		flow = "" // ignore unknown directives — "" means "leave flow untouched"
+	}
+	adjustFlow := flow != ""
+	if addDays == 0 && addBytes == 0 && !adjustFlow {
 		return result, false, common.NewError("no adjustment specified")
 		return result, false, common.NewError("no adjustment specified")
 	}
 	}
 
 
@@ -342,7 +363,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 				entry.newTotal = next
 				entry.newTotal = next
 			}
 			}
 		}
 		}
-		if entry.applyExpiry || entry.applyTotal {
+		if entry.applyExpiry || entry.applyTotal || adjustFlow {
 			plan[email] = entry
 			plan[email] = entry
 		}
 		}
 	}
 	}
@@ -379,11 +400,19 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 	}
 	}
 
 
 	needRestart := false
 	needRestart := false
+	flowHonored := map[string]bool{}
+	flowIneligible := map[string]bool{}
 	for inboundId, ibEmails := range emailsByInbound {
 	for inboundId, ibEmails := range emailsByInbound {
-		ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan)
+		ibRes := s.bulkAdjustInboundClients(inboundSvc, inboundId, ibEmails, plan, flow)
 		if ibRes.needRestart {
 		if ibRes.needRestart {
 			needRestart = true
 			needRestart = true
 		}
 		}
+		for email := range ibRes.flowHonored {
+			flowHonored[email] = true
+		}
+		for email := range ibRes.flowIneligible {
+			flowIneligible[email] = true
+		}
 		for email, reason := range ibRes.perEmailSkipped {
 		for email, reason := range ibRes.perEmailSkipped {
 			if _, already := skippedReasons[email]; !already {
 			if _, already := skippedReasons[email]; !already {
 				skippedReasons[email] = reason
 				skippedReasons[email] = reason
@@ -391,6 +420,7 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		}
 		}
 	}
 	}
 
 
+	adjusted := map[string]struct{}{}
 	for email, entry := range plan {
 	for email, entry := range plan {
 		if _, skipped := skippedReasons[email]; skipped {
 		if _, skipped := skippedReasons[email]; skipped {
 			continue
 			continue
@@ -402,27 +432,49 @@ func (s *ClientService) BulkAdjust(inboundSvc *InboundService, emails []string,
 		if entry.applyTotal {
 		if entry.applyTotal {
 			updates["total"] = entry.newTotal
 			updates["total"] = entry.newTotal
 		}
 		}
-		if len(updates) == 0 {
-			continue
-		}
-		if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil {
-			if _, already := skippedReasons[email]; !already {
-				skippedReasons[email] = err.Error()
+		if len(updates) > 0 {
+			if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Updates(updates).Error; err != nil {
+				if _, already := skippedReasons[email]; !already {
+					skippedReasons[email] = err.Error()
+				}
+				continue
 			}
 			}
-			continue
 		}
 		}
-		result.Adjusted++
+		// Counted when expiry/total changed, or a flow directive was honored
+		// for this client (flow lives in the inbound JSON, not ClientTraffic).
+		if len(updates) > 0 || flowHonored[email] {
+			adjusted[email] = struct{}{}
+		}
 	}
 	}
+	result.Adjusted = len(adjusted)
 
 
 	for email, reason := range skippedReasons {
 	for email, reason := range skippedReasons {
 		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: reason})
 		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: reason})
 	}
 	}
+	// Report a flow directive that no inbound could carry — only when it was not
+	// honored anywhere and the client has no other (expiry/total) skip reason.
+	// The expiry/total part, if any, has already been applied and counted above.
+	for email := range flowIneligible {
+		if flowHonored[email] {
+			continue
+		}
+		if _, already := skippedReasons[email]; already {
+			continue
+		}
+		result.Skipped = append(result.Skipped, BulkAdjustReport{Email: email, Reason: "flow not supported on inbound"})
+	}
 	return result, needRestart, nil
 	return result, needRestart, nil
 }
 }
 
 
 type bulkInboundAdjustResult struct {
 type bulkInboundAdjustResult struct {
 	perEmailSkipped map[string]string
 	perEmailSkipped map[string]string
-	needRestart     bool
+	flowHonored     map[string]bool
+	// flowIneligible is tracked apart from perEmailSkipped: a flow directive
+	// that an inbound cannot carry must not suppress the expiry/total write for
+	// the same client (which would diverge the inbound JSON / ClientRecord from
+	// ClientTraffic). It only feeds the final Skipped report.
+	flowIneligible map[string]bool
+	needRestart    bool
 }
 }
 
 
 // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
 // bulkAdjustInboundClients applies expiry/total deltas to multiple clients
@@ -436,8 +488,9 @@ func (s *ClientService) bulkAdjustInboundClients(
 	inboundId int,
 	inboundId int,
 	emails []string,
 	emails []string,
 	plan map[string]*bulkAdjustEntry,
 	plan map[string]*bulkAdjustEntry,
+	flow string,
 ) bulkInboundAdjustResult {
 ) bulkInboundAdjustResult {
-	res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}}
+	res := bulkInboundAdjustResult{perEmailSkipped: map[string]string{}, flowHonored: map[string]bool{}, flowIneligible: map[string]bool{}}
 
 
 	defer lockInbound(inboundId).Unlock()
 	defer lockInbound(inboundId).Unlock()
 
 
@@ -469,8 +522,15 @@ func (s *ClientService) bulkAdjustInboundClients(
 		wantedEmails[email] = struct{}{}
 		wantedEmails[email] = struct{}{}
 	}
 	}
 
 
+	// Flow eligibility is a property of the inbound (protocol + transport), so
+	// resolve it once. Clearing flow is always allowed; setting a vision flow
+	// is only honored on an inbound that can carry it.
+	flowEligible := flow == bulkFlowClear ||
+		inboundCanEnableTlsFlow(string(oldInbound.Protocol), oldInbound.StreamSettings, oldInbound.Settings)
+
 	interfaceClients, _ := settings["clients"].([]any)
 	interfaceClients, _ := settings["clients"].([]any)
 	foundEmails := map[string]bool{}
 	foundEmails := map[string]bool{}
+	flowChanged := false
 	nowMs := time.Now().Unix() * 1000
 	nowMs := time.Now().Unix() * 1000
 	for i, client := range interfaceClients {
 	for i, client := range interfaceClients {
 		c, ok := client.(map[string]any)
 		c, ok := client.(map[string]any)
@@ -488,6 +548,23 @@ func (s *ClientService) bulkAdjustInboundClients(
 		if entry.applyTotal {
 		if entry.applyTotal {
 			c["totalGB"] = entry.newTotal
 			c["totalGB"] = entry.newTotal
 		}
 		}
+		if flow != "" {
+			if flowEligible {
+				want := ""
+				if flow != bulkFlowClear {
+					want = flow
+				}
+				if cur, _ := c["flow"].(string); cur != want {
+					c["flow"] = want
+					flowChanged = true
+				}
+				res.flowHonored[targetEmail] = true
+			} else {
+				// Record separately so this never suppresses the expiry/total
+				// write for the same client (see flowIneligible doc).
+				res.flowIneligible[targetEmail] = true
+			}
+		}
 		c["updated_at"] = nowMs
 		c["updated_at"] = nowMs
 		interfaceClients[i] = c
 		interfaceClients[i] = c
 		foundEmails[targetEmail] = true
 		foundEmails[targetEmail] = true
@@ -513,6 +590,13 @@ func (s *ClientService) bulkAdjustInboundClients(
 	}
 	}
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
+	// A flow change rewrites the user's xray config, which the lightweight
+	// UpdateUser push below does not carry. Local nodes reload via restart;
+	// remote nodes get a full reconcile (MarkNodeDirty) instead of a per-user push.
+	if flowChanged && oldInbound.NodeID == nil {
+		res.needRestart = true
+	}
+
 	markDirty := false
 	markDirty := false
 	if oldInbound.NodeID != nil {
 	if oldInbound.NodeID != nil {
 		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
 		rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
@@ -525,6 +609,10 @@ func (s *ClientService) bulkAdjustInboundClients(
 			if dirty {
 			if dirty {
 				markDirty = true
 				markDirty = true
 			}
 			}
+			if flowChanged {
+				markDirty = true
+				push = false
+			}
 			// Large batches collapse into one reconcile push rather than M updates.
 			// Large batches collapse into one reconcile push rather than M updates.
 			if push && len(foundEmails) > nodeBulkPushThreshold {
 			if push && len(foundEmails) > nodeBulkPushThreshold {
 				markDirty = true
 				markDirty = true

+ 217 - 0
internal/web/service/client_bulk_flow_test.go

@@ -0,0 +1,217 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// mkInboundStream is mkInbound with explicit stream settings, needed to make an
+// inbound flow-eligible (VLESS + tcp + reality/tls).
+func mkInboundStream(t *testing.T, port int, proto model.Protocol, settings, stream string) *model.Inbound {
+	t.Helper()
+	ib := &model.Inbound{
+		Tag:            string(proto) + "-stream-" + emailSafe(port),
+		Enable:         true,
+		Port:           port,
+		Protocol:       proto,
+		Settings:       settings,
+		StreamSettings: stream,
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound %d: %v", port, err)
+	}
+	return ib
+}
+
+func emailSafe(port int) string {
+	return string(rune('a'+port%26)) + string(rune('a'+(port/26)%26))
+}
+
+func flowOf(t *testing.T, svc *ClientService, email string) string {
+	t.Helper()
+	rec, err := svc.GetRecordByEmail(nil, email)
+	if err != nil {
+		t.Fatalf("GetRecordByEmail(%q): %v", email, err)
+	}
+	return rec.Flow
+}
+
+const realityStream = `{"network":"tcp","security":"reality"}`
+const wsStream = `{"network":"ws","security":"none"}`
+
+// TestBulkAdjust_FlowSetAndClear covers the happy path: a vision flow is applied
+// on an eligible VLESS inbound and later cleared with the "none" directive. Both
+// transitions are real config changes, so they must request a restart.
+func TestBulkAdjust_FlowSetAndClear(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "f1@x", ID: "11111111-1111-1111-1111-111111111111", SubID: "f1", Enable: true},
+		{Email: "f2@x", ID: "22222222-2222-2222-2222-222222222222", SubID: "f2", Enable: true},
+	}
+	ib := mkInboundStream(t, 30001, model.VLESS, clientsSettings(t, clients), realityStream)
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	emails := emailsOf(clients)
+
+	// Set vision flow.
+	res, restart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443")
+	if err != nil {
+		t.Fatalf("BulkAdjust set: %v", err)
+	}
+	if res.Adjusted != 2 {
+		t.Fatalf("expected 2 adjusted, got %d (skipped=%v)", res.Adjusted, res.Skipped)
+	}
+	if !restart {
+		t.Fatalf("setting flow should request a restart")
+	}
+	for _, e := range emails {
+		if got := flowOf(t, svc, e); got != "xtls-rprx-vision-udp443" {
+			t.Fatalf("%s flow = %q, want xtls-rprx-vision-udp443", e, got)
+		}
+	}
+
+	// Setting the same flow again is a no-op: honored (counted) but no restart.
+	if _, restart2, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "xtls-rprx-vision-udp443"); err != nil {
+		t.Fatalf("BulkAdjust idempotent: %v", err)
+	} else if restart2 {
+		t.Fatalf("re-setting identical flow should not request a restart")
+	}
+
+	// Clear flow.
+	cres, crestart, err := svc.BulkAdjust(inboundSvc, emails, 0, 0, "none")
+	if err != nil {
+		t.Fatalf("BulkAdjust clear: %v", err)
+	}
+	if cres.Adjusted != 2 {
+		t.Fatalf("expected 2 cleared, got %d (skipped=%v)", cres.Adjusted, cres.Skipped)
+	}
+	if !crestart {
+		t.Fatalf("clearing flow should request a restart")
+	}
+	for _, e := range emails {
+		if got := flowOf(t, svc, e); got != "" {
+			t.Fatalf("%s flow = %q, want empty after clear", e, got)
+		}
+	}
+}
+
+// TestBulkAdjust_FlowIneligibleSkipped verifies a vision flow is refused on an
+// inbound that cannot carry it (ws transport), reported as skipped, and the
+// client's flow is left untouched.
+func TestBulkAdjust_FlowIneligibleSkipped(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	clients := []model.Client{
+		{Email: "ws1@x", ID: "33333333-3333-3333-3333-333333333333", SubID: "ws1", Enable: true},
+	}
+	ib := mkInboundStream(t, 30101, model.VLESS, clientsSettings(t, clients), wsStream)
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+
+	res, restart, err := svc.BulkAdjust(inboundSvc, []string{"ws1@x"}, 0, 0, "xtls-rprx-vision")
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 0 {
+		t.Fatalf("ineligible inbound should adjust nothing, got %d", res.Adjusted)
+	}
+	if restart {
+		t.Fatalf("no change should not request a restart")
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "ws1@x" {
+		t.Fatalf("expected ws1@x in skipped, got %v", res.Skipped)
+	}
+	if got := flowOf(t, svc, "ws1@x"); got != "" {
+		t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
+	}
+}
+
+// TestBulkAdjust_NoDirectiveErrors guards the relaxed precondition: with no
+// days, traffic, or flow set there is nothing to do.
+func TestBulkAdjust_NoDirectiveErrors(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, ""); err == nil {
+		t.Fatalf("expected error when no adjustment is specified")
+	}
+	// An unknown flow directive is ignored (treated as ""), so it also errors.
+	if _, _, err := svc.BulkAdjust(inboundSvc, []string{"any@x"}, 0, 0, "bogus-flow"); err == nil {
+		t.Fatalf("unknown flow should be ignored and error like an empty directive")
+	}
+}
+
+// TestBulkAdjust_DaysApplyDespiteIneligibleFlow is the regression for the review
+// blocker: when a client on a flow-ineligible inbound is adjusted with BOTH a
+// days/traffic delta AND a flow directive, the days/traffic change must still be
+// persisted to ClientTraffic (not just the inbound JSON / ClientRecord) and the
+// client must count as adjusted, while the unhonored flow is reported separately.
+func TestBulkAdjust_DaysApplyDespiteIneligibleFlow(t *testing.T) {
+	setupBulkDB(t)
+	svc := &ClientService{}
+	inboundSvc := &InboundService{}
+
+	const day = int64(24 * 60 * 60 * 1000)
+	const gb = int64(1) << 30
+	baseExpiry := time.Now().UnixMilli() + 30*day
+	baseTotal := 10 * gb
+
+	clients := []model.Client{
+		{Email: "mix@x", ID: "44444444-4444-4444-4444-444444444444", SubID: "mix", Enable: true, ExpiryTime: baseExpiry, TotalGB: baseTotal},
+	}
+	ib := mkInboundStream(t, 30201, model.VLESS, clientsSettings(t, clients), wsStream)
+	if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("seed: %v", err)
+	}
+	// ClientTraffic is the store the enforcement job reads; seed it to match.
+	if err := database.GetDB().Create(&xray.ClientTraffic{Email: "mix@x", Enable: true, ExpiryTime: baseExpiry, Total: baseTotal}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	res, _, err := svc.BulkAdjust(inboundSvc, []string{"mix@x"}, 7, gb, "xtls-rprx-vision")
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if res.Adjusted != 1 {
+		t.Fatalf("days/traffic should still be applied: Adjusted=%d skipped=%v", res.Adjusted, res.Skipped)
+	}
+	if len(res.Skipped) != 1 || res.Skipped[0].Email != "mix@x" {
+		t.Fatalf("expected mix@x reported for the unhonored flow, got %v", res.Skipped)
+	}
+
+	wantExpiry := baseExpiry + 7*day
+	wantTotal := baseTotal + gb
+
+	// ClientRecord (inbound-derived) advanced.
+	if rec, err := svc.GetRecordByEmail(nil, "mix@x"); err != nil {
+		t.Fatalf("record: %v", err)
+	} else if rec.ExpiryTime != wantExpiry || rec.TotalGB != wantTotal {
+		t.Fatalf("ClientRecord not advanced: expiry=%d total=%d", rec.ExpiryTime, rec.TotalGB)
+	}
+
+	// ClientTraffic advanced in lockstep — no divergence.
+	var ct xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "mix@x").First(&ct).Error; err != nil {
+		t.Fatalf("traffic row: %v", err)
+	}
+	if ct.ExpiryTime != wantExpiry || ct.Total != wantTotal {
+		t.Fatalf("ClientTraffic diverged: expiry=%d total=%d, want expiry=%d total=%d", ct.ExpiryTime, ct.Total, wantExpiry, wantTotal)
+	}
+
+	// Flow left untouched on the ineligible inbound.
+	if got := flowOf(t, svc, "mix@x"); got != "" {
+		t.Fatalf("flow should stay empty on ineligible inbound, got %q", got)
+	}
+}

+ 64 - 0
internal/web/service/client_effective_flow_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// EffectiveFlowsByEmails resolves intended flow for many clients in one batched
+// query, taking the flow_override of the lowest inbound_id and skipping emails
+// with no non-empty flow anywhere.
+func TestEffectiveFlowsByEmails(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+
+	const vision = "xtls-rprx-vision"
+
+	// vis@x: attached to inbound 20 (empty flow) and 10 (Vision) — lowest
+	// inbound_id (10) wins, so the empty override on 20 must not mask it.
+	// plain@x: only an empty flow_override anywhere — absent from the result.
+	mkClient := func(id int, email string) {
+		if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: true}).Error; err != nil {
+			t.Fatalf("create client %s: %v", email, err)
+		}
+	}
+	mkLink := func(clientID, inboundID int, flow string) {
+		if err := db.Create(&model.ClientInbound{ClientId: clientID, InboundId: inboundID, FlowOverride: flow}).Error; err != nil {
+			t.Fatalf("link %d/%d: %v", clientID, inboundID, err)
+		}
+	}
+	mkClient(1, "vis@x")
+	mkClient(2, "plain@x")
+	mkLink(1, 20, "")     // higher inbound_id, empty
+	mkLink(1, 10, vision) // lower inbound_id, Vision
+	mkLink(2, 30, "")     // only empty override
+
+	cs := &ClientService{}
+	got, err := cs.EffectiveFlowsByEmails(nil, []string{"vis@x", "plain@x", "missing@x"})
+	if err != nil {
+		t.Fatalf("EffectiveFlowsByEmails: %v", err)
+	}
+
+	if got["vis@x"] != vision {
+		t.Errorf("vis@x = %q, want %q (lowest inbound_id flow_override)", got["vis@x"], vision)
+	}
+	if v, ok := got["plain@x"]; ok {
+		t.Errorf("plain@x present (%q); want absent (no non-empty flow anywhere)", v)
+	}
+	if v, ok := got["missing@x"]; ok {
+		t.Errorf("missing@x present (%q); want absent (unknown client)", v)
+	}
+
+	// Empty input is a no-op (no query).
+	if m, err := cs.EffectiveFlowsByEmails(nil, nil); err != nil || len(m) != 0 {
+		t.Errorf("empty input: got %v err %v, want empty map", m, err)
+	}
+}

+ 38 - 0
internal/web/service/client_lookup.go

@@ -49,6 +49,44 @@ func (s *ClientService) EffectiveFlow(tx *gorm.DB, recordId int) (string, error)
 	return flows[0], nil
 	return flows[0], nil
 }
 }
 
 
+// EffectiveFlowsByEmails resolves the intended flow (non-empty flow_override,
+// lowest inbound_id first — same rule as EffectiveFlow) for many clients in one
+// query, keyed by email. Emails absent from the result carry no flow anywhere.
+// Batched so flow restoration on an inbound with many clients is O(1) queries
+// instead of O(clients). Used to restore a stripped flow onto an inbound that
+// has just become flow-eligible.
+func (s *ClientService) EffectiveFlowsByEmails(tx *gorm.DB, emails []string) (map[string]string, error) {
+	if tx == nil {
+		tx = database.GetDB()
+	}
+	out := make(map[string]string, len(emails))
+	if len(emails) == 0 {
+		return out, nil
+	}
+	type row struct {
+		Email string
+		Flow  string `gorm:"column:flow_override"`
+	}
+	for _, batch := range chunkStrings(emails, sqlInChunk) {
+		var rows []row
+		err := tx.Table("client_inbounds").
+			Select("clients.email AS email, client_inbounds.flow_override AS flow_override").
+			Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+			Where("clients.email IN ? AND client_inbounds.flow_override <> ?", batch, "").
+			Order("client_inbounds.inbound_id ASC").
+			Scan(&rows).Error
+		if err != nil {
+			return nil, err
+		}
+		for _, r := range rows {
+			if _, seen := out[r.Email]; !seen { // ordered by inbound_id ASC → first = lowest
+				out[r.Email] = r.Flow
+			}
+		}
+	}
+	return out, nil
+}
+
 func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
 func (s *ClientService) GetInboundIdsForEmail(tx *gorm.DB, email string) ([]int, error) {
 	if tx == nil {
 	if tx == nil {
 		tx = database.GetDB()
 		tx = database.GetDB()

+ 8 - 0
internal/web/service/inbound.go

@@ -1065,6 +1065,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
 			logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)")
 			logger.Warning("Shadowsocks inbound", inbound.Id, "method change resized keys; regenerated mismatched client PSK(s)")
 		}
 		}
 
 
+		// Re-gate Vision flow now that the new stream/encryption is known: if this
+		// VLESS inbound just became flow-eligible (e.g. vlessenc was enabled on an
+		// XHTTP inbound), restore Vision for clients whose intended flow is Vision
+		// but was stripped while the inbound was ineligible.
+		if restored, changed := s.restoreVisionFlowForEligibleInbound(tx, inbound.Settings, inbound.StreamSettings, inbound.Protocol); changed {
+			inbound.Settings = restored
+		}
+
 		oldInbound.Total = inbound.Total
 		oldInbound.Total = inbound.Total
 		oldInbound.Remark = inbound.Remark
 		oldInbound.Remark = inbound.Remark
 		oldInbound.SubSortIndex = inbound.SubSortIndex
 		oldInbound.SubSortIndex = inbound.SubSortIndex

+ 90 - 0
internal/web/service/inbound_flow_restore.go

@@ -0,0 +1,90 @@
+package service
+
+import (
+	"encoding/json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+const visionFlow = "xtls-rprx-vision"
+
+// restoreVisionFlowForEligibleInbound re-adds the XTLS Vision flow to a VLESS
+// inbound's clients that lost it earlier.
+//
+// clientWithInboundFlow strips Vision from a client whenever the target inbound
+// is not flow-eligible at write time (e.g. an XHTTP inbound before its vlessenc
+// encryption is set). Nothing restored the flow when the inbound later became
+// eligible — an inbound edit stores its settings verbatim and never re-gates the
+// clients — so enabling encryption on an existing XHTTP inbound left every
+// client without flow, and the share links/subscriptions dropped it.
+//
+// This runs on the now-final inbound settings: when the inbound IS flow-eligible
+// it sets flow=Vision on each client that currently has no flow but whose
+// intended flow (its flow_override on a sibling inbound, via EffectiveFlowsByEmails)
+// is Vision. It never invents a flow for a client that has none anywhere, and it
+// never overwrites an explicit non-empty flow. Returns the rewritten settings
+// JSON and whether anything changed.
+func (s *InboundService) restoreVisionFlowForEligibleInbound(tx *gorm.DB, settings, streamSettings string, protocol model.Protocol) (string, bool) {
+	if protocol != model.VLESS {
+		return settings, false
+	}
+	if !inboundCanEnableTlsFlow(string(protocol), streamSettings, settings) {
+		return settings, false
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
+		return settings, false
+	}
+	clients, ok := parsed["clients"].([]any)
+	if !ok || len(clients) == 0 {
+		return settings, false
+	}
+	// Collect empty-flow clients, then resolve their intended flow in one query.
+	emails := make([]string, 0, len(clients))
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if flow, _ := cm["flow"].(string); flow != "" {
+			continue // respect an explicit flow (Vision or otherwise)
+		}
+		if email, _ := cm["email"].(string); email != "" {
+			emails = append(emails, email)
+		}
+	}
+	if len(emails) == 0 {
+		return settings, false
+	}
+	intended, err := s.clientService.EffectiveFlowsByEmails(tx, emails)
+	if err != nil {
+		return settings, false
+	}
+	changed := false
+	for i := range clients {
+		cm, ok := clients[i].(map[string]any)
+		if !ok {
+			continue
+		}
+		if flow, _ := cm["flow"].(string); flow != "" {
+			continue
+		}
+		email, _ := cm["email"].(string)
+		if intended[email] != visionFlow {
+			continue
+		}
+		cm["flow"] = visionFlow
+		clients[i] = cm
+		changed = true
+	}
+	if !changed {
+		return settings, false
+	}
+	out, err := json.MarshalIndent(parsed, "", "  ")
+	if err != nil {
+		return settings, false
+	}
+	return string(out), true
+}

+ 96 - 0
internal/web/service/inbound_flow_restore_test.go

@@ -0,0 +1,96 @@
+package service
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// restoreVisionFlowForEligibleInbound must re-add Vision to a client whose flow
+// was stripped while the XHTTP inbound was not yet vlessenc-encrypted, but only
+// when the client's intended flow (its flow_override on a sibling) is Vision,
+// only on now-eligible inbounds, and never overwriting an explicit flow.
+func TestRestoreVisionFlowForEligibleInbound(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+
+	const vision = "xtls-rprx-vision"
+	const realityStream = `{"network":"tcp","security":"reality"}`
+	const xhttpEnc = `{"network":"xhttp","security":"reality"}`
+	const encSettings = `"decryption":"mlkem768x25519plus.native.0rtt.KEY","encryption":"mlkem768x25519plus.native.0rtt.KEY"`
+
+	cs := &ClientService{}
+	ibSvc := &InboundService{}
+
+	// Sibling reality inbound where the client legitimately has Vision.
+	sibling := &model.Inbound{
+		Tag: "sib", Enable: true, Port: 51001, Protocol: model.VLESS, StreamSettings: realityStream,
+		Settings: `{"clients":[{"id":"u1","email":"keep@x","flow":"` + vision + `","subId":"s1","enable":true}]}`,
+	}
+	if err := db.Create(sibling).Error; err != nil {
+		t.Fatalf("create sibling: %v", err)
+	}
+	keep, _ := ibSvc.GetClients(sibling)
+	if err := cs.SyncInbound(nil, sibling.Id, keep); err != nil {
+		t.Fatalf("sync sibling: %v", err)
+	}
+
+	// A client with no intended Vision anywhere — must NOT be touched.
+	other := &model.Inbound{
+		Tag: "oth", Enable: true, Port: 51002, Protocol: model.VLESS, StreamSettings: realityStream,
+		Settings: `{"clients":[{"id":"u2","email":"none@x","subId":"s2","enable":true}]}`,
+	}
+	if err := db.Create(other).Error; err != nil {
+		t.Fatalf("create other: %v", err)
+	}
+	oc, _ := ibSvc.GetClients(other)
+	if err := cs.SyncInbound(nil, other.Id, oc); err != nil {
+		t.Fatalf("sync other: %v", err)
+	}
+
+	// The now-eligible XHTTP inbound: keep@x has empty flow (was stripped),
+	// none@x has empty flow (no Vision anywhere), set@x has an explicit empty
+	// stays empty unless intended Vision.
+	target := `{` + encSettings + `,"clients":[` +
+		`{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true},` +
+		`{"id":"u2","email":"none@x","flow":"","subId":"s2","enable":true}` +
+		`]}`
+
+	out, changed := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VLESS)
+	if !changed {
+		t.Fatal("expected changed=true")
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal([]byte(out), &parsed); err != nil {
+		t.Fatalf("parse out: %v", err)
+	}
+	flows := map[string]string{}
+	for _, c := range parsed["clients"].([]any) {
+		cm := c.(map[string]any)
+		flows[cm["email"].(string)], _ = cm["flow"].(string)
+	}
+	if flows["keep@x"] != vision {
+		t.Errorf("keep@x flow = %q, want Vision (intended on sibling)", flows["keep@x"])
+	}
+	if flows["none@x"] != "" {
+		t.Errorf("none@x flow = %q, want empty (no Vision intent)", flows["none@x"])
+	}
+
+	// Ineligible inbound (xhttp without encryption) must be a no-op.
+	noenc := `{"clients":[{"id":"u1","email":"keep@x","flow":"","subId":"s1","enable":true}]}`
+	if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, noenc, `{"network":"xhttp","security":"reality"}`, model.VLESS); ch {
+		t.Error("ineligible xhttp (no vlessenc) must not change")
+	}
+	// Non-VLESS must be a no-op.
+	if _, ch := ibSvc.restoreVisionFlowForEligibleInbound(nil, target, xhttpEnc, model.VMESS); ch {
+		t.Error("non-VLESS must not change")
+	}
+}

+ 41 - 0
internal/web/service/inbound_migration.go

@@ -253,4 +253,45 @@ func (s *InboundService) MigrationRequirements() {
 func (s *InboundService) MigrateDB() {
 func (s *InboundService) MigrateDB() {
 	s.MigrationRequirements()
 	s.MigrationRequirements()
 	s.MigrationRemoveOrphanedTraffics()
 	s.MigrationRemoveOrphanedTraffics()
+	s.MigrationRestoreVisionFlow()
+}
+
+// MigrationRestoreVisionFlow repairs VLESS inbounds whose clients lost their
+// XTLS Vision flow because the inbound was not flow-eligible when the client was
+// written (e.g. an XHTTP inbound whose vlessenc encryption was enabled only
+// later). For each now-eligible inbound it restores flow=xtls-rprx-vision on
+// clients whose intended flow (their flow_override on a sibling inbound) is
+// Vision. Idempotent: once a client carries the flow it is skipped, so this is a
+// no-op on healthy installs and on subsequent boots.
+func (s *InboundService) MigrationRestoreVisionFlow() {
+	db := database.GetDB()
+	var inbounds []*model.Inbound
+	if err := db.Model(&model.Inbound{}).
+		Where("protocol = ?", model.VLESS).
+		Find(&inbounds).Error; err != nil {
+		logger.Warning("MigrationRestoreVisionFlow: load inbounds failed:", err)
+		return
+	}
+	for _, ib := range inbounds {
+		restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
+		if !changed {
+			continue
+		}
+		clients, err := s.GetClients(&model.Inbound{Settings: restored})
+		if err != nil {
+			logger.Warning("MigrationRestoreVisionFlow: parse clients for inbound", ib.Id, "failed:", err)
+			continue
+		}
+		err = db.Transaction(func(tx *gorm.DB) error {
+			if e := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", restored).Error; e != nil {
+				return e
+			}
+			return s.clientService.SyncInbound(tx, ib.Id, clients)
+		})
+		if err != nil {
+			logger.Warning("MigrationRestoreVisionFlow: update inbound", ib.Id, "failed:", err)
+			continue
+		}
+		logger.Info("MigrationRestoreVisionFlow: restored XTLS Vision flow on inbound", ib.Id)
+	}
 }
 }

+ 65 - 53
internal/web/service/server.go

@@ -375,6 +375,53 @@ func getPublicIP(url string) string {
 	return ipString
 	return ipString
 }
 }
 
 
+var publicIPv4Services = []string{
+	"https://api4.ipify.org",
+	"https://ipv4.icanhazip.com",
+	"https://v4.api.ipinfo.io/ip",
+	"https://ipv4.myexternalip.com/raw",
+	"https://4.ident.me",
+	"https://check-host.net/ip",
+}
+
+var publicIPv6Services = []string{
+	"https://api6.ipify.org",
+	"https://ipv6.icanhazip.com",
+	"https://v6.api.ipinfo.io/ip",
+	"https://ipv6.myexternalip.com/raw",
+	"https://6.ident.me",
+}
+
+// resolvePublicIPs caches the public IPv4/IPv6 addresses on first use. Guarded
+// by s.mu because the bot's ServerService may call it from sendBackup while a
+// status report runs concurrently.
+func (s *ServerService) resolvePublicIPs() {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.cachedIPv4 == "" {
+		for _, ip4Service := range publicIPv4Services {
+			s.cachedIPv4 = getPublicIP(ip4Service)
+			if s.cachedIPv4 != "N/A" {
+				break
+			}
+		}
+	}
+
+	if s.cachedIPv6 == "" && !s.noIPv6 {
+		for _, ip6Service := range publicIPv6Services {
+			s.cachedIPv6 = getPublicIP(ip6Service)
+			if s.cachedIPv6 != "N/A" {
+				break
+			}
+		}
+	}
+
+	if s.cachedIPv6 == "N/A" {
+		s.noIPv6 = true
+	}
+}
+
 func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 	now := time.Now()
 	now := time.Now()
 	status := &Status{
 	status := &Status{
@@ -536,45 +583,7 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
 		logger.Warning("get udp connections failed:", err)
 		logger.Warning("get udp connections failed:", err)
 	}
 	}
 
 
-	// IP fetching with caching
-	showIp4ServiceLists := []string{
-		"https://api4.ipify.org",
-		"https://ipv4.icanhazip.com",
-		"https://v4.api.ipinfo.io/ip",
-		"https://ipv4.myexternalip.com/raw",
-		"https://4.ident.me",
-		"https://check-host.net/ip",
-	}
-	showIp6ServiceLists := []string{
-		"https://api6.ipify.org",
-		"https://ipv6.icanhazip.com",
-		"https://v6.api.ipinfo.io/ip",
-		"https://ipv6.myexternalip.com/raw",
-		"https://6.ident.me",
-	}
-
-	if s.cachedIPv4 == "" {
-		for _, ip4Service := range showIp4ServiceLists {
-			s.cachedIPv4 = getPublicIP(ip4Service)
-			if s.cachedIPv4 != "N/A" {
-				break
-			}
-		}
-	}
-
-	if s.cachedIPv6 == "" && !s.noIPv6 {
-		for _, ip6Service := range showIp6ServiceLists {
-			s.cachedIPv6 = getPublicIP(ip6Service)
-			if s.cachedIPv6 != "N/A" {
-				break
-			}
-		}
-	}
-
-	if s.cachedIPv6 == "N/A" {
-		s.noIPv6 = true
-	}
-
+	s.resolvePublicIPs()
 	status.PublicIP.IPv4 = s.cachedIPv4
 	status.PublicIP.IPv4 = s.cachedIPv4
 	status.PublicIP.IPv6 = s.cachedIPv6
 	status.PublicIP.IPv6 = s.cachedIPv6
 
 
@@ -1282,11 +1291,12 @@ func (s *ServerService) GetDb() ([]byte, error) {
 
 
 // BackupFilename returns the filename for a database backup, named after the
 // BackupFilename returns the filename for a database backup, named after the
 // panel's address so a downloaded or Telegram-sent backup identifies the server
 // panel's address so a downloaded or Telegram-sent backup identifies the server
-// it came from. requestHost is the browser's address (the getDb handler passes
-// c.Request.Host, matching the host shown in the panel title); it is preferred
-// when present, otherwise the configured web domain and then the server's public
-// IP are used. The extension is .dump on PostgreSQL and .db on SQLite; the base
-// falls back to "x-ui" when no address is known.
+// it came from. requestHost is the browser's address: the getDb handler passes
+// c.Request.Host so a panel download is named after whatever address the user
+// reached the panel with, no Listen Domain needed. The Telegram bot has no
+// request and passes "", falling back to the configured Listen Domain (webDomain)
+// and then the public IP. The extension is .dump on PostgreSQL and .db on SQLite;
+// the base falls back to "x-ui" when no address is known.
 func (s *ServerService) BackupFilename(requestHost string) string {
 func (s *ServerService) BackupFilename(requestHost string) string {
 	ext := ".db"
 	ext := ".db"
 	if database.IsPostgres() {
 	if database.IsPostgres() {
@@ -1296,9 +1306,12 @@ func (s *ServerService) BackupFilename(requestHost string) string {
 }
 }
 
 
 // backupHost picks the address used to name backup files: the browser's request
 // backupHost picks the address used to name backup files: the browser's request
-// host (port stripped, the same value the panel title shows) when available,
-// otherwise the configured web domain and then the cached public IP (IPv4 before
-// IPv6), reduced to safe filename characters.
+// host (port stripped) when available, otherwise the configured Listen Domain
+// (webDomain) and then the resolved public IP (IPv4 before IPv6), reduced to safe
+// filename characters. The public IP is resolved directly rather than read from
+// LastStatus so callers whose ServerService never runs the status ticker —
+// notably the Telegram bot — still get a real address instead of the "x-ui"
+// fallback.
 func (s *ServerService) backupHost(requestHost string) string {
 func (s *ServerService) backupHost(requestHost string) string {
 	host := extractHostname(strings.TrimSpace(requestHost))
 	host := extractHostname(strings.TrimSpace(requestHost))
 	if host == "" {
 	if host == "" {
@@ -1307,12 +1320,11 @@ func (s *ServerService) backupHost(requestHost string) string {
 		}
 		}
 	}
 	}
 	if host == "" {
 	if host == "" {
-		if st := s.LastStatus(); st != nil {
-			if ip := st.PublicIP.IPv4; ip != "" && ip != "N/A" {
-				host = ip
-			} else if ip := st.PublicIP.IPv6; ip != "" && ip != "N/A" {
-				host = ip
-			}
+		s.resolvePublicIPs()
+		if ip := s.cachedIPv4; ip != "" && ip != "N/A" {
+			host = ip
+		} else if ip := s.cachedIPv6; ip != "" && ip != "N/A" {
+			host = ip
 		}
 		}
 	}
 	}
 	return sanitizeBackupHost(host)
 	return sanitizeBackupHost(host)

+ 11 - 1
internal/web/service/setting.go

@@ -55,7 +55,7 @@ var defaultValueMap = map[string]string{
 	"pageSize":                    "25",
 	"pageSize":                    "25",
 	"expireDiff":                  "0",
 	"expireDiff":                  "0",
 	"trafficDiff":                 "0",
 	"trafficDiff":                 "0",
-	"remarkTemplate":              "{{INBOUND}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
+	"remarkTemplate":              "{{INBOUND}}-{{EMAIL}}|📊{{TRAFFIC_LEFT}}|⏳{{DAYS_LEFT}}D",
 	"timeLocation":                "Local",
 	"timeLocation":                "Local",
 	"tgBotEnable":                 "false",
 	"tgBotEnable":                 "false",
 	"tgBotToken":                  "",
 	"tgBotToken":                  "",
@@ -78,6 +78,8 @@ var defaultValueMap = map[string]string{
 	"subEnableRouting":            "false",
 	"subEnableRouting":            "false",
 	"subRoutingRules":             "",
 	"subRoutingRules":             "",
 	"subHideSettings":             "false",
 	"subHideSettings":             "false",
+	"subIncyEnableRouting":        "false",
+	"subIncyRoutingRules":         "",
 	"subListen":                   "",
 	"subListen":                   "",
 	"subPort":                     "2096",
 	"subPort":                     "2096",
 	"subPath":                     "/sub/",
 	"subPath":                     "/sub/",
@@ -709,6 +711,14 @@ func (s *SettingService) GetSubHideSettings() (bool, error) {
 	return s.getBool("subHideSettings")
 	return s.getBool("subHideSettings")
 }
 }
 
 
+func (s *SettingService) GetSubIncyEnableRouting() (bool, error) {
+	return s.getBool("subIncyEnableRouting")
+}
+
+func (s *SettingService) GetSubIncyRoutingRules() (string, error) {
+	return s.getString("subIncyRoutingRules")
+}
+
 func (s *SettingService) GetSubListen() (string, error) {
 func (s *SettingService) GetSubListen() (string, error) {
 	return s.getString("subListen")
 	return s.getString("subListen")
 }
 }

+ 1 - 1
internal/web/service/sync_scale_postgres_test.go

@@ -346,7 +346,7 @@ func TestBulkOpsPostgresScale(t *testing.T) {
 			}
 			}
 
 
 			t0 := time.Now()
 			t0 := time.Now()
-			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30); err != nil {
+			if _, _, err := svc.BulkAdjust(inboundSvc, emailsM, 7, 1<<30, ""); err != nil {
 				t.Fatalf("BulkAdjust: %v", err)
 				t.Fatalf("BulkAdjust: %v", err)
 			}
 			}
 			adjustDur := time.Since(t0)
 			adjustDur := time.Since(t0)

+ 4 - 0
internal/web/translation/ar-EG.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
       "subRoutingRulesDesc": "قواعد التوجيه العامة لعميل VPN. (فقط لـ Happ)",
       "subHideSettings": "إخفاء إعدادات الخادم",
       "subHideSettings": "إخفاء إعدادات الخادم",
       "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
       "subHideSettingsDesc": "إخفاء إمكانية عرض وتعديل إعدادات الخادم في عميل VPN. (فقط لـ Happ)",
+      "subIncyEnableRouting": "تفعيل التوجيه",
+      "subIncyEnableRoutingDesc": "حقن ملف تعريف التوجيه في محتوى الاشتراك لعميل Incy. (فقط لـ Incy)",
+      "subIncyRoutingRules": "قواعد التوجيه",
+      "subIncyRoutingRulesDesc": "رابط توجيه Incy المُضاف إلى محتوى الاشتراك، مثل incy://routing/onadd/<base64>. (فقط لـ Incy)",
       "subClashEnableRouting": "تفعيل التوجيه",
       "subClashEnableRouting": "تفعيل التوجيه",
       "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
       "subClashEnableRoutingDesc": "تضمين قواعد توجيه Clash/Mihomo العامة في اشتراكات YAML المُنشأة.",
       "subClashRoutingRules": "قواعد التوجيه العامة",
       "subClashRoutingRules": "قواعد التوجيه العامة",

+ 8 - 1
internal/web/translation/en-US.json

@@ -831,9 +831,12 @@
       "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
       "bulkDeleteConfirmContent": "Each selected client is removed from every attached inbound and its traffic record is dropped. This cannot be undone.",
       "bulkAdjustTitle": "Adjust {count} clients",
       "bulkAdjustTitle": "Adjust {count} clients",
       "bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.",
       "bulkAdjustHint": "Positive values extend, negative values reduce. Clients with unlimited expiry or traffic are skipped for that field.",
-      "bulkAdjustNothing": "Set days or traffic before applying.",
+      "bulkAdjustNothing": "Set days, traffic, or flow before applying.",
       "addDays": "Add days",
       "addDays": "Add days",
       "addTrafficGB": "Add traffic (GB)",
       "addTrafficGB": "Add traffic (GB)",
+      "bulkFlow": "Set flow",
+      "bulkFlowNoChange": "No change",
+      "bulkFlowDisable": "Disable (clear flow)",
       "delDepleted": "Delete depleted",
       "delDepleted": "Delete depleted",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
       "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
@@ -1240,6 +1243,10 @@
       "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
       "subRoutingRulesDesc": "Global routing rules for the VPN client. (Only for Happ)",
       "subHideSettings": "Hide server settings",
       "subHideSettings": "Hide server settings",
       "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
       "subHideSettingsDesc": "Hide the ability to view and edit server configurations in the VPN client. (Only for Happ)",
+      "subIncyEnableRouting": "Enable routing",
+      "subIncyEnableRoutingDesc": "Inject a routing profile into the subscription body for the Incy client. (Only for Incy)",
+      "subIncyRoutingRules": "Routing rules",
+      "subIncyRoutingRulesDesc": "Incy routing deep-link added to the subscription body, e.g. incy://routing/onadd/<base64>. (Only for Incy)",
       "subClashEnableRouting": "Enable routing",
       "subClashEnableRouting": "Enable routing",
       "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
       "subClashEnableRoutingDesc": "Include global Clash/Mihomo routing rules in generated YAML subscriptions.",
       "subClashRoutingRules": "Global routing rules",
       "subClashRoutingRules": "Global routing rules",

+ 4 - 0
internal/web/translation/es-ES.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
       "subRoutingRulesDesc": "Reglas de enrutamiento globales para el cliente VPN. (Solo para Happ)",
       "subHideSettings": "Ocultar configuración del servidor",
       "subHideSettings": "Ocultar configuración del servidor",
       "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
       "subHideSettingsDesc": "Ocultar la posibilidad de ver y editar las configuraciones del servidor en el cliente VPN. (Solo para Happ)",
+      "subIncyEnableRouting": "Habilitar enrutamiento",
+      "subIncyEnableRoutingDesc": "Inyectar un perfil de enrutamiento en el cuerpo de la suscripción para el cliente Incy. (Solo para Incy)",
+      "subIncyRoutingRules": "Reglas de enrutamiento",
+      "subIncyRoutingRulesDesc": "Enlace de enrutamiento de Incy añadido al cuerpo de la suscripción, p. ej. incy://routing/onadd/<base64>. (Solo para Incy)",
       "subClashEnableRouting": "Habilitar enrutamiento",
       "subClashEnableRouting": "Habilitar enrutamiento",
       "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
       "subClashEnableRoutingDesc": "Incluir reglas globales de enrutamiento Clash/Mihomo en las suscripciones YAML generadas.",
       "subClashRoutingRules": "Reglas globales de enrutamiento",
       "subClashRoutingRules": "Reglas globales de enrutamiento",

+ 4 - 0
internal/web/translation/fa-IR.json

@@ -1132,6 +1132,10 @@
       "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
       "subRoutingRulesDesc": "قوانین مسیریابی سراسری برای کلاینت VPN. (فقط برای Happ)",
       "subHideSettings": "پنهان کردن تنظیمات سرور",
       "subHideSettings": "پنهان کردن تنظیمات سرور",
       "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
       "subHideSettingsDesc": "پنهان کردن توانایی مشاهده و ویرایش پیکربندی سرور در کلاینت VPN. (فقط برای Happ)",
+      "subIncyEnableRouting": "فعال‌سازی مسیریابی",
+      "subIncyEnableRoutingDesc": "تزریق پروفایل مسیریابی به بدنه اشتراک برای کلاینت Incy. (فقط برای Incy)",
+      "subIncyRoutingRules": "قوانین مسیریابی",
+      "subIncyRoutingRulesDesc": "لینک مسیریابی Incy که به بدنه اشتراک افزوده می‌شود، مثلاً incy://routing/onadd/<base64>. (فقط برای Incy)",
       "subClashEnableRouting": "فعال‌سازی مسیریابی",
       "subClashEnableRouting": "فعال‌سازی مسیریابی",
       "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
       "subClashEnableRoutingDesc": "قوانین مسیریابی سراسری Clash/Mihomo را در اشتراک‌های YAML تولیدشده وارد کن.",
       "subClashRoutingRules": "قوانین مسیریابی سراسری",
       "subClashRoutingRules": "قوانین مسیریابی سراسری",

+ 4 - 0
internal/web/translation/id-ID.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
       "subRoutingRulesDesc": "Aturan routing global untuk klien VPN. (Hanya untuk Happ)",
       "subHideSettings": "Sembunyikan pengaturan server",
       "subHideSettings": "Sembunyikan pengaturan server",
       "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
       "subHideSettingsDesc": "Menyembunyikan kemampuan untuk melihat dan mengedit konfigurasi server di klien VPN. (Hanya untuk Happ)",
+      "subIncyEnableRouting": "Aktifkan perutean",
+      "subIncyEnableRoutingDesc": "Menyuntikkan profil perutean ke dalam body langganan untuk klien Incy. (Hanya untuk Incy)",
+      "subIncyRoutingRules": "Aturan routing",
+      "subIncyRoutingRulesDesc": "Tautan perutean Incy yang ditambahkan ke body langganan, mis. incy://routing/onadd/<base64>. (Hanya untuk Incy)",
       "subClashEnableRouting": "Aktifkan routing",
       "subClashEnableRouting": "Aktifkan routing",
       "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
       "subClashEnableRoutingDesc": "Sertakan aturan routing global Clash/Mihomo dalam langganan YAML yang dibuat.",
       "subClashRoutingRules": "Aturan routing global",
       "subClashRoutingRules": "Aturan routing global",

+ 4 - 0
internal/web/translation/ja-JP.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
       "subRoutingRulesDesc": "VPNクライアントのグローバルルーティングルール。(Happのみ)",
       "subHideSettings": "サーバー設定を非表示",
       "subHideSettings": "サーバー設定を非表示",
       "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
       "subHideSettingsDesc": "VPNクライアントでサーバー設定の表示・編集機能を非表示にします。(Happのみ)",
+      "subIncyEnableRouting": "ルーティングを有効化",
+      "subIncyEnableRoutingDesc": "Incyクライアント用に、サブスクリプション本文へルーティングプロファイルを挿入します。(Incyのみ)",
+      "subIncyRoutingRules": "ルーティングルール",
+      "subIncyRoutingRulesDesc": "サブスクリプション本文に追加するIncyルーティングのディープリンク。例: incy://routing/onadd/<base64>。(Incyのみ)",
       "subClashEnableRouting": "ルーティングを有効化",
       "subClashEnableRouting": "ルーティングを有効化",
       "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
       "subClashEnableRoutingDesc": "生成されたYAMLサブスクリプションにClash/Mihomoのグローバルルーティングルールを含めます。",
       "subClashRoutingRules": "グローバルルーティングルール",
       "subClashRoutingRules": "グローバルルーティングルール",

+ 4 - 0
internal/web/translation/pt-BR.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
       "subRoutingRulesDesc": "Regras de roteamento globais para o cliente VPN. (Apenas para Happ)",
       "subHideSettings": "Ocultar configurações do servidor",
       "subHideSettings": "Ocultar configurações do servidor",
       "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
       "subHideSettingsDesc": "Ocultar a capacidade de visualizar e editar as configurações do servidor no cliente VPN. (Apenas para Happ)",
+      "subIncyEnableRouting": "Ativar roteamento",
+      "subIncyEnableRoutingDesc": "Injetar um perfil de roteamento no corpo da assinatura para o cliente Incy. (Apenas para Incy)",
+      "subIncyRoutingRules": "Regras de roteamento",
+      "subIncyRoutingRulesDesc": "Link de roteamento do Incy adicionado ao corpo da assinatura, ex. incy://routing/onadd/<base64>. (Apenas para Incy)",
       "subClashEnableRouting": "Ativar roteamento",
       "subClashEnableRouting": "Ativar roteamento",
       "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
       "subClashEnableRoutingDesc": "Incluir regras globais de roteamento Clash/Mihomo nas assinaturas YAML geradas.",
       "subClashRoutingRules": "Regras globais de roteamento",
       "subClashRoutingRules": "Regras globais de roteamento",

+ 4 - 0
internal/web/translation/ru-RU.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
       "subRoutingRulesDesc": "Глобальные правила маршрутизации для VPN-клиента. (Только для Happ)",
       "subHideSettings": "Скрыть настройки сервера",
       "subHideSettings": "Скрыть настройки сервера",
       "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
       "subHideSettingsDesc": "Скрыть возможность просмотра и редактирования конфигурации сервера в VPN-клиенте. (Только для Happ)",
+      "subIncyEnableRouting": "Включить маршрутизацию",
+      "subIncyEnableRoutingDesc": "Внедрять профиль маршрутизации в тело подписки для клиента Incy. (Только для Incy)",
+      "subIncyRoutingRules": "Правила маршрутизации",
+      "subIncyRoutingRulesDesc": "Ссылка маршрутизации Incy, добавляемая в тело подписки, напр. incy://routing/onadd/<base64>. (Только для Incy)",
       "subClashEnableRouting": "Включить маршрутизацию",
       "subClashEnableRouting": "Включить маршрутизацию",
       "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
       "subClashEnableRoutingDesc": "Добавлять глобальные правила маршрутизации Clash/Mihomo в сгенерированные YAML-подписки.",
       "subClashRoutingRules": "Глобальные правила маршрутизации",
       "subClashRoutingRules": "Глобальные правила маршрутизации",

+ 4 - 0
internal/web/translation/tr-TR.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
       "subRoutingRulesDesc": "VPN istemcisi için genel yönlendirme kuralları. (Yalnızca Happ için)",
       "subHideSettings": "Sunucu ayarlarını gizle",
       "subHideSettings": "Sunucu ayarlarını gizle",
       "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
       "subHideSettingsDesc": "VPN istemcisinde sunucu yapılandırmalarını görüntüleme ve düzenleme özelliğini gizleyin. (Yalnızca Happ için)",
+      "subIncyEnableRouting": "Yönlendirmeyi etkinleştir",
+      "subIncyEnableRoutingDesc": "Incy istemcisi için abonelik gövdesine bir yönlendirme profili ekleyin. (Yalnızca Incy için)",
+      "subIncyRoutingRules": "Yönlendirme kuralları",
+      "subIncyRoutingRulesDesc": "Abonelik gövdesine eklenen Incy yönlendirme bağlantısı, örn. incy://routing/onadd/<base64>. (Yalnızca Incy için)",
       "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
       "subClashEnableRouting": "Yönlendirmeyi Etkinleştir",
       "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
       "subClashEnableRoutingDesc": "Oluşturulan YAML aboneliklerine genel Clash/Mihomo yönlendirme kurallarını ekler.",
       "subClashRoutingRules": "Genel Yönlendirme Kuralları",
       "subClashRoutingRules": "Genel Yönlendirme Kuralları",

+ 4 - 0
internal/web/translation/uk-UA.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
       "subRoutingRulesDesc": "Глобальні правила маршрутизації для VPN-клієнта. (Тільки для Happ)",
       "subHideSettings": "Приховати налаштування сервера",
       "subHideSettings": "Приховати налаштування сервера",
       "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
       "subHideSettingsDesc": "Приховати можливість перегляду та редагування конфігурації сервера у VPN-клієнті. (Тільки для Happ)",
+      "subIncyEnableRouting": "Увімкнути маршрутизацію",
+      "subIncyEnableRoutingDesc": "Вставляти профіль маршрутизації в тіло підписки для клієнта Incy. (Тільки для Incy)",
+      "subIncyRoutingRules": "Правила маршрутизації",
+      "subIncyRoutingRulesDesc": "Посилання маршрутизації Incy, що додається в тіло підписки, напр. incy://routing/onadd/<base64>. (Тільки для Incy)",
       "subClashEnableRouting": "Увімкнути маршрутизацію",
       "subClashEnableRouting": "Увімкнути маршрутизацію",
       "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
       "subClashEnableRoutingDesc": "Додавати глобальні правила маршрутизації Clash/Mihomo до згенерованих YAML-підписок.",
       "subClashRoutingRules": "Глобальні правила маршрутизації",
       "subClashRoutingRules": "Глобальні правила маршрутизації",

+ 4 - 0
internal/web/translation/vi-VN.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
       "subRoutingRulesDesc": "Quy tắc định tuyến toàn cầu cho client VPN. (Chỉ dành cho Happ)",
       "subHideSettings": "Ẩn cài đặt máy chủ",
       "subHideSettings": "Ẩn cài đặt máy chủ",
       "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
       "subHideSettingsDesc": "Ẩn khả năng xem và chỉnh sửa cấu hình máy chủ trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
+      "subIncyEnableRouting": "Bật định tuyến",
+      "subIncyEnableRoutingDesc": "Chèn hồ sơ định tuyến vào nội dung đăng ký cho ứng dụng Incy. (Chỉ dành cho Incy)",
+      "subIncyRoutingRules": "Quy tắc định tuyến",
+      "subIncyRoutingRulesDesc": "Liên kết định tuyến Incy được thêm vào nội dung đăng ký, ví dụ incy://routing/onadd/<base64>. (Chỉ dành cho Incy)",
       "subClashEnableRouting": "Bật định tuyến",
       "subClashEnableRouting": "Bật định tuyến",
       "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
       "subClashEnableRoutingDesc": "Bao gồm quy tắc định tuyến Clash/Mihomo toàn cầu trong các đăng ký YAML được tạo.",
       "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",
       "subClashRoutingRules": "Quy tắc định tuyến toàn cầu",

+ 4 - 0
internal/web/translation/zh-CN.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
       "subRoutingRulesDesc": "VPN 用户端的全域路由规则。(仅限 Happ)",
       "subHideSettings": "隐藏服务器设置",
       "subHideSettings": "隐藏服务器设置",
       "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
       "subHideSettingsDesc": "在 VPN 客户端中隐藏查看和编辑服务器配置的功能。(仅限 Happ)",
+      "subIncyEnableRouting": "启用路由",
+      "subIncyEnableRoutingDesc": "为 Incy 客户端将路由配置注入订阅内容中。(仅限 Incy)",
+      "subIncyRoutingRules": "路由规则",
+      "subIncyRoutingRulesDesc": "添加到订阅内容的 Incy 路由深层链接,例如 incy://routing/onadd/<base64>。(仅限 Incy)",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRouting": "启用路由",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashEnableRoutingDesc": "在生成的 YAML 订阅中包含 Clash/Mihomo 全局路由规则。",
       "subClashRoutingRules": "全局路由规则",
       "subClashRoutingRules": "全局路由规则",

+ 4 - 0
internal/web/translation/zh-TW.json

@@ -1130,6 +1130,10 @@
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
       "subRoutingRulesDesc": "VPN 用戶端的全域路由規則。(僅限 Happ)",
       "subHideSettings": "隱藏伺服器設定",
       "subHideSettings": "隱藏伺服器設定",
       "subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
       "subHideSettingsDesc": "在 VPN 用戶端中隱藏查看和編輯伺服器配置的功能。(僅限 Happ)",
+      "subIncyEnableRouting": "啟用路由",
+      "subIncyEnableRoutingDesc": "為 Incy 用戶端將路由設定檔注入訂閱內容中。(僅限 Incy)",
+      "subIncyRoutingRules": "路由規則",
+      "subIncyRoutingRulesDesc": "加入訂閱內容的 Incy 路由深層連結,例如 incy://routing/onadd/<base64>。(僅限 Incy)",
       "subClashEnableRouting": "啟用路由",
       "subClashEnableRouting": "啟用路由",
       "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
       "subClashEnableRoutingDesc": "在產生的 YAML 訂閱中包含 Clash/Mihomo 全域路由規則。",
       "subClashRoutingRules": "全域路由規則",
       "subClashRoutingRules": "全域路由規則",

+ 22 - 0
internal/web/web.go

@@ -619,6 +619,28 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		return nil
 		return nil
 	})
 	})
 
 
+	controller.SetReloadTgbotFunc(func() {
+		enabled, err := s.settingService.GetTgbotEnabled()
+		if err != nil || !enabled {
+			if s.tgbotService.IsRunning() {
+				s.tgbotService.Stop()
+			}
+			if s.bus != nil {
+				s.bus.Unsubscribe("tg-notifier")
+			}
+			return
+		}
+		// Start() stops any previous receiver first, so it is safe whether or not the bot is already running.
+		tgBot := s.tgbotService.NewTgbot()
+		if startErr := tgBot.Start(i18nFS); startErr != nil {
+			logger.Warning("reload Telegram bot failed:", startErr)
+			return
+		}
+		if s.bus != nil {
+			s.bus.Subscribe("tg-notifier", s.tgbotService.HandleEvent)
+		}
+	})
+
 	s.startTask(restartXray)
 	s.startTask(restartXray)
 
 
 	if startTgBot {
 	if startTgBot {