1
0

5 Коммиты 3092326d9e ... 57e9661758

Автор SHA1 Сообщение Дата
  Rouzbeh† 57e9661758 fix: properly configure fail2ban backend and dependencies on Ubuntu 22.04+ (#5159) (#5184) 1 день назад
  Rouzbeh† 65fa40b819 fix: accurately retrieve and generate API tokens via CLI with hashed storage (#5145) (#5183) 1 день назад
  MHSanaei f88f53cd7b fix(update): restart panel after regenerating webBasePath to fix login desync 1 день назад
  MHSanaei ca4f32e3da feat: replace panel proxy URL with outbound-based egress bridge 1 день назад
  MHSanaei 6b16d8c37a feat: apply inbound/outbound/routing changes live via Xray gRPC API 1 день назад
48 измененных файлов с 2590 добавлено и 186 удалено
  1. 126 6
      frontend/public/openapi.json
  2. 2 2
      frontend/src/generated/examples.ts
  3. 6 6
      frontend/src/generated/schemas.ts
  4. 2 2
      frontend/src/generated/types.ts
  5. 2 2
      frontend/src/generated/zod.ts
  6. 2 22
      frontend/src/hooks/useXraySetting.ts
  7. 1 1
      frontend/src/models/setting.ts
  8. 34 0
      frontend/src/pages/api-docs/endpoints.ts
  9. 5 1
      frontend/src/pages/index/GeodataSection.tsx
  10. 42 5
      frontend/src/pages/settings/GeneralTab.tsx
  11. 0 29
      frontend/src/pages/xray/XrayPage.tsx
  12. 97 53
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  13. 59 0
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  14. 146 0
      frontend/src/pages/xray/routing/RouteTester.tsx
  15. 7 1
      frontend/src/pages/xray/routing/RoutingTab.tsx
  16. 1 1
      frontend/src/schemas/setting.ts
  17. 60 0
      frontend/src/test/balancer-observatory-sync.test.ts
  18. 11 0
      internal/web/controller/setting.go
  19. 86 1
      internal/web/controller/xray_setting.go
  20. 1 1
      internal/web/entity/entity.go
  21. 2 1
      internal/web/service/config.json
  22. 1 1
      internal/web/service/integration/panel_egress_test.go
  23. 39 13
      internal/web/service/setting.go
  24. 5 6
      internal/web/service/tgbot/tgbot.go
  25. 302 2
      internal/web/service/xray.go
  26. 159 0
      internal/web/service/xray_config_inject_test.go
  27. 18 2
      internal/web/translation/ar-EG.json
  28. 18 2
      internal/web/translation/en-US.json
  29. 18 2
      internal/web/translation/es-ES.json
  30. 18 2
      internal/web/translation/fa-IR.json
  31. 18 2
      internal/web/translation/id-ID.json
  32. 18 2
      internal/web/translation/ja-JP.json
  33. 18 2
      internal/web/translation/pt-BR.json
  34. 18 2
      internal/web/translation/ru-RU.json
  35. 18 2
      internal/web/translation/tr-TR.json
  36. 18 2
      internal/web/translation/uk-UA.json
  37. 18 2
      internal/web/translation/vi-VN.json
  38. 18 2
      internal/web/translation/zh-CN.json
  39. 18 2
      internal/web/translation/zh-TW.json
  40. 250 0
      internal/xray/api.go
  41. 240 0
      internal/xray/api_e2e_test.go
  42. 6 0
      internal/xray/config.go
  43. 361 0
      internal/xray/hot_diff.go
  44. 265 0
      internal/xray/hot_diff_test.go
  45. 7 0
      internal/xray/process.go
  46. 18 1
      main.go
  47. 8 0
      update.sh
  48. 3 3
      x-ui.sh

+ 126 - 6
frontend/public/openapi.json

@@ -120,8 +120,8 @@
             "minimum": 0,
             "type": "integer"
           },
-          "panelProxy": {
-            "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+          "panelOutbound": {
+            "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
           "remarkModel": {
@@ -383,7 +383,7 @@
           "ldapUserFilter",
           "ldapVlessField",
           "pageSize",
-          "panelProxy",
+          "panelOutbound",
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -554,8 +554,8 @@
             "minimum": 0,
             "type": "integer"
           },
-          "panelProxy": {
-            "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+          "panelOutbound": {
+            "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
             "type": "string"
           },
           "remarkModel": {
@@ -823,7 +823,7 @@
           "ldapUserFilter",
           "ldapVlessField",
           "pageSize",
-          "panelProxy",
+          "panelOutbound",
           "remarkModel",
           "restartXrayOnClientDisable",
           "sessionMaxAge",
@@ -7364,6 +7364,126 @@
         }
       }
     },
+    "/panel/api/xray/balancerStatus": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Live state of routing balancers in the running core (RoutingService.GetBalancerInfo): current override and the targets the strategy prefers. Returns a map keyed by balancer tag.",
+        "operationId": "post_panel_api_xray_balancerStatus",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/balancerOverride": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.",
+        "operationId": "post_panel_api_xray_balancerOverride",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/xray/routeTest": {
+      "post": {
+        "tags": [
+          "Xray Settings"
+        ],
+        "summary": "Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.",
+        "operationId": "post_panel_api_xray_routeTest",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/xray/outbound-subs": {
       "get": {
         "tags": [

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

@@ -26,7 +26,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserFilter": "",
     "ldapVlessField": "",
     "pageSize": 0,
-    "panelProxy": "",
+    "panelOutbound": "",
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,
@@ -115,7 +115,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "ldapUserFilter": "",
     "ldapVlessField": "",
     "pageSize": 0,
-    "panelProxy": "",
+    "panelOutbound": "",
     "remarkModel": "",
     "restartXrayOnClientDisable": false,
     "sessionMaxAge": 1,

+ 6 - 6
frontend/src/generated/schemas.ts

@@ -94,8 +94,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
-      "panelProxy": {
-        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+      "panelOutbound": {
+        "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
       "remarkModel": {
@@ -357,7 +357,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserFilter",
       "ldapVlessField",
       "pageSize",
-      "panelProxy",
+      "panelOutbound",
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",
@@ -528,8 +528,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "minimum": 0,
         "type": "integer"
       },
-      "panelProxy": {
-        "description": "Proxy URL for the panel's own outbound requests (GitHub/Telegram)",
+      "panelOutbound": {
+        "description": "Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)",
         "type": "string"
       },
       "remarkModel": {
@@ -797,7 +797,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "ldapUserFilter",
       "ldapVlessField",
       "pageSize",
-      "panelProxy",
+      "panelOutbound",
       "remarkModel",
       "restartXrayOnClientDisable",
       "sessionMaxAge",

+ 2 - 2
frontend/src/generated/types.ts

@@ -30,7 +30,7 @@ export interface AllSetting {
   ldapUserFilter: string;
   ldapVlessField: string;
   pageSize: number;
-  panelProxy: string;
+  panelOutbound: string;
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;
@@ -120,7 +120,7 @@ export interface AllSettingView {
   ldapUserFilter: string;
   ldapVlessField: string;
   pageSize: number;
-  panelProxy: string;
+  panelOutbound: string;
   remarkModel: string;
   restartXrayOnClientDisable: boolean;
   sessionMaxAge: number;

+ 2 - 2
frontend/src/generated/zod.ts

@@ -38,7 +38,7 @@ export const AllSettingSchema = z.object({
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
-  panelProxy: z.string(),
+  panelOutbound: z.string(),
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),
@@ -129,7 +129,7 @@ export const AllSettingViewSchema = z.object({
   ldapUserFilter: z.string(),
   ldapVlessField: z.string(),
   pageSize: z.number().int().min(0).max(1000),
-  panelProxy: z.string(),
+  panelOutbound: z.string(),
   remarkModel: z.string(),
   restartXrayOnClientDisable: z.boolean(),
   sessionMaxAge: z.number().int().min(1).max(525600),

+ 2 - 22
frontend/src/hooks/useXraySetting.ts

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 import { z } from 'zod';
 
-import { HttpUtil, Msg, PromiseUtil } from '@/utils';
+import { HttpUtil, Msg } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
 import {
@@ -53,7 +53,6 @@ export interface UseXraySettingResult {
   clientReverseTags: string[];
   subscriptionOutbounds: unknown[];
   subscriptionOutboundTags: string[];
-  restartResult: string;
   outboundsTraffic: OutboundTrafficRow[];
   outboundTestStates: Record<number, OutboundTestState>;
   subscriptionTestStates: Record<string, OutboundTestState>;
@@ -74,7 +73,6 @@ export interface UseXraySettingResult {
   testAllOutbounds: (mode?: string) => Promise<void>;
   saveAll: () => Promise<void>;
   resetToDefault: () => Promise<void>;
-  restartXray: () => Promise<void>;
 }
 
 type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
@@ -128,7 +126,6 @@ export function useXraySetting(): UseXraySettingResult {
   const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
   const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
   const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
-  const [restartResult, setRestartResult] = useState('');
   const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
   // Subscription outbounds aren't in templateSettings.outbounds, so their test
   // results are keyed by tag rather than by index.
@@ -238,18 +235,6 @@ export function useXraySetting(): UseXraySettingResult {
     },
   });
 
-  const restartMut = useMutation({
-    mutationFn: async () => {
-      const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
-      if (!msg?.success) return msg;
-      await PromiseUtil.sleep(500);
-      const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
-      const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
-      if (validated?.success) setRestartResult(validated.obj || '');
-      return msg;
-    },
-  });
-
   const resetDefaultMut = useMutation({
     mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
       const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
@@ -265,10 +250,9 @@ export function useXraySetting(): UseXraySettingResult {
 
   const saveAll = useCallback(async () => { await saveMut.mutateAsync(); }, [saveMut]);
   const resetOutboundsTraffic = useCallback(async (tag: string) => { await resetTrafficMut.mutateAsync(tag); }, [resetTrafficMut]);
-  const restartXray = useCallback(async () => { await restartMut.mutateAsync(); }, [restartMut]);
   const resetToDefault = useCallback(async () => { await resetDefaultMut.mutateAsync(); }, [resetDefaultMut]);
 
-  const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
+  const spinning = saveMut.isPending || resetDefaultMut.isPending;
 
   // Shared POST + parse for a single outbound test. Returns an OutboundTestResult
   // (success or a failure-shaped result); callers store it under their own key.
@@ -384,7 +368,6 @@ export function useXraySetting(): UseXraySettingResult {
       clientReverseTags,
       subscriptionOutbounds,
       subscriptionOutboundTags,
-      restartResult,
       outboundsTraffic,
       outboundTestStates,
       subscriptionTestStates,
@@ -397,7 +380,6 @@ export function useXraySetting(): UseXraySettingResult {
       testAllOutbounds,
       saveAll,
       resetToDefault,
-      restartXray,
     }),
     [
       fetched,
@@ -414,7 +396,6 @@ export function useXraySetting(): UseXraySettingResult {
       clientReverseTags,
       subscriptionOutbounds,
       subscriptionOutboundTags,
-      restartResult,
       outboundsTraffic,
       outboundTestStates,
       subscriptionTestStates,
@@ -427,7 +408,6 @@ export function useXraySetting(): UseXraySettingResult {
       testAllOutbounds,
       saveAll,
       resetToDefault,
-      restartXray,
     ],
   );
 }

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

@@ -9,7 +9,7 @@ export class AllSetting {
   webBasePath = '/';
   sessionMaxAge = 360;
   trustedProxyCIDRs = '127.0.0.1/32,::1/128';
-  panelProxy = '';
+  panelOutbound = '';
   pageSize = 25;
   expireDiff = 0;
   trafficDiff = 0;

+ 34 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -1045,6 +1045,40 @@ export const sections: readonly Section[] = [
         ],
         body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/balancerStatus',
+        summary: 'Live state of routing balancers in the running core (RoutingService.GetBalancerInfo): current override and the targets the strategy prefers. Returns a map keyed by balancer tag.',
+        params: [
+          { name: 'tags', in: 'body (form)', type: 'string', desc: 'Comma-separated balancer tags to query (e.g. "b1,b2").' },
+        ],
+        body: 'tags=b1,b2',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/balancerOverride',
+        summary: 'Force a balancer in the running core to always pick one outbound (RoutingService.OverrideBalancerTarget). Applied live without a restart; cleared automatically when Xray restarts.',
+        params: [
+          { name: 'tag', in: 'body (form)', type: 'string', desc: 'Balancer tag (required).' },
+          { name: 'target', in: 'body (form)', type: 'string', desc: 'Outbound tag to force. Empty clears the override and returns control to the strategy.' },
+        ],
+        body: 'tag=b1&target=proxy',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/xray/routeTest',
+        summary: 'Ask the running core which outbound its router would pick for a synthetic connection (RoutingService.TestRoute). No traffic is sent.',
+        params: [
+          { name: 'domain', in: 'body (form)', type: 'string', desc: 'Target domain. Either domain or ip is required.' },
+          { name: 'ip', in: 'body (form)', type: 'string', desc: 'Target IP. Either domain or ip is required.' },
+          { name: 'port', in: 'body (form)', type: 'number', desc: 'Target port (optional).' },
+          { name: 'network', in: 'body (form)', type: 'string', desc: '"tcp" (default) or "udp".' },
+          { name: 'inboundTag', in: 'body (form)', type: 'string', desc: 'Simulate arrival on this inbound (optional).' },
+          { name: 'protocol', in: 'body (form)', type: 'string', desc: 'Sniffed protocol such as http, tls, bittorrent (optional).' },
+          { name: 'email', in: 'body (form)', type: 'string', desc: 'User attribution for user-based rules (optional).' },
+        ],
+        body: 'domain=example.com&port=443&network=tcp',
+      },
       {
         method: 'GET',
         path: '/panel/api/xray/outbound-subs',

+ 5 - 1
frontend/src/pages/index/GeodataSection.tsx

@@ -65,10 +65,14 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
       );
 
       // Download outbound candidates: template outbounds + subscription outbounds.
+      // Skip blackhole outbounds — routing a download through one just drops it.
       const tags = new Set<string>();
       const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
       for (const o of outbounds) {
-        const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
+        if (!o || typeof o !== 'object') continue;
+        const rec = o as Record<string, unknown>;
+        if (rec.protocol === 'blackhole') continue;
+        const tag = rec.tag;
         if (typeof tag === 'string' && tag) tags.add(tag);
       }
       const subTags = Array.isArray(payload.subscriptionOutboundTags)

+ 42 - 5
frontend/src/pages/settings/GeneralTab.tsx

@@ -43,6 +43,7 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
 
   const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
   const [inboundOptions, setInboundOptions] = useState<{ label: string; value: string }[]>([]);
+  const [outboundOptions, setOutboundOptions] = useState<{ label: string; value: string }[]>([]);
 
   useEffect(() => {
     let cancelled = false;
@@ -65,6 +66,38 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
     return () => { cancelled = true; };
   }, []);
 
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      // Outbound tags for the panel egress picker: template outbounds plus
+      // subscription-derived outbounds, same candidate set as the geodata
+      // download picker.
+      const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true }) as ApiMsg<string>;
+      if (cancelled || !msg?.success || typeof msg.obj !== 'string') return;
+      try {
+        const payload = JSON.parse(msg.obj) as Record<string, unknown>;
+        const template = (payload.xraySetting || {}) as Record<string, unknown>;
+        const tags = new Set<string>();
+        const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
+        for (const o of outbounds) {
+          if (!o || typeof o !== 'object') continue;
+          const rec = o as Record<string, unknown>;
+          if (rec.protocol === 'blackhole') continue; // dropping traffic is never a useful egress
+          const tag = rec.tag;
+          if (typeof tag === 'string' && tag) tags.add(tag);
+        }
+        const subTags = Array.isArray(payload.subscriptionOutboundTags) ? payload.subscriptionOutboundTags : [];
+        for (const tag of subTags) {
+          if (typeof tag === 'string' && tag) tags.add(tag);
+        }
+        setOutboundOptions([...tags].map((tag) => ({ label: tag, value: tag })));
+      } catch {
+        setOutboundOptions([]);
+      }
+    })();
+    return () => { cancelled = true; };
+  }, []);
+
   const ldapInboundTagList = useMemo(() => {
     const csv = allSetting.ldapInboundTags || '';
     return csv.length ? csv.split(',').map((s) => s.trim()).filter(Boolean) : [];
@@ -133,11 +166,15 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
               />
             </SettingListItem>
 
-            <SettingListItem paddings="small" title={t('pages.settings.panelProxy')} description={t('pages.settings.panelProxyDesc')}>
-              <Input
-                value={allSetting.panelProxy}
-                placeholder="socks5:// or http://user:pass@host:port"
-                onChange={(e) => updateSetting({ panelProxy: e.target.value })}
+            <SettingListItem paddings="small" title={t('pages.settings.panelOutbound')} description={t('pages.settings.panelOutboundDesc')}>
+              <Select
+                style={{ width: '100%' }}
+                allowClear
+                showSearch
+                value={allSetting.panelOutbound || undefined}
+                placeholder={t('pages.settings.panelOutboundPh')}
+                options={outboundOptions}
+                onChange={(v) => updateSetting({ panelOutbound: (v as string | undefined) || '' })}
               />
             </SettingListItem>
 

+ 0 - 29
frontend/src/pages/xray/XrayPage.tsx

@@ -10,15 +10,12 @@ import {
   FloatButton,
   Layout,
   message,
-  Modal,
-  Popover,
   Radio,
   Result,
   Row,
   Space,
   Spin,
 } from 'antd';
-import { QuestionCircleOutlined } from '@ant-design/icons';
 
 import { useTheme } from '@/hooks/useTheme';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -63,7 +60,6 @@ export default function XrayPage() {
     clientReverseTags,
     subscriptionOutbounds,
     subscriptionOutboundTags,
-    restartResult,
     outboundsTraffic,
     outboundTestStates,
     subscriptionTestStates,
@@ -75,10 +71,8 @@ export default function XrayPage() {
     testAllOutbounds,
     saveAll,
     resetToDefault,
-    restartXray,
   } = xs;
 
-  const [modal, modalContextHolder] = Modal.useModal();
   const [warpOpen, setWarpOpen] = useState(false);
   const [nordOpen, setNordOpen] = useState(false);
   const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
@@ -187,16 +181,6 @@ export default function XrayPage() {
     });
   }
 
-  function confirmRestart() {
-    modal.confirm({
-      title: t('pages.xray.restartConfirmTitle'),
-      content: t('pages.xray.restartConfirmContent'),
-      okText: t('pages.xray.restart'),
-      cancelText: t('cancel'),
-      onOk: () => restartXray(),
-    });
-  }
-
   function onSaveAll() {
     try {
       JSON.parse(xraySetting);
@@ -306,7 +290,6 @@ export default function XrayPage() {
   return (
     <ConfigProvider theme={antdThemeConfig}>
       {messageContextHolder}
-      {modalContextHolder}
       <Layout className={pageClass}>
         <AppSidebar />
 
@@ -332,18 +315,6 @@ export default function XrayPage() {
                             <Button type="primary" disabled={saveDisabled} onClick={onSaveAll}>
                               {t('pages.xray.save')}
                             </Button>
-                            <Button type="primary" danger disabled={!saveDisabled} onClick={confirmRestart}>
-                              {t('pages.xray.restart')}
-                            </Button>
-                            {restartResult && (
-                              <Popover
-                                placement="rightTop"
-                                title={t('pages.xray.restartOutputTitle')}
-                                content={<pre className="restart-result">{restartResult}</pre>}
-                              >
-                                <QuestionCircleOutlined className="restart-icon" />
-                              </Popover>
-                            )}
                           </Space>
                         </Col>
                         <Col xs={24} sm={10} className="header-info">

+ 97 - 53
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -1,12 +1,14 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Button, Divider, Dropdown, Empty, Modal, Radio, Space, Table, Tag } from 'antd';
-import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
+import { Button, Divider, Dropdown, Empty, Modal, Radio, Select, Space, Table, Tag, Tooltip } from 'antd';
+import { PlusOutlined, MoreOutlined, EditOutlined, DeleteOutlined, SyncOutlined } from '@ant-design/icons';
 import type { ColumnsType } from 'antd/es/table';
 
 import BalancerFormModal from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
+import { syncObservatories } from './balancer-helpers';
 import { JsonEditor } from '@/components/form';
+import { HttpUtil } from '@/utils';
 import type { XraySettingsValue, SetTemplate } from '@/hooks/useXraySetting';
 import type {
   BalancerObject,
@@ -14,6 +16,15 @@ import type {
   BalancerStrategyType,
 } from '@/schemas/routing';
 
+// Live state of one balancer inside the running core, as reported by the
+// panel's /xray/balancerStatus endpoint (RoutingService.GetBalancerInfo).
+interface BalancerLiveStatus {
+  tag: string;
+  running: boolean;
+  override: string;
+  selected: string[];
+}
+
 interface BalancersTabProps {
   templateSettings: XraySettingsValue | null;
   setTemplateSettings: SetTemplate;
@@ -40,53 +51,6 @@ const STRATEGY_LABELS: Record<string, string> = {
   leastPing: 'Least ping',
 };
 
-const DEFAULT_OBSERVATORY = Object.freeze({
-  subjectSelector: [] as string[],
-  probeURL: 'https://www.google.com/generate_204',
-  probeInterval: '1m',
-  enableConcurrency: true,
-});
-
-const DEFAULT_BURST_OBSERVATORY = Object.freeze({
-  subjectSelector: [] as string[],
-  pingConfig: {
-    destination: 'https://www.google.com/generate_204',
-    interval: '1m',
-    connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
-    timeout: '5s',
-    sampling: 2,
-  },
-});
-
-function collectSelectors(list: BalancerRecord[]): string[] {
-  const out = new Set<string>();
-  list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
-  return [...out];
-}
-
-function syncObservatories(t: XraySettingsValue) {
-  const balancers = (t.routing?.balancers || []) as BalancerRecord[];
-
-  const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
-  if (leastPings.length > 0) {
-    if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
-    (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
-  } else {
-    delete t.observatory;
-  }
-
-  const burstFeeders = balancers.filter((b) => {
-    const type = b.strategy?.type || 'random';
-    return type === 'leastLoad' || type === 'random' || type === 'roundRobin';
-  });
-  if (burstFeeders.length > 0) {
-    if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
-    (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(burstFeeders);
-  } else {
-    delete t.burstObservatory;
-  }
-}
-
 export default function BalancersTab({
   templateSettings,
   setTemplateSettings,
@@ -143,6 +107,38 @@ export default function BalancersTab({
     [setTemplateSettings],
   );
 
+  const [liveStatus, setLiveStatus] = useState<Record<string, BalancerLiveStatus>>({});
+  const [liveLoading, setLiveLoading] = useState(false);
+  const liveTags = useMemo(
+    () => rows.map((r) => r.tag).filter(Boolean).join(','),
+    [rows],
+  );
+
+  const refreshLive = useCallback(async () => {
+    if (!liveTags) {
+      setLiveStatus({});
+      return;
+    }
+    setLiveLoading(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/balancerStatus', { tags: liveTags }, { silent: true });
+      if (msg?.success && msg.obj && typeof msg.obj === 'object') {
+        setLiveStatus(msg.obj as Record<string, BalancerLiveStatus>);
+      }
+    } finally {
+      setLiveLoading(false);
+    }
+  }, [liveTags]);
+
+  useEffect(() => {
+    refreshLive();
+  }, [refreshLive]);
+
+  async function setOverride(tag: string, target: string) {
+    const msg = await HttpUtil.post('/panel/api/xray/balancerOverride', { tag, target });
+    if (msg?.success) await refreshLive();
+  }
+
   function openAdd() {
     setEditingBalancer(null);
     setEditingIndex(null);
@@ -275,6 +271,49 @@ export default function BalancersTab({
         )),
     },
     { title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
+    {
+      title: t('pages.xray.balancerLive'),
+      key: 'live',
+      align: 'center',
+      width: 170,
+      render: (_v, record) => {
+        const live = liveStatus[record.tag];
+        if (!live?.running) {
+          return (
+            <Tooltip title={t('pages.xray.balancerNotRunning')}>
+              <Tag>—</Tag>
+            </Tooltip>
+          );
+        }
+        const picked = live.override || live.selected?.[0] || record.fallbackTag;
+        return (
+          <Tooltip title={(live.selected || []).join(', ') || undefined}>
+            <Tag color={live.override ? 'orange' : 'blue'}>{picked || '—'}</Tag>
+          </Tooltip>
+        );
+      },
+    },
+    {
+      title: t('pages.xray.balancerOverride'),
+      key: 'overrideTarget',
+      align: 'center',
+      width: 200,
+      render: (_v, record) => {
+        const live = liveStatus[record.tag];
+        return (
+          <Select
+            size="small"
+            style={{ width: 170 }}
+            placeholder={t('pages.xray.balancerOverridePh')}
+            allowClear
+            disabled={!live?.running}
+            value={live?.override || undefined}
+            options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
+            onChange={(v) => setOverride(record.tag, (v as string | undefined) || '')}
+          />
+        );
+      },
+    },
   ];
 
   const hasObservatory = !!templateSettings?.observatory;
@@ -321,9 +360,14 @@ export default function BalancersTab({
           </Empty>
         ) : (
           <>
-            <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
-              {t('pages.xray.Balancers')}
-            </Button>
+            <Space>
+              <Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
+                {t('pages.xray.Balancers')}
+              </Button>
+              <Tooltip title={t('pages.xray.balancerLiveRefresh')}>
+                <Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
+              </Tooltip>
+            </Space>
 
             <Table
               columns={columns}
@@ -331,7 +375,7 @@ export default function BalancersTab({
               rowKey={(r) => r.key}
               pagination={false}
               size="small"
-              scroll={{ x: 400 }}
+              scroll={{ x: 700 }}
             />
 
             {showObsEditor && (

+ 59 - 0
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -0,0 +1,59 @@
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import type { BalancerObject } from '@/schemas/routing';
+
+export const DEFAULT_OBSERVATORY = Object.freeze({
+  subjectSelector: [] as string[],
+  probeURL: 'https://www.google.com/generate_204',
+  probeInterval: '1m',
+  enableConcurrency: true,
+});
+
+export const DEFAULT_BURST_OBSERVATORY = Object.freeze({
+  subjectSelector: [] as string[],
+  pingConfig: {
+    destination: 'https://www.google.com/generate_204',
+    interval: '1m',
+    connectivity: 'http://connectivitycheck.platform.hicloud.com/generate_204',
+    timeout: '5s',
+    sampling: 2,
+  },
+});
+
+export function collectSelectors(list: BalancerObject[]): string[] {
+  const out = new Set<string>();
+  list.forEach((b) => (b.selector || []).forEach((s) => s && out.add(s)));
+  return [...out];
+}
+
+// syncObservatories keeps the (burst)observatory sections aligned with the
+// balancer strategies that actually require them. Observatories have no
+// runtime reload API in xray-core, so any change here forces a full process
+// restart — that's why random/roundRobin balancers, which work fine without
+// an observer, never CREATE one: a plain balancer add/edit then stays a
+// routing-only change and applies live through the core API. An already
+// existing burstObservatory is still kept in sync for them (alive-only
+// filtering keeps working for setups that had it), it's just never the
+// reason a new one appears.
+export function syncObservatories(t: XraySettingsValue) {
+  const balancers = (t.routing?.balancers || []) as BalancerObject[];
+
+  const leastPings = balancers.filter((b) => b.strategy?.type === 'leastPing');
+  if (leastPings.length > 0) {
+    if (!t.observatory) t.observatory = JSON.parse(JSON.stringify(DEFAULT_OBSERVATORY));
+    (t.observatory as { subjectSelector: string[] }).subjectSelector = collectSelectors(leastPings);
+  } else {
+    delete t.observatory;
+  }
+
+  const required = balancers.filter((b) => b.strategy?.type === 'leastLoad');
+  const optional = balancers.filter((b) => {
+    const type = b.strategy?.type || 'random';
+    return type === 'random' || type === 'roundRobin';
+  });
+  if (required.length > 0 || (optional.length > 0 && t.burstObservatory)) {
+    if (!t.burstObservatory) t.burstObservatory = JSON.parse(JSON.stringify(DEFAULT_BURST_OBSERVATORY));
+    (t.burstObservatory as { subjectSelector: string[] }).subjectSelector = collectSelectors([...required, ...optional]);
+  } else if (required.length === 0 && optional.length === 0) {
+    delete t.burstObservatory;
+  }
+}

+ 146 - 0
frontend/src/pages/xray/routing/RouteTester.tsx

@@ -0,0 +1,146 @@
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
+import { AimOutlined } from '@ant-design/icons';
+
+import { HttpUtil } from '@/utils';
+
+interface RouteTesterProps {
+  inboundTags: string[];
+  isMobile: boolean;
+}
+
+// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
+interface RouteTestResult {
+  matched: boolean;
+  outboundTag: string;
+  groupTags?: string[];
+}
+
+const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
+
+export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
+  const { t } = useTranslation();
+  const [dest, setDest] = useState('');
+  const [port, setPort] = useState<number | null>(443);
+  const [network, setNetwork] = useState('tcp');
+  const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
+  const [protocol, setProtocol] = useState<string | undefined>(undefined);
+  const [testing, setTesting] = useState(false);
+  const [result, setResult] = useState<RouteTestResult | null>(null);
+
+  async function run() {
+    const value = dest.trim();
+    if (!value) return;
+    // Domains never contain ':' and a pure dotted-quad is an IPv4 address;
+    // everything else is treated as a domain.
+    const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
+    setTesting(true);
+    setResult(null);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
+        domain: isIp ? '' : value,
+        ip: isIp ? value : '',
+        port: port ?? 0,
+        network,
+        inboundTag: inboundTag || '',
+        protocol: protocol || '',
+      });
+      if (msg?.success && msg.obj && typeof msg.obj === 'object') {
+        setResult(msg.obj as RouteTestResult);
+      }
+    } finally {
+      setTesting(false);
+    }
+  }
+
+  const fieldSpan = isMobile ? 24 : undefined;
+
+  return (
+    <Space orientation="vertical" size="middle" style={{ width: '100%' }}>
+      <Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
+      <Row gutter={[8, 8]} align="bottom">
+        <Col xs={fieldSpan} sm={7}>
+          <Input
+            placeholder={t('pages.xray.routeTesterDest')}
+            value={dest}
+            onChange={(e) => setDest(e.target.value)}
+            onPressEnter={run}
+            allowClear
+          />
+        </Col>
+        <Col xs={12} sm={3}>
+          <InputNumber
+            style={{ width: '100%' }}
+            min={0}
+            max={65535}
+            placeholder={t('pages.xray.routeTesterPort')}
+            value={port}
+            onChange={(v) => setPort(v)}
+          />
+        </Col>
+        <Col xs={12} sm={3}>
+          <Select
+            style={{ width: '100%' }}
+            value={network}
+            onChange={setNetwork}
+            options={[
+              { label: 'TCP', value: 'tcp' },
+              { label: 'UDP', value: 'udp' },
+            ]}
+          />
+        </Col>
+        <Col xs={12} sm={4}>
+          <Select
+            style={{ width: '100%' }}
+            placeholder={t('pages.xray.routeTesterInbound')}
+            allowClear
+            value={inboundTag}
+            onChange={setInboundTag}
+            options={inboundTags.filter(Boolean).map((tag) => ({ label: tag, value: tag }))}
+          />
+        </Col>
+        <Col xs={12} sm={4}>
+          <Select
+            style={{ width: '100%' }}
+            placeholder={t('pages.xray.routeTesterProtocol')}
+            allowClear
+            value={protocol}
+            onChange={setProtocol}
+            options={PROTOCOL_OPTIONS}
+          />
+        </Col>
+        <Col xs={fieldSpan} sm={3}>
+          <Button type="primary" icon={<AimOutlined />} loading={testing} disabled={!dest.trim()} onClick={run} block>
+            {t('pages.xray.routeTesterTest')}
+          </Button>
+        </Col>
+      </Row>
+
+      {result && (
+        result.matched ? (
+          <Alert
+            type="success"
+            showIcon
+            title={
+              <Space wrap>
+                <span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
+                <Tag color="blue">{result.outboundTag || '—'}</Tag>
+                {(result.groupTags || []).length > 0 && (
+                  <>
+                    <span>{t('pages.xray.routeTesterViaBalancer')}:</span>
+                    {(result.groupTags || []).map((tag) => (
+                      <Tag key={tag} color="orange">{tag}</Tag>
+                    ))}
+                  </>
+                )}
+              </Space>
+            }
+          />
+        ) : (
+          <Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
+        )
+      )}
+    </Space>
+  );
+}

+ 7 - 1
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -1,10 +1,11 @@
 import { useCallback, useMemo, useRef, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { Button, Modal, Space, Table, Tabs } from 'antd';
-import { ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
+import { AimOutlined, ControlOutlined, PlusOutlined, UnorderedListOutlined } from '@ant-design/icons';
 
 import { catTabLabel } from '@/pages/settings/catTabLabel';
 import RoutingBasic from './RoutingBasic';
+import RouteTester from './RouteTester';
 import RuleFormModal from './RuleFormModal';
 import type { RoutingRule } from './RuleFormModal';
 import RuleCardList from './RuleCardList';
@@ -312,6 +313,11 @@ export default function RoutingTab({
               </Space>
             ),
           },
+          {
+            key: 'tester',
+            label: catTabLabel(<AimOutlined />, t('pages.xray.routeTester'), isMobile),
+            children: <RouteTester inboundTags={inboundTagOptions} isMobile={isMobile} />,
+          },
         ]}
       />
       <RuleFormModal

+ 1 - 1
frontend/src/schemas/setting.ts

@@ -13,7 +13,7 @@ export const AllSettingSchema = z.object({
   webBasePath: absolutePath.optional(),
   sessionMaxAge: z.number().int().min(1).max(525600).optional(),
   trustedProxyCIDRs: z.string().optional(),
-  panelProxy: z.string().optional(),
+  panelOutbound: z.string().optional(),
   pageSize: z.number().int().min(0).max(1000).optional(),
   expireDiff: nonNegativeInt.optional(),
   trafficDiff: nonNegativeInt.max(100).optional(),

+ 60 - 0
frontend/src/test/balancer-observatory-sync.test.ts

@@ -0,0 +1,60 @@
+import { describe, expect, it } from 'vitest';
+
+import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
+  return { routing, ...extra } as unknown as XraySettingsValue;
+}
+
+// Observatory sections have no reload API in xray-core, so creating one turns
+// a balancer save from a live (hot-applied) routing change into a full
+// restart. These tests pin the rule: only strategies that genuinely need an
+// observer may create one.
+describe('syncObservatories', () => {
+  it('does not create burstObservatory for a fresh random balancer (stays hot-appliable)', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['direct'] }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeUndefined();
+    expect(t.observatory).toBeUndefined();
+  });
+
+  it('does not create burstObservatory for roundRobin', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'roundRobin' } }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeUndefined();
+  });
+
+  it('creates burstObservatory for leastLoad (required by the strategy)', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
+    syncObservatories(t);
+    expect(t.burstObservatory).toBeDefined();
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
+  });
+
+  it('creates observatory for leastPing (required by the strategy)', () => {
+    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
+    syncObservatories(t);
+    expect(t.observatory).toBeDefined();
+    expect((t.observatory as { subjectSelector: string[] }).subjectSelector).toEqual(['a']);
+  });
+
+  it('keeps an existing burstObservatory in sync for random balancers (legacy setups)', () => {
+    const t = tpl(
+      { balancers: [{ tag: 'b1', selector: ['a'] }, { tag: 'b2', selector: ['b'], strategy: { type: 'leastLoad' } }] },
+      { burstObservatory: { subjectSelector: ['stale'] } },
+    );
+    syncObservatories(t);
+    expect((t.burstObservatory as { subjectSelector: string[] }).subjectSelector).toEqual(['b', 'a']);
+  });
+
+  it('removes observatories when no balancer can use them', () => {
+    const t = tpl({ balancers: [] }, {
+      observatory: { subjectSelector: ['a'] },
+      burstObservatory: { subjectSelector: ['a'] },
+    });
+    syncObservatories(t);
+    expect(t.observatory).toBeUndefined();
+    expect(t.burstObservatory).toBeUndefined();
+  });
+});

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

@@ -5,6 +5,7 @@ import (
 	"strconv"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
@@ -29,6 +30,7 @@ type SettingController struct {
 	userService     panel.UserService
 	panelService    panel.PanelService
 	apiTokenService panel.ApiTokenService
+	xrayService     service.XrayService
 }
 
 // NewSettingController creates a new SettingController and initializes its routes.
@@ -81,12 +83,21 @@ func (a *SettingController) updateSetting(c *gin.Context) {
 		return
 	}
 	oldTwoFactor, twoFactorErr := a.settingService.GetTwoFactorEnable()
+	oldPanelOutbound, _ := a.settingService.GetPanelOutbound()
 	err := a.settingService.UpdateAllSetting(allSetting)
 	if err == nil && twoFactorErr == nil && !oldTwoFactor && allSetting.TwoFactorEnable {
 		if bumpErr := a.userService.BumpLoginEpoch(); bumpErr != nil {
 			err = bumpErr
 		}
 	}
+	if err == nil && allSetting.PanelOutbound != oldPanelOutbound {
+		// The egress bridge lives in the generated config; reconcile the
+		// running core. One SOCKS inbound plus one routing rule — both
+		// hot-appliable, so this normally does not restart Xray.
+		if applyErr := a.xrayService.RestartXray(false); applyErr != nil {
+			logger.Warning("apply panel outbound change failed:", applyErr)
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 }
 

+ 86 - 1
internal/web/controller/xray_setting.go

@@ -4,12 +4,14 @@ import (
 	"encoding/json"
 	"fmt"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/outbound"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 	"github.com/gin-gonic/gin"
 )
@@ -46,6 +48,9 @@ func (a *XraySettingController) initRouter(g *gin.RouterGroup) {
 	g.POST("/update", a.updateSetting)
 	g.POST("/resetOutboundsTraffic", a.resetOutboundsTraffic)
 	g.POST("/testOutbound", a.testOutbound)
+	g.POST("/balancerStatus", a.balancerStatus)
+	g.POST("/balancerOverride", a.balancerOverride)
+	g.POST("/routeTest", a.routeTest)
 
 	// Outbound subscription (remote outbound lists)
 	g.GET("/outbound-subs", a.listOutboundSubs)
@@ -120,7 +125,9 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
 	jsonObj(c, string(result), nil)
 }
 
-// updateSetting updates the Xray configuration settings.
+// updateSetting updates the Xray configuration settings and applies them to
+// the running core right away — through the gRPC API when only inbounds,
+// outbounds or routing rules changed, with a process restart otherwise.
 func (a *XraySettingController) updateSetting(c *gin.Context) {
 	xraySetting := c.PostForm("xraySetting")
 	if err := a.XraySettingService.SaveXraySetting(xraySetting); err != nil {
@@ -135,6 +142,13 @@ func (a *XraySettingController) updateSetting(c *gin.Context) {
 		jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
 		return
 	}
+	// Only reconcile a running core; a manually stopped xray stays stopped.
+	if a.XrayService.IsXrayRunning() {
+		if err := a.XrayService.RestartXray(false); err != nil {
+			jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), err)
+			return
+		}
+	}
 	jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifySettings"), nil)
 }
 
@@ -272,6 +286,77 @@ func (a *XraySettingController) testOutbound(c *gin.Context) {
 	jsonObj(c, result, nil)
 }
 
+// balancerStatus reports the live state (override + strategy picks) of the
+// balancer tags given as a comma-separated "tags" form field.
+func (a *XraySettingController) balancerStatus(c *gin.Context) {
+	raw := c.PostForm("tags")
+	var tags []string
+	for _, tag := range strings.Split(raw, ",") {
+		if tag = strings.TrimSpace(tag); tag != "" {
+			tags = append(tags, tag)
+		}
+	}
+	statuses, err := a.XrayService.GetBalancersStatus(tags)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	byTag := make(map[string]service.BalancerStatus, len(statuses))
+	for _, status := range statuses {
+		byTag[status.Tag] = status
+	}
+	jsonObj(c, byTag, nil)
+}
+
+// balancerOverride forces a balancer to a specific outbound tag; an empty
+// "target" clears the override.
+func (a *XraySettingController) balancerOverride(c *gin.Context) {
+	tag := c.PostForm("tag")
+	if tag == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("tag is required"))
+		return
+	}
+	target := c.PostForm("target")
+	if err := a.XrayService.OverrideBalancer(tag, target); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, "", nil)
+}
+
+// routeTest asks the running core which outbound it would route a synthetic
+// connection to.
+func (a *XraySettingController) routeTest(c *gin.Context) {
+	port := 0
+	if portStr := c.PostForm("port"); portStr != "" {
+		parsed, err := strconv.Atoi(portStr)
+		if err != nil || parsed < 0 || parsed > 65535 {
+			jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("invalid port"))
+			return
+		}
+		port = parsed
+	}
+	req := xray.RouteTestRequest{
+		InboundTag: c.PostForm("inboundTag"),
+		Domain:     c.PostForm("domain"),
+		IP:         c.PostForm("ip"),
+		Port:       port,
+		Network:    c.PostForm("network"),
+		Protocol:   c.PostForm("protocol"),
+		Email:      c.PostForm("email"),
+	}
+	if req.Domain == "" && req.IP == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("domain or ip is required"))
+		return
+	}
+	result, err := a.XrayService.TestRoute(req)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+}
+
 // --- Outbound Subscription handlers ---
 
 func (a *XraySettingController) listOutboundSubs(c *gin.Context) {

+ 1 - 1
internal/web/entity/entity.go

@@ -29,7 +29,7 @@ type AllSetting struct {
 	WebBasePath       string `json:"webBasePath" form:"webBasePath"`                                 // Base path for web panel URLs
 	SessionMaxAge     int    `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"` // Session maximum age in minutes (cap at one year)
 	TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`                     // Trusted reverse proxy IPs/CIDRs for forwarded headers
-	PanelProxy        string `json:"panelProxy" form:"panelProxy"`                                   // Proxy URL for the panel's own outbound requests (GitHub/Telegram)
+	PanelOutbound     string `json:"panelOutbound" form:"panelOutbound"`                             // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
 
 	// UI settings
 	PageSize    int    `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`      // Number of items per page in lists (0 disables pagination)

+ 2 - 1
internal/web/service/config.json

@@ -3,7 +3,8 @@
     "services": [
       "HandlerService",
       "LoggerService",
-      "StatsService"
+      "StatsService",
+      "RoutingService"
     ],
     "tag": "api"
   },

+ 1 - 1
internal/web/service/integration/panel_proxy_test.go → internal/web/service/integration/panel_egress_test.go

@@ -28,7 +28,7 @@ func originServer(t *testing.T, hits *int64) *httptest.Server {
 	}))
 }
 
-func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
+func TestPanelEgress_NetproxyHelperRoutesThroughProxy(t *testing.T) {
 	var proxyHits, originHits int64
 	proxy := recordingProxy(t, &proxyHits)
 	defer proxy.Close()

+ 39 - 13
internal/web/service/setting.go

@@ -95,7 +95,7 @@ var defaultValueMap = map[string]string{
 	"externalTrafficInformURI":    "",
 	"restartXrayOnClientDisable":  "true",
 	"xrayOutboundTestUrl":         "https://www.google.com/generate_204",
-	"panelProxy":                  "",
+	"panelOutbound":               "",
 
 	// LDAP defaults
 	"ldapEnable":            "false",
@@ -384,26 +384,52 @@ func (s *SettingService) SetTgBotProxy(token string) error {
 	return s.setString("tgBotProxy", token)
 }
 
-func (s *SettingService) GetPanelProxy() (string, error) {
-	return s.getString("panelProxy")
+// GetPanelOutbound returns the Xray outbound tag the panel's own outbound
+// requests (version checks, Telegram, subscription fetches) are routed through.
+func (s *SettingService) GetPanelOutbound() (string, error) {
+	return s.getString("panelOutbound")
 }
 
-func (s *SettingService) SetPanelProxy(proxyUrl string) error {
-	return s.setString("panelProxy", proxyUrl)
+func (s *SettingService) SetPanelOutbound(tag string) error {
+	return s.setString("panelOutbound", tag)
+}
+
+// PanelEgressProxyURL resolves the loopback SOCKS bridge that the generated
+// config exposes when a panel outbound is configured (see injectPanelEgress).
+// It returns "" — meaning a direct connection — when the feature is off or
+// the bridge is not present in the running core yet.
+func (s *SettingService) PanelEgressProxyURL() string {
+	tag, err := s.GetPanelOutbound()
+	if err != nil || tag == "" {
+		return ""
+	}
+	proc := XrayProcess()
+	if proc == nil || !proc.IsRunning() {
+		logger.Warning("panel outbound [", tag, "] is set but Xray is not running, using a direct connection")
+		return ""
+	}
+	cfg := proc.GetConfig()
+	if cfg == nil {
+		return ""
+	}
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
+			return fmt.Sprintf("socks5://127.0.0.1:%d", cfg.InboundConfigs[i].Port)
+		}
+	}
+	logger.Warning("panel outbound [", tag, "] is set but the egress bridge is not in the running config, using a direct connection")
+	return ""
 }
 
 // NewProxiedHTTPClient returns an HTTP client that routes the panel's own
-// outbound requests through the configured panelProxy setting. An invalid or
-// missing proxy falls back to a direct client so existing behavior is preserved.
+// outbound requests through the configured panel outbound (via the loopback
+// SOCKS bridge in the running Xray). When the feature is off or the bridge
+// is unavailable it falls back to a direct client.
 func (s *SettingService) NewProxiedHTTPClient(timeout time.Duration) *http.Client {
-	proxyUrl, err := s.GetPanelProxy()
-	if err != nil {
-		logger.Warning("Failed to read panel proxy setting:", err)
-		proxyUrl = ""
-	}
+	proxyUrl := s.PanelEgressProxyURL()
 	client, err := netproxy.NewHTTPClient(proxyUrl, timeout)
 	if err != nil {
-		logger.Warningf("Invalid panel proxy %q, using direct connection: %v", proxyUrl, err)
+		logger.Warningf("Invalid panel egress proxy %q, using direct connection: %v", proxyUrl, err)
 		return &http.Client{Timeout: timeout}
 	}
 	return client

+ 5 - 6
internal/web/service/tgbot/tgbot.go

@@ -234,13 +234,12 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 		logger.Warning("Failed to get Telegram bot proxy URL:", err)
 	}
 
-	// Fall back to the panel-wide proxy when no dedicated bot proxy is set.
+	// Fall back to the panel-wide egress bridge when no dedicated bot proxy is
+	// set. Resolved once at bot start: if Xray comes up later, the bot keeps
+	// its direct connection until it is restarted.
 	if tgBotProxy == "" {
-		panelProxy, perr := t.settingService.GetPanelProxy()
-		if perr != nil {
-			logger.Warning("Failed to get panel proxy URL:", perr)
-		} else if isSupportedBotProxyScheme(panelProxy) {
-			tgBotProxy = panelProxy
+		if egress := t.settingService.PanelEgressProxyURL(); egress != "" && isSupportedBotProxyScheme(egress) {
+			tgBotProxy = egress
 		}
 	}
 

+ 302 - 2
internal/web/service/xray.go

@@ -115,6 +115,7 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		return nil, err
 	}
 	xrayConfig.LogConfig = resolveXrayLogPaths(xrayConfig.LogConfig)
+	xrayConfig.API = ensureAPIServices(xrayConfig.API)
 
 	_, _, _ = s.inboundService.AddTraffic(nil, nil)
 
@@ -272,9 +273,82 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
 		mergeSubscriptionOutbounds(xrayConfig, prepend, appendList)
 	}
 
+	// Wire the panel's own HTTP traffic through the configured outbound, after
+	// the subscription merge so subscription outbound tags are valid targets.
+	if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
+		logger.Warning("read panelOutbound setting failed:", err)
+	} else if egressTag != "" {
+		injectPanelEgress(xrayConfig, egressTag)
+	}
+
 	return xrayConfig, nil
 }
 
+// PanelEgressInboundTag is the tag of the loopback SOCKS inbound injected into
+// the generated config when a panel outbound is configured. The panel's own
+// HTTP clients dial through it to egress via the chosen outbound.
+const PanelEgressInboundTag = "panel-egress"
+
+// panelEgressBasePort is the first port tried for the egress bridge; ports
+// already taken by other inbounds in the generated config are skipped.
+const panelEgressBasePort = 62790
+
+// injectPanelEgress appends a loopback SOCKS inbound to the generated config
+// and prepends a routing rule sending it to outboundTag. Both live only in the
+// generated config — the stored template is never modified — and both are
+// hot-appliable, so changing the panel outbound never restarts the core.
+func injectPanelEgress(cfg *xray.Config, outboundTag string) {
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == PanelEgressInboundTag {
+			logger.Warning("panel egress: inbound tag [", PanelEgressInboundTag, "] already exists, skipping injection")
+			return
+		}
+	}
+
+	// The rule must exist before the inbound takes traffic, otherwise the
+	// bridge would silently egress through the default outbound instead.
+	routing := map[string]any{}
+	if len(cfg.RouterConfig) > 0 {
+		if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+			logger.Warning("panel egress: routing section is unparsable, skipping injection:", err)
+			return
+		}
+	}
+	rules, _ := routing["rules"].([]any)
+	rule := map[string]any{
+		"type":        "field",
+		"inboundTag":  []any{PanelEgressInboundTag},
+		"outboundTag": outboundTag,
+	}
+	routing["rules"] = append([]any{rule}, rules...)
+	newRouting, err := json.Marshal(routing)
+	if err != nil {
+		logger.Warning("panel egress: failed to rebuild routing section, skipping injection:", err)
+		return
+	}
+	cfg.RouterConfig = json_util.RawMessage(newRouting)
+
+	used := make(map[int]struct{}, len(cfg.InboundConfigs))
+	for i := range cfg.InboundConfigs {
+		used[cfg.InboundConfigs[i].Port] = struct{}{}
+	}
+	port := panelEgressBasePort
+	for {
+		if _, taken := used[port]; !taken {
+			break
+		}
+		port++
+	}
+
+	cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
+		Listen:   json_util.RawMessage(`"127.0.0.1"`),
+		Port:     port,
+		Protocol: "socks",
+		Settings: json_util.RawMessage(`{"auth":"noauth","udp":false}`),
+		Tag:      PanelEgressInboundTag,
+	})
+}
+
 // mergeSubscriptionOutbounds appends the subscription outbounds to the
 // OutboundConfigs array of the xray config. It works on the already-unmarshaled
 // template so that manually configured outbounds are never overwritten.
@@ -306,6 +380,47 @@ func mergeSubscriptionOutbounds(cfg *xray.Config, prepend, appendList []any) {
 	cfg.OutboundConfigs = json_util.RawMessage(combined)
 }
 
+// ensureAPIServices guarantees the gRPC services the panel depends on are
+// listed in the generated config's api block: HandlerService and StatsService
+// have always been required for inbound/user management and traffic polling,
+// and RoutingService enables hot routing reload on templates saved before it
+// was added to the default template. The stored template itself is not
+// modified — only the generated runtime config.
+func ensureAPIServices(api json_util.RawMessage) json_util.RawMessage {
+	if len(api) == 0 {
+		// No api block means the panel's API integration is deliberately
+		// disabled; don't resurrect it behind the user's back.
+		return api
+	}
+	var parsed map[string]any
+	if err := json.Unmarshal(api, &parsed); err != nil {
+		return api
+	}
+	services, _ := parsed["services"].([]any)
+	have := make(map[string]bool, len(services))
+	for _, svc := range services {
+		if name, ok := svc.(string); ok {
+			have[name] = true
+		}
+	}
+	added := false
+	for _, name := range []string{"HandlerService", "StatsService", "RoutingService"} {
+		if !have[name] {
+			services = append(services, name)
+			added = true
+		}
+	}
+	if !added {
+		return api
+	}
+	parsed["services"] = services
+	out, err := json.Marshal(parsed)
+	if err != nil {
+		return api
+	}
+	return out
+}
+
 // resolveXrayLogPaths rewrites relative `log.access` / `log.error` values to
 // absolute paths under config.GetLogFolder(), so Xray writes those files
 // alongside the panel's other logs regardless of the working directory the
@@ -378,7 +493,81 @@ func (s *XrayService) GetXrayTraffic() ([]*xray.Traffic, []*xray.ClientTraffic,
 	return traffic, clientTraffic, nil
 }
 
-// RestartXray restarts the Xray process, optionally forcing a restart even if config unchanged.
+// BalancerStatus is the live view of one balancer for the panel UI. Running
+// is false when the balancer isn't present in the running core (e.g. xray is
+// stopped or the balancer hasn't been saved/applied yet).
+type BalancerStatus struct {
+	Tag      string   `json:"tag"`
+	Running  bool     `json:"running"`
+	Override string   `json:"override"`
+	Selected []string `json:"selected"`
+}
+
+// GetBalancersStatus queries the running core for the live state of the
+// given balancer tags. Per-tag failures are reported as Running=false rather
+// than failing the whole call, so the UI can render saved-but-not-applied
+// balancers alongside live ones.
+func (s *XrayService) GetBalancersStatus(tags []string) ([]BalancerStatus, error) {
+	statuses := make([]BalancerStatus, 0, len(tags))
+	if !s.IsXrayRunning() {
+		for _, tag := range tags {
+			statuses = append(statuses, BalancerStatus{Tag: tag})
+		}
+		return statuses, nil
+	}
+	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+		return nil, err
+	}
+	defer s.xrayAPI.Close()
+
+	for _, tag := range tags {
+		info, err := s.xrayAPI.GetBalancerInfo(tag)
+		if err != nil {
+			logger.Debug("get balancer info [", tag, "] failed:", err)
+			statuses = append(statuses, BalancerStatus{Tag: tag})
+			continue
+		}
+		statuses = append(statuses, BalancerStatus{
+			Tag:      tag,
+			Running:  true,
+			Override: info.Override,
+			Selected: info.Selected,
+		})
+	}
+	return statuses, nil
+}
+
+// OverrideBalancer forces a balancer in the running core to use the given
+// outbound tag; an empty target clears the override.
+func (s *XrayService) OverrideBalancer(tag, target string) error {
+	if !s.IsXrayRunning() {
+		return errors.New("xray is not running")
+	}
+	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+		return err
+	}
+	defer s.xrayAPI.Close()
+	return s.xrayAPI.SetBalancerTarget(tag, target)
+}
+
+// TestRoute asks the running core which outbound its router picks for the
+// described connection.
+func (s *XrayService) TestRoute(req xray.RouteTestRequest) (*xray.RouteTestResult, error) {
+	if !s.IsXrayRunning() {
+		return nil, errors.New("xray is not running")
+	}
+	if err := s.xrayAPI.Init(p.GetAPIPort()); err != nil {
+		return nil, err
+	}
+	defer s.xrayAPI.Close()
+	return s.xrayAPI.TestRoute(req)
+}
+
+// RestartXray reconciles the running Xray process with the current desired
+// config. When isForce is false it first tries to apply the changes through
+// the Xray gRPC API without restarting the process (inbounds, outbounds and
+// routing rules/balancers are hot-reloadable); only changes the core cannot
+// take at runtime — or a force request — stop and restart the process.
 func (s *XrayService) RestartXray(isForce bool) error {
 	lock.Lock()
 	defer lock.Unlock()
@@ -391,10 +580,15 @@ func (s *XrayService) RestartXray(isForce bool) error {
 	}
 
 	if s.IsXrayRunning() {
-		if !isForce && p.GetConfig().Equals(xrayConfig) && !isNeedXrayRestart.Load() {
+		configUnchanged := p.GetConfig().Equals(xrayConfig)
+		if !isForce && configUnchanged && !isNeedXrayRestart.Load() {
 			logger.Debug("It does not need to restart Xray")
 			return nil
 		}
+		if !isForce && !configUnchanged && s.tryHotApply(xrayConfig) {
+			logger.Info("Xray config changes applied through the core API, no restart needed")
+			return nil
+		}
 		p.Stop()
 	}
 
@@ -409,6 +603,112 @@ func (s *XrayService) RestartXray(isForce bool) error {
 	return nil
 }
 
+// tryHotApply attempts to reconcile the running Xray instance with newCfg
+// through the core gRPC API (HandlerService for inbounds/outbounds,
+// RoutingService for rules/balancers). It returns true when the running
+// instance now matches newCfg; on any failure it returns false and the
+// caller falls back to a full process restart, which cleans up whatever was
+// partially applied. Callers must hold the package-level lock.
+func (s *XrayService) tryHotApply(newCfg *xray.Config) bool {
+	oldCfg := p.GetConfig()
+	diff, ok := xray.ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		logger.Debug("hot apply: config change is not API-applicable, falling back to restart")
+		return false
+	}
+	if diff.Empty() {
+		p.SetConfig(newCfg)
+		return true
+	}
+
+	apiPort := p.GetAPIPort()
+	if apiPort <= 0 {
+		return false
+	}
+	// A dedicated client: s.xrayAPI may be in use by traffic polling on other
+	// service instances and is reset around restarts.
+	hotAPI := xray.XrayAPI{}
+	if err := hotAPI.Init(apiPort); err != nil {
+		logger.Debug("hot apply: failed to init xray api:", err)
+		return false
+	}
+	defer hotAPI.Close()
+
+	// Removals first so changed handlers and port swaps never collide with
+	// the additions that follow.
+	for _, tag := range diff.RemovedInboundTags {
+		if err := hotAPI.DelInbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
+			logger.Info("hot apply: remove inbound [", tag, "] failed:", err)
+			return false
+		}
+	}
+	for _, tag := range diff.RemovedOutboundTags {
+		if err := hotAPI.DelOutbound(tag); err != nil && !xray.IsMissingHandlerErr(err) {
+			logger.Info("hot apply: remove outbound [", tag, "] failed:", err)
+			return false
+		}
+	}
+	for _, ob := range diff.AddedOutbounds {
+		if err := addOutboundReconciling(&hotAPI, ob); err != nil {
+			logger.Info("hot apply: add outbound failed:", err)
+			return false
+		}
+	}
+	for _, ib := range diff.AddedInbounds {
+		if err := addInboundReconciling(&hotAPI, ib); err != nil {
+			logger.Info("hot apply: add inbound failed:", err)
+			return false
+		}
+	}
+	if diff.RoutingConfig != nil {
+		if err := hotAPI.ApplyRoutingConfig(diff.RoutingConfig); err != nil {
+			logger.Info("hot apply: apply routing config failed:", err)
+			return false
+		}
+	}
+
+	p.SetConfig(newCfg)
+	return true
+}
+
+// addInboundReconciling adds an inbound, and on a tag conflict (the handler
+// was already created through the runtime API while the stored snapshot was
+// stale) replaces the existing handler instead.
+func addInboundReconciling(api *xray.XrayAPI, inbound []byte) error {
+	err := api.AddInbound(inbound)
+	if err == nil || !xray.IsExistingTagErr(err) {
+		return err
+	}
+	var meta struct {
+		Tag string `json:"tag"`
+	}
+	if jsonErr := json.Unmarshal(inbound, &meta); jsonErr != nil || meta.Tag == "" {
+		return err
+	}
+	if delErr := api.DelInbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
+		return delErr
+	}
+	return api.AddInbound(inbound)
+}
+
+// addOutboundReconciling mirrors addInboundReconciling for outbounds.
+func addOutboundReconciling(api *xray.XrayAPI, outbound []byte) error {
+	err := api.AddOutbound(outbound)
+	if err == nil || !xray.IsExistingTagErr(err) {
+		return err
+	}
+	var meta struct {
+		Tag string `json:"tag"`
+	}
+	if jsonErr := json.Unmarshal(outbound, &meta); jsonErr != nil || meta.Tag == "" {
+		return err
+	}
+	if delErr := api.DelOutbound(meta.Tag); delErr != nil && !xray.IsMissingHandlerErr(delErr) {
+		return delErr
+	}
+	return api.AddOutbound(outbound)
+}
+
 // StopXray stops the running Xray process.
 func (s *XrayService) StopXray() error {
 	lock.Lock()

+ 159 - 0
internal/web/service/xray_config_inject_test.go

@@ -0,0 +1,159 @@
+package service
+
+import (
+	"encoding/json"
+	"os"
+	"testing"
+
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"github.com/op/go-logging"
+)
+
+func TestMain(m *testing.M) {
+	// injectPanelEgress logs when it skips injection; the package logger must
+	// exist before any test exercises a skipped path.
+	xuilogger.InitLogger(logging.ERROR)
+	os.Exit(m.Run())
+}
+
+func TestEnsureAPIServices(t *testing.T) {
+	// legacy template without RoutingService gets it injected
+	out := ensureAPIServices(json_util.RawMessage(`{"services":["HandlerService","LoggerService","StatsService"],"tag":"api"}`))
+	var parsed struct {
+		Services []string `json:"services"`
+		Tag      string   `json:"tag"`
+	}
+	if err := json.Unmarshal(out, &parsed); err != nil {
+		t.Fatal(err)
+	}
+	want := map[string]bool{"HandlerService": true, "StatsService": true, "RoutingService": true, "LoggerService": true}
+	if len(parsed.Services) != 4 {
+		t.Fatalf("expected 4 services, got %v", parsed.Services)
+	}
+	for _, svc := range parsed.Services {
+		if !want[svc] {
+			t.Fatalf("unexpected service %q", svc)
+		}
+	}
+	if parsed.Tag != "api" {
+		t.Fatalf("tag must be preserved, got %q", parsed.Tag)
+	}
+
+	// complete api block is returned unchanged (no marshal churn)
+	full := json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`)
+	if got := ensureAPIServices(full); string(got) != string(full) {
+		t.Fatalf("complete api block must pass through untouched, got %s", got)
+	}
+
+	// absent api block stays absent
+	if got := ensureAPIServices(nil); got != nil {
+		t.Fatalf("nil api block must stay nil, got %s", got)
+	}
+}
+
+func egressTestConfig() *xray.Config {
+	return &xray.Config{
+		RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
+		InboundConfigs: []xray.InboundConfig{
+			{Port: 62789, Protocol: "tunnel", Tag: "api", Listen: json_util.RawMessage(`"127.0.0.1"`)},
+		},
+	}
+}
+
+type egressRouting struct {
+	DomainStrategy string `json:"domainStrategy"`
+	Rules          []struct {
+		InboundTag  []string `json:"inboundTag"`
+		OutboundTag string   `json:"outboundTag"`
+		Type        string   `json:"type"`
+	} `json:"rules"`
+}
+
+func TestInjectPanelEgress(t *testing.T) {
+	cfg := egressTestConfig()
+	injectPanelEgress(cfg, "warp")
+
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatalf("expected the egress inbound to be appended, got %d inbounds", len(cfg.InboundConfigs))
+	}
+	ib := cfg.InboundConfigs[1]
+	if ib.Tag != PanelEgressInboundTag || ib.Protocol != "socks" || ib.Port != panelEgressBasePort {
+		t.Fatalf("unexpected egress inbound: %+v", ib)
+	}
+	if string(ib.Listen) != `"127.0.0.1"` {
+		t.Fatalf("egress inbound must listen on loopback, got %s", ib.Listen)
+	}
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if routing.DomainStrategy != "AsIs" {
+		t.Fatalf("routing keys outside rules must be preserved, got %+v", routing)
+	}
+	if len(routing.Rules) != 2 {
+		t.Fatalf("expected egress rule + existing rule, got %+v", routing.Rules)
+	}
+	first := routing.Rules[0]
+	if first.Type != "field" || first.OutboundTag != "warp" ||
+		len(first.InboundTag) != 1 || first.InboundTag[0] != PanelEgressInboundTag {
+		t.Fatalf("egress rule must be prepended, got %+v", first)
+	}
+}
+
+func TestInjectPanelEgress_PortCollision(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: panelEgressBasePort, Protocol: "vless", Tag: "in-1"},
+		xray.InboundConfig{Port: panelEgressBasePort + 1, Protocol: "vless", Tag: "in-2"},
+	)
+	injectPanelEgress(cfg, "direct")
+	got := cfg.InboundConfigs[len(cfg.InboundConfigs)-1]
+	if got.Tag != PanelEgressInboundTag || got.Port != panelEgressBasePort+2 {
+		t.Fatalf("egress inbound must skip taken ports, got %+v", got)
+	}
+}
+
+func TestInjectPanelEgress_TagCollisionSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.InboundConfigs = append(cfg.InboundConfigs,
+		xray.InboundConfig{Port: 1234, Protocol: "socks", Tag: PanelEgressInboundTag},
+	)
+	before := string(cfg.RouterConfig)
+	injectPanelEgress(cfg, "direct")
+	if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
+		t.Fatal("a user inbound owning the egress tag must make injection a no-op")
+	}
+}
+
+func TestInjectPanelEgress_NoRoutingSection(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = nil
+	injectPanelEgress(cfg, "direct")
+
+	var routing egressRouting
+	if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
+		t.Fatal(err)
+	}
+	if len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
+		t.Fatalf("a routing section must be created with the egress rule, got %+v", routing)
+	}
+	if len(cfg.InboundConfigs) != 2 {
+		t.Fatal("egress inbound must still be appended")
+	}
+}
+
+func TestInjectPanelEgress_BadRoutingSkips(t *testing.T) {
+	cfg := egressTestConfig()
+	cfg.RouterConfig = json_util.RawMessage(`{not json`)
+	injectPanelEgress(cfg, "direct")
+	if len(cfg.InboundConfigs) != 1 {
+		t.Fatal("unparsable routing must skip the whole injection, inbound included")
+	}
+	if string(cfg.RouterConfig) != `{not json` {
+		t.Fatal("unparsable routing must be left untouched")
+	}
+}

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "مسار URI للبانل. (يبدأ بـ '/' وبينتهي بـ '/')",
       "pageSize": "حجم الصفحة",
       "pageSizeDesc": "حدد حجم الصفحة لجدول الإدخالات. (0 = تعطيل)",
-      "panelProxy": "وكيل شبكة اللوحة",
-      "panelProxyDesc": "يوجه طلبات اللوحة الصادرة (تحديثات geo، فحص إصدارات Xray/اللوحة، تيليجرام) عبر هذا الوكيل لتجاوز فلترة GitHub/تيليجرام على الخادم. يقبل socks5:// أو http(s)://، مثل وارد SOCKS محلي لـ Xray. اتركه فارغاً للاتصال المباشر.",
+      "panelOutbound": "صادر ترافيك اللوحة",
+      "panelOutboundDesc": "بيوجه طلبات اللوحة نفسها — فحص إصدارات وتنزيلات اللوحة/Xray، تيليجرام، وتحديث ملفات geo العادي — عبر صادر Xray ده لتجاوز فلترة GitHub/تيليجرام على الخادم. وارد جسر محلي بيتضاف تلقائياً للإعداد الشغال وبيتطبق مباشرة. تحديث Geodata التلقائي الأصلي في Xray مش متأثر؛ ليه صادر تنزيل خاص بيه. اتركه فارغاً للاتصال المباشر.",
+      "panelOutboundPh": "اتصال مباشر",
       "remarkModel": "نموذج الملاحظة وحرف الفصل",
       "datepicker": "نوع التقويم",
       "datepickerPlaceholder": "اختار التاريخ",
@@ -1183,6 +1184,21 @@
       "Balancers": "موازنات التحميل",
       "balancerTagRequired": "الوسم مطلوب",
       "balancerSelectorRequired": "اختر صادراً واحداً على الأقل",
+      "balancerLive": "الهدف الحالي",
+      "balancerOverride": "تجاوز الاختيار",
+      "balancerOverridePh": "تلقائي (الاستراتيجية)",
+      "balancerLiveRefresh": "تحديث حالة موازن التحميل",
+      "balancerNotRunning": "موازن التحميل ده مش نشط في Xray الشغال — احفظ التغييرات أو ابدأ Xray الأول",
+      "routeTester": "اختبار المسار",
+      "routeTesterDesc": "اسأل Xray الشغال أي صادر هيتعامل مع الاتصال ده. مفيش ترافيك بيتبعت — القرار بييجي مباشرة من محرك التوجيه الحي.",
+      "routeTesterDest": "نطاق أو IP",
+      "routeTesterPort": "المنفذ",
+      "routeTesterInbound": "الوارد",
+      "routeTesterProtocol": "البروتوكول المكتشف",
+      "routeTesterTest": "اختبر المسار",
+      "routeTesterMatchedOutbound": "الصادر المطابق",
+      "routeTesterViaBalancer": "عبر موازن التحميل",
+      "routeTesterDefaultOutbound": "ما في قاعدة توجيه اتطابقت — الترافيك رايح للصادر الافتراضي (الأول).",
       "OutboundsDesc": "حدد مسار الترافيك الصادر.",
       "Routings": "قواعد التوجيه",
       "RoutingsDesc": "أولوية كل قاعدة مهمة جداً!",

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

@@ -939,8 +939,9 @@
       "panelUrlPathDesc": "The URI path for the web panel. (begins with ‘/‘ and concludes with ‘/‘)",
       "pageSize": "Pagination Size",
       "pageSizeDesc": "Define page size for inbounds table. (0 = disable)",
-      "panelProxy": "Panel Network Proxy",
-      "panelProxyDesc": "Routes the panel's own outbound requests (geo updates, Xray/panel version checks, Telegram) through this proxy to bypass server-side filtering of GitHub/Telegram. Accepts socks5:// or http(s)://, e.g. a local Xray SOCKS inbound. Leave empty for a direct connection.",
+      "panelOutbound": "Panel Traffic Outbound",
+      "panelOutboundDesc": "Routes the panel's own requests — panel/Xray version checks and downloads, Telegram, and the normal geo-file update — through this Xray outbound to bypass server-side filtering of GitHub/Telegram. A loopback bridge inbound is added to the running config automatically and applied live. The Xray-native Geodata Auto-Update is not affected; it has its own download outbound. Leave empty for a direct connection.",
+      "panelOutboundPh": "Direct connection",
       "remarkModel": "Remark Model & Separation Character",
       "datepicker": "Calendar Type",
       "datepickerPlaceholder": "Select date",
@@ -1186,6 +1187,21 @@
       "Balancers": "Balancers",
       "balancerTagRequired": "Tag is required",
       "balancerSelectorRequired": "Pick at least one outbound",
+      "balancerLive": "Live Target",
+      "balancerOverride": "Override",
+      "balancerOverridePh": "Auto (strategy)",
+      "balancerLiveRefresh": "Refresh live balancer state",
+      "balancerNotRunning": "This balancer is not active in the running Xray — save your changes or start Xray first",
+      "routeTester": "Route Tester",
+      "routeTesterDesc": "Ask the running Xray which outbound would handle a connection. No traffic is sent — the decision comes straight from the live routing engine.",
+      "routeTesterDest": "Domain or IP",
+      "routeTesterPort": "Port",
+      "routeTesterInbound": "Inbound",
+      "routeTesterProtocol": "Sniffed protocol",
+      "routeTesterTest": "Test Route",
+      "routeTesterMatchedOutbound": "Matched outbound",
+      "routeTesterViaBalancer": "via balancer",
+      "routeTesterDefaultOutbound": "No routing rule matched — traffic goes to the default (first) outbound.",
       "OutboundsDesc": "Set the outgoing traffic pathway.",
       "Routings": "Routing Rules",
       "RoutingsDesc": "The priority of each rule is important!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Debe empezar con '/' y terminar con.",
       "pageSize": "Tamaño de paginación",
       "pageSizeDesc": "Defina el tamaño de página para la tabla de entradas. Establezca 0 para desactivar",
-      "panelProxy": "Proxy de red del panel",
-      "panelProxyDesc": "Enruta las peticiones salientes del propio panel (actualizaciones de geo, comprobaciones de versión de Xray/panel, Telegram) a través de este proxy para sortear el filtrado de GitHub/Telegram en el servidor. Acepta socks5:// o http(s)://, p. ej. una entrada SOCKS local de Xray. Deja vacío para conexión directa.",
+      "panelOutbound": "Salida del tráfico del panel",
+      "panelOutboundDesc": "Enruta las peticiones del propio panel — comprobaciones de versión y descargas de panel/Xray, Telegram y la actualización normal de archivos geo — a través de esta salida de Xray para sortear el filtrado de GitHub/Telegram en el servidor. Una entrada puente local se añade automáticamente a la configuración en ejecución y se aplica en vivo. La Autoactualización de Geodata nativa de Xray no se ve afectada; tiene su propia salida de descarga. Deja vacío para conexión directa.",
+      "panelOutboundPh": "Conexión directa",
       "remarkModel": "Modelo de observación y carácter de separación",
       "datepicker": "selector de fechas",
       "datepickerPlaceholder": "Seleccionar fecha",
@@ -1183,6 +1184,21 @@
       "Balancers": "Equilibradores",
       "balancerTagRequired": "La etiqueta es obligatoria",
       "balancerSelectorRequired": "Elige al menos una salida",
+      "balancerLive": "Destino actual",
+      "balancerOverride": "Forzar destino",
+      "balancerOverridePh": "Automático (estrategia)",
+      "balancerLiveRefresh": "Actualizar estado del balanceador",
+      "balancerNotRunning": "Este balanceador no está activo en el Xray en ejecución — guarda los cambios o inicia Xray primero",
+      "routeTester": "Prueba de ruta",
+      "routeTesterDesc": "Pregunta al Xray en ejecución qué salida gestionaría una conexión. No se envía tráfico real — la decisión viene directamente del motor de enrutamiento en vivo.",
+      "routeTesterDest": "Dominio o IP",
+      "routeTesterPort": "Puerto",
+      "routeTesterInbound": "Entrante",
+      "routeTesterProtocol": "Protocolo detectado",
+      "routeTesterTest": "Probar ruta",
+      "routeTesterMatchedOutbound": "Salida coincidente",
+      "routeTesterViaBalancer": "vía balanceador",
+      "routeTesterDefaultOutbound": "Ninguna regla de enrutamiento coincidió — el tráfico va a la salida predeterminada (primera).",
       "OutboundsDesc": "Cambia la plantilla de configuración para definir formas de salida para este servidor.",
       "Routings": "Reglas de enrutamiento",
       "RoutingsDesc": "¡La prioridad de cada regla es importante!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "برای وب پنل. با '/' شروع‌ و با '/' خاتمه‌ می‌یابد URI مسیر",
       "pageSize": "اندازه صفحه بندی جدول",
       "pageSizeDesc": "(اندازه صفحه برای جدول ورودی‌ها.(0 = غیرفعال",
-      "panelProxy": "پراکسی شبکه پنل",
-      "panelProxyDesc": "درخواست‌های خروجی خود پنل (به‌روزرسانی geo، بررسی نسخه Xray/پنل، تلگرام) را از این پراکسی عبور می‌دهد تا فیلترینگ GitHub/تلگرام در سرور دور زده شود. socks5:// یا http(s):// قابل قبول است، مثل ورودی SOCKS محلی Xray. برای اتصال مستقیم خالی بگذارید.",
+      "panelOutbound": "اوتباند ترافیک پنل",
+      "panelOutboundDesc": "درخواست‌های خود پنل — بررسی نسخه و دانلود پنل/Xray، تلگرام، و به‌روزرسانی معمولی فایل‌های geo — را از این اوتباند Xray عبور می‌دهد تا فیلترینگ GitHub/تلگرام در سمت سرور دور زده شود. یک ورودی پل لوکال به‌صورت خودکار به کانفیگ در حال اجرا اضافه و زنده اعمال می‌شود. روی Geodata Auto-Update نِیتیو Xray اثری ندارد؛ آن اوتباند دانلود مخصوص خودش را دارد. برای اتصال مستقیم خالی بگذارید.",
+      "panelOutboundPh": "اتصال مستقیم",
       "remarkModel": "نام‌کانفیگ و جداکننده",
       "datepicker": "نوع تقویم",
       "datepickerPlaceholder": "انتخاب تاریخ",
@@ -1183,6 +1184,21 @@
       "Balancers": "بالانسرها",
       "balancerTagRequired": "تگ الزامی است",
       "balancerSelectorRequired": "حداقل یک خروجی انتخاب کنید",
+      "balancerLive": "هدف زنده",
+      "balancerOverride": "اجبار مسیر",
+      "balancerOverridePh": "خودکار (استراتژی)",
+      "balancerLiveRefresh": "به‌روزرسانی وضعیت زنده بالانسر",
+      "balancerNotRunning": "این بالانسر در Xray در حال اجرا فعال نیست — ابتدا تغییرات را ذخیره کنید یا Xray را روشن کنید",
+      "routeTester": "آزمایش مسیر",
+      "routeTesterDesc": "از Xray در حال اجرا بپرسید یک اتصال از کدام خروجی عبور می‌کند. هیچ ترافیکی ارسال نمی‌شود — پاسخ مستقیماً از موتور مسیریابی زنده می‌آید.",
+      "routeTesterDest": "دامنه یا IP",
+      "routeTesterPort": "پورت",
+      "routeTesterInbound": "ورودی",
+      "routeTesterProtocol": "پروتکل تشخیصی",
+      "routeTesterTest": "آزمایش مسیر",
+      "routeTesterMatchedOutbound": "خروجی منطبق",
+      "routeTesterViaBalancer": "از طریق بالانسر",
+      "routeTesterDefaultOutbound": "هیچ قانونی منطبق نشد — ترافیک به خروجی پیش‌فرض (اولین خروجی) می‌رود.",
       "OutboundsDesc": "مسیر ترافیک خروجی را تنظیم کنید",
       "Routings": "قوانین مسیریابی",
       "RoutingsDesc": "اولویت هر قانون مهم است",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "URI path untuk panel web. (dimulai dengan ‘/‘ dan diakhiri dengan ‘/‘)",
       "pageSize": "Ukuran Halaman",
       "pageSizeDesc": "Tentukan ukuran halaman untuk tabel masuk. (0 = nonaktif)",
-      "panelProxy": "Proxy jaringan panel",
-      "panelProxyDesc": "Mengarahkan permintaan keluar panel sendiri (pembaruan geo, pemeriksaan versi Xray/panel, Telegram) melalui proxy ini untuk melewati pemfilteran GitHub/Telegram di sisi server. Menerima socks5:// atau http(s)://, mis. inbound SOCKS lokal Xray. Kosongkan untuk koneksi langsung.",
+      "panelOutbound": "Outbound lalu lintas panel",
+      "panelOutboundDesc": "Mengarahkan permintaan panel sendiri — pemeriksaan versi dan unduhan panel/Xray, Telegram, dan pembaruan file geo biasa — melalui outbound Xray ini untuk melewati pemfilteran GitHub/Telegram di sisi server. Inbound jembatan lokal ditambahkan secara otomatis ke konfigurasi yang berjalan dan diterapkan langsung. Pembaruan Otomatis Geodata bawaan Xray tidak terpengaruh; ia memiliki outbound unduhan sendiri. Kosongkan untuk koneksi langsung.",
+      "panelOutboundPh": "Koneksi langsung",
       "remarkModel": "Model Catatan & Karakter Pemisah",
       "datepicker": "Jenis Kalender",
       "datepickerPlaceholder": "Pilih tanggal",
@@ -1183,6 +1184,21 @@
       "Balancers": "Penyeimbang",
       "balancerTagRequired": "Tag wajib diisi",
       "balancerSelectorRequired": "Pilih setidaknya satu outbound",
+      "balancerLive": "Target Saat Ini",
+      "balancerOverride": "Paksa Tujuan",
+      "balancerOverridePh": "Otomatis (strategi)",
+      "balancerLiveRefresh": "Perbarui status penyeimbang beban",
+      "balancerNotRunning": "Penyeimbang ini tidak aktif di Xray yang berjalan — simpan perubahan atau mulai Xray terlebih dahulu",
+      "routeTester": "Uji Rute",
+      "routeTesterDesc": "Tanyakan kepada Xray yang berjalan outbound mana yang akan menangani koneksi. Tidak ada lalu lintas yang dikirim — keputusan langsung datang dari mesin routing langsung.",
+      "routeTesterDest": "Domain atau IP",
+      "routeTesterPort": "Port",
+      "routeTesterInbound": "Inbound",
+      "routeTesterProtocol": "Protokol yang terdeteksi",
+      "routeTesterTest": "Uji Rute",
+      "routeTesterMatchedOutbound": "Outbound yang cocok",
+      "routeTesterViaBalancer": "melalui penyeimbang",
+      "routeTesterDefaultOutbound": "Tidak ada aturan routing yang cocok — lalu lintas menuju outbound default (pertama).",
       "OutboundsDesc": "Atur jalur lalu lintas keluar.",
       "Routings": "Aturan Pengalihan",
       "RoutingsDesc": "Prioritas setiap aturan penting!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "'/'で始まり、'/'で終わる必要があります",
       "pageSize": "ページサイズ",
       "pageSizeDesc": "インバウンドテーブルのページサイズを定義します。0を設定すると無効化されます",
-      "panelProxy": "パネルネットワークプロキシ",
-      "panelProxyDesc": "パネル自体のアウトバウンドリクエスト (geo 更新、Xray/パネルバージョンチェック、Telegram) をこのプロキシ経由でルーティングし、サーバー側の GitHub/Telegram フィルタリングを回避します。socks5:// または http(s):// を受け付けます。例: ローカルの Xray SOCKS インバウンド。直接接続するには空のままにします。",
+      "panelOutbound": "パネルトラフィックのアウトバウンド",
+      "panelOutboundDesc": "パネル自体のリクエスト (パネル/Xray のバージョンチェックとダウンロード、Telegram、通常の geo ファイル更新) をこの Xray アウトバウンド経由でルーティングし、サーバー側の GitHub/Telegram フィルタリングを回避します。ローカルのブリッジインバウンドが実行中の設定に自動的に追加され、ライブで適用されます。Xray ネイティブの Geodata 自動更新は影響を受けません。専用のダウンロードアウトバウンドを持ちます。直接接続するには空のままにします。",
+      "panelOutboundPh": "直接接続",
       "remarkModel": "備考モデルと区切り記号",
       "datepicker": "日付ピッカー",
       "datepickerPlaceholder": "日付を選択",
@@ -1183,6 +1184,21 @@
       "Balancers": "負荷分散",
       "balancerTagRequired": "タグは必須です",
       "balancerSelectorRequired": "アウトバウンドを少なくとも1つ選んでください",
+      "balancerLive": "現在のターゲット",
+      "balancerOverride": "ターゲット強制",
+      "balancerOverridePh": "自動(ストラテジー)",
+      "balancerLiveRefresh": "ロードバランサーのライブ状態を更新",
+      "balancerNotRunning": "このバランサーは実行中の Xray でアクティブではありません — 変更を保存するか、先に Xray を起動してください",
+      "routeTester": "ルートテスト",
+      "routeTesterDesc": "実行中の Xray にどのアウトバウンドが接続を処理するか問い合わせます。実際のトラフィックは送信されません — 判断はライブルーティングエンジンから直接取得されます。",
+      "routeTesterDest": "ドメインまたは IP",
+      "routeTesterPort": "ポート",
+      "routeTesterInbound": "インバウンド",
+      "routeTesterProtocol": "検出されたプロトコル",
+      "routeTesterTest": "ルートをテスト",
+      "routeTesterMatchedOutbound": "マッチしたアウトバウンド",
+      "routeTesterViaBalancer": "バランサー経由",
+      "routeTesterDefaultOutbound": "ルーティングルールに一致しませんでした — トラフィックはデフォルト(最初の)アウトバウンドに送られます。",
       "OutboundsDesc": "アウトバウンドトラフィックの送信方法を設定する",
       "Routings": "ルーティングルール",
       "RoutingsDesc": "各ルールの優先順位が重要です",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "O caminho URI para o painel web. (começa com ‘/‘ e termina com ‘/‘)",
       "pageSize": "Tamanho da Paginação",
       "pageSizeDesc": "Definir o tamanho da página para a tabela de entradas. (0 = desativado)",
-      "panelProxy": "Proxy de rede do painel",
-      "panelProxyDesc": "Encaminha as requisições de saída do próprio painel (atualizações de geo, verificações de versão do Xray/painel, Telegram) por este proxy para contornar a filtragem de GitHub/Telegram no servidor. Aceita socks5:// ou http(s)://, ex. uma entrada SOCKS local do Xray. Deixe vazio para conexão direta.",
+      "panelOutbound": "Saída do tráfego do painel",
+      "panelOutboundDesc": "Encaminha as requisições do próprio painel — verificações de versão e downloads do painel/Xray, Telegram e a atualização normal de arquivos geo — por esta saída do Xray para contornar a filtragem de GitHub/Telegram no servidor. Uma entrada ponte local é adicionada automaticamente à configuração em execução e aplicada ao vivo. A Atualização Automática de Geodata nativa do Xray não é afetada; ela tem sua própria saída de download. Deixe vazio para conexão direta.",
+      "panelOutboundPh": "Conexão direta",
       "remarkModel": "Modelo de Observação & Caractere de Separação",
       "datepicker": "Tipo de Calendário",
       "datepickerPlaceholder": "Selecionar data",
@@ -1183,6 +1184,21 @@
       "Balancers": "Balanceadores",
       "balancerTagRequired": "A tag é obrigatória",
       "balancerSelectorRequired": "Selecione pelo menos uma saída",
+      "balancerLive": "Destino atual",
+      "balancerOverride": "Forçar destino",
+      "balancerOverridePh": "Automático (estratégia)",
+      "balancerLiveRefresh": "Atualizar estado do balanceador",
+      "balancerNotRunning": "Este balanceador não está ativo no Xray em execução — salve as alterações ou inicie o Xray primeiro",
+      "routeTester": "Teste de rota",
+      "routeTesterDesc": "Pergunte ao Xray em execução qual saída trataria uma conexão. Nenhum tráfego é enviado — a decisão vem diretamente do motor de roteamento ao vivo.",
+      "routeTesterDest": "Domínio ou IP",
+      "routeTesterPort": "Porta",
+      "routeTesterInbound": "Entrada",
+      "routeTesterProtocol": "Protocolo detectado",
+      "routeTesterTest": "Testar rota",
+      "routeTesterMatchedOutbound": "Saída correspondente",
+      "routeTesterViaBalancer": "via balanceador",
+      "routeTesterDefaultOutbound": "Nenhuma regra de roteamento correspondeu — o tráfego vai para a saída padrão (primeira).",
       "OutboundsDesc": "Definir o caminho de saída do tráfego.",
       "Routings": "Regras de Roteamento",
       "RoutingsDesc": "A prioridade de cada regra é importante!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Должен начинаться с '/' и заканчиваться '/'",
       "pageSize": "Размер нумерации страниц",
       "pageSizeDesc": "Определить размер страницы для таблицы подключений. Установите 0, чтобы отключить",
-      "panelProxy": "Сетевой прокси панели",
-      "panelProxyDesc": "Маршрутизирует исходящие запросы самой панели (обновления geo, проверки версий Xray/панели, Telegram) через этот прокси для обхода серверной фильтрации GitHub/Telegram. Принимает socks5:// или http(s)://, напр. локальный SOCKS-входящий Xray. Оставьте пустым для прямого подключения.",
+      "panelOutbound": "Исходящий для трафика панели",
+      "panelOutboundDesc": "Маршрутизирует собственные запросы панели — проверки версий и загрузки панели/Xray, Telegram и обычное обновление geo-файлов — через этот исходящий Xray для обхода серверной фильтрации GitHub/Telegram. Локальный мост-входящий добавляется в работающую конфигурацию автоматически и применяется на лету. Встроенное в Xray автообновление Geodata не затрагивается; у него свой исходящий для загрузки. Оставьте пустым для прямого подключения.",
+      "panelOutboundPh": "Прямое подключение",
       "remarkModel": "Модель примечания и символ разделения",
       "datepicker": "Тип календаря",
       "datepickerPlaceholder": "Выберите дату",
@@ -1183,6 +1184,21 @@
       "Balancers": "Балансировщик",
       "balancerTagRequired": "Тег обязателен",
       "balancerSelectorRequired": "Выберите хотя бы одно исходящее",
+      "balancerLive": "Текущая цель",
+      "balancerOverride": "Переопределить",
+      "balancerOverridePh": "Авто (стратегия)",
+      "balancerLiveRefresh": "Обновить состояние балансировщика",
+      "balancerNotRunning": "Этот балансировщик неактивен в запущенном Xray — сохраните изменения или запустите Xray",
+      "routeTester": "Тест маршрута",
+      "routeTesterDesc": "Спросите запущенный Xray, через какой исходящий будет обработано соединение. Трафик не отправляется — решение поступает напрямую от живого движка маршрутизации.",
+      "routeTesterDest": "Домен или IP",
+      "routeTesterPort": "Порт",
+      "routeTesterInbound": "Входящий",
+      "routeTesterProtocol": "Обнаруженный протокол",
+      "routeTesterTest": "Тест маршрута",
+      "routeTesterMatchedOutbound": "Совпавший исходящий",
+      "routeTesterViaBalancer": "через балансировщик",
+      "routeTesterDefaultOutbound": "Ни одно правило маршрутизации не совпало — трафик направляется в исходящий по умолчанию (первый).",
       "OutboundsDesc": "Изменение шаблона конфигурации, чтобы определить исходящие подключения для этого сервера",
       "Routings": "Маршрутизация",
       "RoutingsDesc": "Важен приоритет каждого правила!",

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

@@ -937,8 +937,9 @@
       "panelUrlPathDesc": "Web paneli için URI yolu. ('/' ile başlar ve '/' ile biter)",
       "pageSize": "Sayfa Boyutu",
       "pageSizeDesc": "Gelen Bağlantılar tablosu için sayfa boyutunu belirler. (0 = devre dışı)",
-      "panelProxy": "Panel Ağ Proxy'si",
-      "panelProxyDesc": "Panelin kendi giden isteklerini (geo güncellemeleri, Xray/panel sürüm kontrolleri, Telegram) bu proxy üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini atlatmak için. socks5:// veya http(s):// kabul eder, örn. yerel bir Xray SOCKS gelen bağlantı. Doğrudan bağlantı için boş bırakın.",
+      "panelOutbound": "Panel Trafiği Gideni",
+      "panelOutboundDesc": "Panelin kendi isteklerini — panel/Xray sürüm kontrolleri ve indirmeleri, Telegram ve normal geo dosyası güncellemesi — bu Xray gideni üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini aşmak için. Yerel bir köprü gelen bağlantısı çalışan yapılandırmaya otomatik eklenir ve canlı uygulanır. Xray'in yerel Geodata Otomatik Güncellemesi etkilenmez; kendi indirme gidenine sahiptir. Doğrudan bağlantı için boş bırakın.",
+      "panelOutboundPh": "Doğrudan bağlantı",
       "remarkModel": "Açıklama Modeli ve Ayırma Karakteri",
       "datepicker": "Takvim Türü",
       "datepickerPlaceholder": "Tarih Seçin",
@@ -1184,6 +1185,21 @@
       "Balancers": "Dengeleyiciler",
       "balancerTagRequired": "Etiket zorunludur",
       "balancerSelectorRequired": "En az bir giden bağlantı seçin",
+      "balancerLive": "Anlık Hedef",
+      "balancerOverride": "Hedef Zorla",
+      "balancerOverridePh": "Otomatik (strateji)",
+      "balancerLiveRefresh": "Dengeleyici durumunu yenile",
+      "balancerNotRunning": "Bu dengeleyici çalışan Xray'de etkin değil — değişikliklerinizi kaydedin veya önce Xray'i başlatın",
+      "routeTester": "Rota Testi",
+      "routeTesterDesc": "Çalışan Xray'e hangi giden bağlantının bir isteği işleyeceğini sorun. Gerçek trafik gönderilmez — karar doğrudan canlı yönlendirme motorundan gelir.",
+      "routeTesterDest": "Alan adı veya IP",
+      "routeTesterPort": "Port",
+      "routeTesterInbound": "Gelen",
+      "routeTesterProtocol": "Algılanan protokol",
+      "routeTesterTest": "Rotayı Test Et",
+      "routeTesterMatchedOutbound": "Eşleşen giden",
+      "routeTesterViaBalancer": "dengeleyici aracılığıyla",
+      "routeTesterDefaultOutbound": "Hiçbir yönlendirme kuralı eşleşmedi — trafik varsayılan (ilk) giden bağlantıya yönlendirilir.",
       "OutboundsDesc": "Giden trafiğin yolunu ayarlayın.",
       "Routings": "Yönlendirme Kuralları",
       "RoutingsDesc": "Her kuralın önceliği önemlidir!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Шлях URL для веб-панелі. (починається з ‘/‘ і закінчується ‘/‘)",
       "pageSize": "Розмір сторінки",
       "pageSizeDesc": "Визначити розмір сторінки для вхідної таблиці. (0 = вимкнено)",
-      "panelProxy": "Мережевий проксі панелі",
-      "panelProxyDesc": "Маршрутизує власні вихідні запити панелі (оновлення geo, перевірки версій Xray/панелі, Telegram) через цей проксі для обходу фільтрації GitHub/Telegram на стороні сервера. Приймає socks5:// або http(s)://, напр. локальний SOCKS-вхідний Xray. Залиште порожнім для прямого підключення.",
+      "panelOutbound": "Вихідний для трафіку панелі",
+      "panelOutboundDesc": "Маршрутизує власні запити панелі — перевірки версій і завантаження панелі/Xray, Telegram та звичайне оновлення geo-файлів — через цей вихідний Xray для обходу фільтрації GitHub/Telegram на стороні сервера. Локальний міст-вхідний додається до робочої конфігурації автоматично і застосовується наживо. Вбудоване в Xray автооновлення Geodata не зачіпається; воно має власний вихідний для завантаження. Залиште порожнім для прямого підключення.",
+      "panelOutboundPh": "Пряме підключення",
       "remarkModel": "Модель зауваження та роздільний символ",
       "datepicker": "Тип календаря",
       "datepickerPlaceholder": "Виберіть дату",
@@ -1183,6 +1184,21 @@
       "Balancers": "Балансери",
       "balancerTagRequired": "Тег обов'язковий",
       "balancerSelectorRequired": "Виберіть принаймні один вихідний",
+      "balancerLive": "Поточна ціль",
+      "balancerOverride": "Примусова ціль",
+      "balancerOverridePh": "Авто (стратегія)",
+      "balancerLiveRefresh": "Оновити стан балансувальника",
+      "balancerNotRunning": "Цей балансувальник неактивний у запущеному Xray — збережіть зміни або спочатку запустіть Xray",
+      "routeTester": "Тест маршруту",
+      "routeTesterDesc": "Запитайте запущений Xray, через який вихідний буде оброблено з'єднання. Реальний трафік не надсилається — рішення надходить безпосередньо від живого рушія маршрутизації.",
+      "routeTesterDest": "Домен або IP",
+      "routeTesterPort": "Порт",
+      "routeTesterInbound": "Вхідний",
+      "routeTesterProtocol": "Виявлений протокол",
+      "routeTesterTest": "Тест маршруту",
+      "routeTesterMatchedOutbound": "Відповідний вихідний",
+      "routeTesterViaBalancer": "через балансувальник",
+      "routeTesterDefaultOutbound": "Жодне правило маршрутизації не збіглося — трафік надходить до вихідного за замовчуванням (першого).",
       "OutboundsDesc": "Встановити шлях вихідного трафіку.",
       "Routings": "Правила маршрутизації",
       "RoutingsDesc": "Пріоритет кожного правила важливий!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "Phải bắt đầu và kết thúc bằng '/'",
       "pageSize": "Kích thước phân trang",
       "pageSizeDesc": "Xác định kích thước trang cho bảng gửi đến. Đặt 0 để tắt",
-      "panelProxy": "Proxy mạng của bảng điều khiển",
-      "panelProxyDesc": "Định tuyến các yêu cầu đi của chính bảng điều khiển (cập nhật geo, kiểm tra phiên bản Xray/panel, Telegram) qua proxy này để vượt qua lọc GitHub/Telegram phía máy chủ. Chấp nhận socks5:// hoặc http(s)://, ví dụ inbound SOCKS cục bộ của Xray. Để trống để kết nối trực tiếp.",
+      "panelOutbound": "Outbound cho lưu lượng panel",
+      "panelOutboundDesc": "Định tuyến các yêu cầu của chính bảng điều khiển — kiểm tra phiên bản và tải xuống panel/Xray, Telegram, và cập nhật tệp geo thông thường — qua outbound Xray này để vượt qua lọc GitHub/Telegram phía máy chủ. Một inbound cầu nối cục bộ được tự động thêm vào cấu hình đang chạy và áp dụng trực tiếp. Tính năng Tự động cập nhật Geodata gốc của Xray không bị ảnh hưởng; nó có outbound tải xuống riêng. Để trống để kết nối trực tiếp.",
+      "panelOutboundPh": "Kết nối trực tiếp",
       "remarkModel": "Ghi chú mô hình và ký tự phân tách",
       "datepicker": "Kiểu lịch",
       "datepickerPlaceholder": "Chọn ngày",
@@ -1183,6 +1184,21 @@
       "Balancers": "Cân bằng",
       "balancerTagRequired": "Tag là bắt buộc",
       "balancerSelectorRequired": "Chọn ít nhất một outbound",
+      "balancerLive": "Mục tiêu hiện tại",
+      "balancerOverride": "Ghi đè đích",
+      "balancerOverridePh": "Tự động (chiến lược)",
+      "balancerLiveRefresh": "Làm mới trạng thái bộ cân bằng tải",
+      "balancerNotRunning": "Bộ cân bằng này không hoạt động trong Xray đang chạy — hãy lưu thay đổi hoặc khởi động Xray trước",
+      "routeTester": "Kiểm tra tuyến đường",
+      "routeTesterDesc": "Hỏi Xray đang chạy outbound nào sẽ xử lý kết nối. Không có lưu lượng nào được gửi — quyết định đến thẳng từ công cụ định tuyến trực tiếp.",
+      "routeTesterDest": "Tên miền hoặc IP",
+      "routeTesterPort": "Cổng",
+      "routeTesterInbound": "Inbound",
+      "routeTesterProtocol": "Giao thức nhận diện",
+      "routeTesterTest": "Kiểm tra tuyến",
+      "routeTesterMatchedOutbound": "Outbound phù hợp",
+      "routeTesterViaBalancer": "qua bộ cân bằng tải",
+      "routeTesterDefaultOutbound": "Không có quy tắc định tuyến nào khớp — lưu lượng đến outbound mặc định (đầu tiên).",
       "OutboundsDesc": "Thay đổi mẫu cấu hình để xác định các cách ra đi cho máy chủ này.",
       "Routings": "Quy tắc định tuyến",
       "RoutingsDesc": "Mức độ ưu tiên của mỗi quy tắc đều quan trọng!",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "必须以 '/' 开头,以 '/' 结尾",
       "pageSize": "分页大小",
       "pageSizeDesc": "定义入站表的页面大小。设置 0 表示禁用",
-      "panelProxy": "面板网络代理",
-      "panelProxyDesc": "通过此代理路由面板自身的出站请求(geo 更新、Xray/面板版本检查、Telegram),以绕过服务端对 GitHub/Telegram 的过滤。接受 socks5:// 或 http(s)://,如本地 Xray SOCKS 入站。留空表示直连。",
+      "panelOutbound": "面板流量出站",
+      "panelOutboundDesc": "通过此 Xray 出站路由面板自身的请求(面板/Xray 版本检查与下载、Telegram、普通 geo 文件更新),以绕过服务端对 GitHub/Telegram 的过滤。本地桥接入站会自动添加到运行中的配置并实时生效。Xray 原生的 Geodata 自动更新不受影响,它有自己的下载出站。留空表示直连。",
+      "panelOutboundPh": "直连",
       "remarkModel": "备注模型和分隔符",
       "datepicker": "日期选择器",
       "datepickerPlaceholder": "选择日期",
@@ -1183,6 +1184,21 @@
       "Balancers": "负载均衡",
       "balancerTagRequired": "标签为必填项",
       "balancerSelectorRequired": "至少选择一个出站",
+      "balancerLive": "当前目标",
+      "balancerOverride": "强制指定",
+      "balancerOverridePh": "自动(策略)",
+      "balancerLiveRefresh": "刷新负载均衡器实时状态",
+      "balancerNotRunning": "此负载均衡器在运行中的 Xray 未激活 — 请先保存更改或启动 Xray",
+      "routeTester": "路由测试",
+      "routeTesterDesc": "向运行中的 Xray 查询某个连接将使用哪个出站。不会发送真实流量 — 结果直接来自实时路由引擎。",
+      "routeTesterDest": "域名或 IP",
+      "routeTesterPort": "端口",
+      "routeTesterInbound": "入站",
+      "routeTesterProtocol": "嗅探协议",
+      "routeTesterTest": "测试路由",
+      "routeTesterMatchedOutbound": "匹配出站",
+      "routeTesterViaBalancer": "经由负载均衡器",
+      "routeTesterDefaultOutbound": "无路由规则匹配 — 流量将发往默认(第一个)出站。",
       "OutboundsDesc": "设置出站流量传出方式",
       "Routings": "路由规则",
       "RoutingsDesc": "每条规则的优先级都很重要",

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

@@ -938,8 +938,9 @@
       "panelUrlPathDesc": "必須以 '/' 開頭,以 '/' 結尾",
       "pageSize": "分頁大小",
       "pageSizeDesc": "定義入站表的頁面大小。設定 0 表示禁用",
-      "panelProxy": "面板網路代理",
-      "panelProxyDesc": "透過此代理路由面板自身的出站請求(geo 更新、Xray/面板版本檢查、Telegram),以繞過伺服器端對 GitHub/Telegram 的過濾。接受 socks5:// 或 http(s)://,如本地 Xray SOCKS 入站。留空表示直連。",
+      "panelOutbound": "面板流量出站",
+      "panelOutboundDesc": "透過此 Xray 出站路由面板自身的請求(面板/Xray 版本檢查與下載、Telegram、一般 geo 檔案更新),以繞過伺服器端對 GitHub/Telegram 的過濾。本地橋接入站會自動加入執行中的設定並即時生效。Xray 原生的 Geodata 自動更新不受影響,它有自己的下載出站。留空表示直連。",
+      "panelOutboundPh": "直連",
       "remarkModel": "備註模型和分隔符",
       "datepicker": "日期選擇器",
       "datepickerPlaceholder": "選擇日期",
@@ -1183,6 +1184,21 @@
       "Balancers": "負載均衡",
       "balancerTagRequired": "標籤為必填",
       "balancerSelectorRequired": "至少選擇一個出站",
+      "balancerLive": "目前目標",
+      "balancerOverride": "強制指定",
+      "balancerOverridePh": "自動(策略)",
+      "balancerLiveRefresh": "重新整理負載均衡器即時狀態",
+      "balancerNotRunning": "此負載均衡器在執行中的 Xray 未啟用 — 請先儲存變更或啟動 Xray",
+      "routeTester": "路由測試",
+      "routeTesterDesc": "向執行中的 Xray 查詢某個連線將使用哪個出站。不會傳送真實流量 — 結果直接來自即時路由引擎。",
+      "routeTesterDest": "網域或 IP",
+      "routeTesterPort": "埠號",
+      "routeTesterInbound": "入站",
+      "routeTesterProtocol": "嗅探協議",
+      "routeTesterTest": "測試路由",
+      "routeTesterMatchedOutbound": "匹配出站",
+      "routeTesterViaBalancer": "經由負載均衡器",
+      "routeTesterDefaultOutbound": "無路由規則匹配 — 流量將導向預設(第一個)出站。",
       "OutboundsDesc": "設定出站流量傳出方式",
       "Routings": "路由規則",
       "RoutingsDesc": "每條規則的優先順序都很重要",

+ 250 - 0
internal/xray/api.go

@@ -8,14 +8,21 @@ import (
 	"encoding/json"
 	"fmt"
 	"math"
+	"net"
+	"os"
+	"path/filepath"
 	"regexp"
+	"strings"
 	"time"
 
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 
 	"github.com/xtls/xray-core/app/proxyman/command"
+	routerService "github.com/xtls/xray-core/app/router/command"
 	statsService "github.com/xtls/xray-core/app/stats/command"
+	xnet "github.com/xtls/xray-core/common/net"
 	"github.com/xtls/xray-core/common/protocol"
 	"github.com/xtls/xray-core/common/serial"
 	"github.com/xtls/xray-core/infra/conf"
@@ -33,6 +40,7 @@ import (
 type XrayAPI struct {
 	HandlerServiceClient *command.HandlerServiceClient
 	StatsServiceClient   *statsService.StatsServiceClient
+	RoutingServiceClient *routerService.RoutingServiceClient
 	grpcClient           *grpc.ClientConn
 	isConnected          bool
 	StatsLastValues      map[string]int64
@@ -86,9 +94,11 @@ func (x *XrayAPI) Init(apiPort int) error {
 
 	hsClient := command.NewHandlerServiceClient(conn)
 	ssClient := statsService.NewStatsServiceClient(conn)
+	rsClient := routerService.NewRoutingServiceClient(conn)
 
 	x.HandlerServiceClient = &hsClient
 	x.StatsServiceClient = &ssClient
+	x.RoutingServiceClient = &rsClient
 
 	return nil
 }
@@ -100,6 +110,7 @@ func (x *XrayAPI) Close() {
 	}
 	x.HandlerServiceClient = nil
 	x.StatsServiceClient = nil
+	x.RoutingServiceClient = nil
 	x.isConnected = false
 }
 
@@ -134,6 +145,245 @@ func (x *XrayAPI) DelInbound(tag string) error {
 	return err
 }
 
+// AddOutbound adds a new outbound configuration to the Xray core via gRPC.
+func (x *XrayAPI) AddOutbound(outbound []byte) error {
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
+	client := *x.HandlerServiceClient
+
+	conf := new(conf.OutboundDetourConfig)
+	if err := json.Unmarshal(outbound, conf); err != nil {
+		logger.Debug("Failed to unmarshal outbound:", err)
+		return err
+	}
+	config, err := conf.Build()
+	if err != nil {
+		logger.Debug("Failed to build outbound detour:", err)
+		return err
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	_, err = client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: config})
+	return err
+}
+
+// DelOutbound removes an outbound configuration from the Xray core by tag.
+func (x *XrayAPI) DelOutbound(tag string) error {
+	if x.HandlerServiceClient == nil {
+		return common.NewError("xray HandlerServiceClient is not initialized")
+	}
+	client := *x.HandlerServiceClient
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	_, err := client.RemoveOutbound(ctx, &command.RemoveOutboundRequest{Tag: tag})
+	return err
+}
+
+// ApplyRoutingConfig replaces the routing rules and balancers of the running
+// Xray core with the given routing section (the JSON value of the top-level
+// "routing" key) via the RoutingService gRPC API. Note that this cannot change
+// routing.domainStrategy/domainMatcher — those are fixed at process start.
+func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
+	if x.RoutingServiceClient == nil {
+		return common.NewError("xray RoutingServiceClient is not initialized")
+	}
+
+	// Rules referencing geoip:/geosite: need the dat files; point xray-core's
+	// in-process loader at the panel's bin folder where they live.
+	ensureXrayAssetLocation()
+
+	routerConf := new(conf.RouterConfig)
+	if err := json.Unmarshal(routing, routerConf); err != nil {
+		logger.Debug("Failed to unmarshal routing config:", err)
+		return err
+	}
+	config, err := routerConf.Build()
+	if err != nil {
+		logger.Debug("Failed to build routing config:", err)
+		return err
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+	defer cancel()
+
+	_, err = (*x.RoutingServiceClient).AddRule(ctx, &routerService.AddRuleRequest{
+		ShouldAppend: false,
+		Config:       serial.ToTypedMessage(config),
+	})
+	return err
+}
+
+// BalancerInfo is the live state of one balancer inside the running core.
+type BalancerInfo struct {
+	Tag string `json:"tag"`
+	// Override is the outbound tag an admin forced via the API; empty when
+	// the strategy is in control.
+	Override string `json:"override"`
+	// Selected are the outbound tags the strategy currently prefers, best
+	// first (xray's "principle target" list).
+	Selected []string `json:"selected"`
+}
+
+// GetBalancerInfo queries the running core for a balancer's current override
+// and the targets its strategy would pick right now.
+func (x *XrayAPI) GetBalancerInfo(tag string) (*BalancerInfo, error) {
+	if x.RoutingServiceClient == nil {
+		return nil, common.NewError("xray RoutingServiceClient is not initialized")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	resp, err := (*x.RoutingServiceClient).GetBalancerInfo(ctx, &routerService.GetBalancerInfoRequest{Tag: tag})
+	if err != nil {
+		return nil, err
+	}
+
+	info := &BalancerInfo{Tag: tag}
+	if balancer := resp.GetBalancer(); balancer != nil {
+		if balancer.Override != nil {
+			info.Override = balancer.Override.Target
+		}
+		if balancer.PrincipleTarget != nil {
+			info.Selected = balancer.PrincipleTarget.Tag
+		}
+	}
+	return info, nil
+}
+
+// SetBalancerTarget forces a balancer to always pick the given outbound tag.
+// An empty target clears the override and hands control back to the strategy.
+func (x *XrayAPI) SetBalancerTarget(tag, target string) error {
+	if x.RoutingServiceClient == nil {
+		return common.NewError("xray RoutingServiceClient is not initialized")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	_, err := (*x.RoutingServiceClient).OverrideBalancerTarget(ctx, &routerService.OverrideBalancerTargetRequest{
+		BalancerTag: tag,
+		Target:      target,
+	})
+	return err
+}
+
+// RouteTestRequest describes a synthetic connection to ask the running core
+// which outbound its router would pick for it.
+type RouteTestRequest struct {
+	InboundTag string // optional: simulate arrival on this inbound
+	Domain     string // target domain (sniffed/SOCKS-style destination)
+	IP         string // target IP, used when Domain is empty or alongside it
+	Port       int
+	Network    string // "tcp" (default) or "udp"
+	Protocol   string // optional sniffed protocol: http, tls, bittorrent, ...
+	Email      string // optional user attribution for user-based rules
+}
+
+// RouteTestResult is the routing decision the core reported.
+type RouteTestResult struct {
+	// Matched is false when no routing rule matched — traffic would use the
+	// default (first) outbound and OutboundTag is empty.
+	Matched     bool     `json:"matched"`
+	OutboundTag string   `json:"outboundTag"`
+	// GroupTags lists the balancer chain the decision went through, when any.
+	GroupTags []string `json:"groupTags,omitempty"`
+}
+
+// TestRoute asks the running core's router which outbound it would pick for
+// the described connection, without sending any traffic.
+func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
+	if x.RoutingServiceClient == nil {
+		return nil, common.NewError("xray RoutingServiceClient is not initialized")
+	}
+
+	network := xnet.Network_TCP
+	if strings.EqualFold(req.Network, "udp") {
+		network = xnet.Network_UDP
+	}
+	rc := &routerService.RoutingContext{
+		InboundTag:   req.InboundTag,
+		Network:      network,
+		TargetDomain: req.Domain,
+		TargetPort:   uint32(req.Port),
+		Protocol:     req.Protocol,
+		User:         req.Email,
+	}
+	if req.IP != "" {
+		parsed := net.ParseIP(req.IP)
+		if parsed == nil {
+			return nil, common.NewErrorf("invalid IP address: %s", req.IP)
+		}
+		if v4 := parsed.To4(); v4 != nil {
+			rc.TargetIPs = [][]byte{v4}
+		} else {
+			rc.TargetIPs = [][]byte{parsed.To16()}
+		}
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+
+	resp, err := (*x.RoutingServiceClient).TestRoute(ctx, &routerService.TestRouteRequest{
+		RoutingContext: rc,
+		PublishResult:  false,
+	})
+	if err != nil {
+		// The router reports "no rule matched" as an error; for the caller
+		// that simply means the default outbound takes the traffic.
+		if strings.Contains(strings.ToLower(err.Error()), "not enough information") {
+			return &RouteTestResult{Matched: false}, nil
+		}
+		return nil, err
+	}
+
+	return &RouteTestResult{
+		Matched:     true,
+		OutboundTag: resp.GetOutboundTag(),
+		GroupTags:   resp.GetOutboundGroupTags(),
+	}, nil
+}
+
+// IsMissingHandlerErr reports whether err is xray's response to removing a
+// handler (inbound/outbound) that does not exist — e.g. it was already
+// removed through the runtime API while the panel's config snapshot was
+// stale. Safe to treat as success for removal operations.
+func IsMissingHandlerErr(err error) bool {
+	if err == nil {
+		return false
+	}
+	msg := strings.ToLower(err.Error())
+	return strings.Contains(msg, "not found") ||
+		strings.Contains(msg, "not enough information")
+}
+
+// IsExistingTagErr reports whether err is xray's response to adding a handler
+// whose tag is already taken by a running handler.
+func IsExistingTagErr(err error) bool {
+	if err == nil {
+		return false
+	}
+	return strings.Contains(strings.ToLower(err.Error()), "existing tag")
+}
+
+// ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core
+// config builders run inside the panel process. The xray binary resolves assets
+// relative to its own executable, but the panel binary lives one level above
+// the bin folder, so an explicit location is required.
+func ensureXrayAssetLocation() {
+	if os.Getenv("XRAY_LOCATION_ASSET") != "" || os.Getenv("xray.location.asset") != "" {
+		return
+	}
+	if abs, err := filepath.Abs(config.GetBinFolderPath()); err == nil {
+		os.Setenv("XRAY_LOCATION_ASSET", abs)
+	}
+}
+
 // AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
 func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
 	userEmail, err := getRequiredUserString(user, "email")

+ 240 - 0
internal/xray/api_e2e_test.go

@@ -0,0 +1,240 @@
+package xray
+
+import (
+	"encoding/json"
+	"fmt"
+	"net"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+	"time"
+)
+
+// TestXrayAPI_E2E exercises the gRPC hot-apply surface (outbounds, inbounds,
+// routing) against a real xray-core process. It validates the exact error
+// texts IsMissingHandlerErr/IsExistingTagErr rely on, and that replacing the
+// routing config keeps the api rule working.
+//
+// Skipped unless XRAY_E2E_BINARY points at an xray executable built from the
+// same xray-core version as go.mod, e.g.:
+//
+//	go install github.com/xtls/xray-core/main@<version from go.mod>
+//	XRAY_E2E_BINARY=$GOBIN/main go test ./internal/xray -run TestXrayAPI_E2E -v
+func TestXrayAPI_E2E(t *testing.T) {
+	bin := os.Getenv("XRAY_E2E_BINARY")
+	if bin == "" {
+		t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
+	}
+
+	apiPort := freePort(t)
+	cfg := map[string]any{
+		"log": map[string]any{"loglevel": "warning"},
+		"api": map[string]any{
+			"services": []string{"HandlerService", "StatsService", "RoutingService"},
+			"tag":      "api",
+		},
+		"inbounds": []any{
+			map[string]any{
+				"listen":   "127.0.0.1",
+				"port":     apiPort,
+				"protocol": "tunnel",
+				"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
+				"tag":      "api",
+			},
+		},
+		"outbounds": []any{
+			map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
+			map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
+		},
+		"routing": map[string]any{
+			"domainStrategy": "AsIs",
+			"rules": []any{
+				map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
+			},
+		},
+		"policy": map[string]any{},
+		"stats":  map[string]any{},
+	}
+	cfgBytes, err := json.MarshalIndent(cfg, "", "  ")
+	if err != nil {
+		t.Fatal(err)
+	}
+	cfgPath := filepath.Join(t.TempDir(), "config.json")
+	if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	cmd := exec.Command(bin, "-c", cfgPath)
+	cmd.Stdout = os.Stderr
+	cmd.Stderr = os.Stderr
+	if err := cmd.Start(); err != nil {
+		t.Fatalf("failed to start xray: %v", err)
+	}
+	defer func() {
+		_ = cmd.Process.Kill()
+		_, _ = cmd.Process.Wait()
+	}()
+
+	waitForPort(t, apiPort)
+
+	api := XrayAPI{}
+	if err := api.Init(apiPort); err != nil {
+		t.Fatalf("api init: %v", err)
+	}
+	defer api.Close()
+
+	// --- outbounds ---
+	socksOutbound := []byte(`{"protocol":"socks","settings":{"servers":[{"address":"127.0.0.1","port":10808}]},"tag":"test-out"}`)
+	if err := api.AddOutbound(socksOutbound); err != nil {
+		t.Fatalf("AddOutbound: %v", err)
+	}
+	err = api.AddOutbound(socksOutbound)
+	if err == nil {
+		t.Fatal("duplicate AddOutbound must fail")
+	}
+	if !IsExistingTagErr(err) {
+		t.Fatalf("duplicate AddOutbound error not matched by IsExistingTagErr: %q", err)
+	}
+	if err := api.DelOutbound("test-out"); err != nil {
+		t.Fatalf("DelOutbound: %v", err)
+	}
+	// xray's outbound manager treats removal of an unknown tag as a no-op.
+	if err := api.DelOutbound("test-out"); err != nil && !IsMissingHandlerErr(err) {
+		t.Fatalf("removing a missing outbound: unexpected error %q", err)
+	}
+
+	// --- inbounds ---
+	vlessPort := freePort(t)
+	vlessInbound := fmt.Appendf(nil,
+		`{"listen":"127.0.0.1","port":%d,"protocol":"vless","settings":{"clients":[{"id":"a17e367c-2074-4d3e-aaeb-fbef5dfde7e7","email":"e2e"}],"decryption":"none"},"tag":"test-in"}`,
+		vlessPort)
+	if err := api.AddInbound(vlessInbound); err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+	err = api.AddInbound(vlessInbound)
+	if err == nil {
+		t.Fatal("duplicate AddInbound must fail")
+	}
+	if !IsExistingTagErr(err) {
+		t.Fatalf("duplicate AddInbound error not matched by IsExistingTagErr: %q", err)
+	}
+	if err := api.DelInbound("test-in"); err != nil {
+		t.Fatalf("DelInbound: %v", err)
+	}
+	err = api.DelInbound("test-in")
+	if err == nil {
+		t.Fatal("removing a missing inbound must fail")
+	}
+	if !IsMissingHandlerErr(err) {
+		t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
+	}
+
+	// --- routing (rules + balancers replace) ---
+	newRouting := []byte(`{
+		"domainStrategy": "AsIs",
+		"balancers": [{"tag":"b1","selector":["direct"]}],
+		"rules": [
+			{"type":"field","inboundTag":["api"],"outboundTag":"api"},
+			{"type":"field","port":"6666","outboundTag":"blocked","ruleTag":"e2e-rule"},
+			{"type":"field","port":"7777","balancerTag":"b1","ruleTag":"e2e-balancer-rule"}
+		]
+	}`)
+	if err := api.ApplyRoutingConfig(newRouting); err != nil {
+		t.Fatalf("ApplyRoutingConfig: %v", err)
+	}
+	// The replaced rule set still contains the api rule — the gRPC channel
+	// must keep working after the swap.
+	if err := api.AddOutbound([]byte(`{"protocol":"blackhole","settings":{},"tag":"post-routing"}`)); err != nil {
+		t.Fatalf("api unusable after routing replace (api rule lost?): %v", err)
+	}
+	if err := api.DelOutbound("post-routing"); err != nil {
+		t.Fatalf("DelOutbound after routing replace: %v", err)
+	}
+
+	// --- route testing ---
+	res, err := api.TestRoute(RouteTestRequest{IP: "1.2.3.4", Port: 6666, Network: "tcp"})
+	if err != nil {
+		t.Fatalf("TestRoute(port rule): %v", err)
+	}
+	if !res.Matched || res.OutboundTag != "blocked" {
+		t.Fatalf("TestRoute(port rule) = %+v, want matched blocked", res)
+	}
+	res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
+	if err != nil {
+		t.Fatalf("TestRoute(balancer rule): %v", err)
+	}
+	if !res.Matched || res.OutboundTag != "direct" {
+		t.Fatalf("TestRoute(balancer rule) = %+v, want matched direct", res)
+	}
+	// Note: current xray-core never populates OutboundGroupTags in PickRoute,
+	// so GroupTags stays empty even for balancer rules — don't assert on it.
+	res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 9999, Network: "tcp"})
+	if err != nil {
+		t.Fatalf("TestRoute(no match): %v", err)
+	}
+	if res.Matched {
+		t.Fatalf("TestRoute(no match) = %+v, want unmatched (default outbound)", res)
+	}
+
+	// --- balancer info + override ---
+	info, err := api.GetBalancerInfo("b1")
+	if err != nil {
+		t.Fatalf("GetBalancerInfo: %v", err)
+	}
+	if info.Override != "" {
+		t.Fatalf("fresh balancer must have no override, got %q", info.Override)
+	}
+	if err := api.SetBalancerTarget("b1", "blocked"); err != nil {
+		t.Fatalf("SetBalancerTarget: %v", err)
+	}
+	info, err = api.GetBalancerInfo("b1")
+	if err != nil {
+		t.Fatalf("GetBalancerInfo after override: %v", err)
+	}
+	if info.Override != "blocked" {
+		t.Fatalf("override = %q, want blocked", info.Override)
+	}
+	res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
+	if err != nil {
+		t.Fatalf("TestRoute(overridden balancer): %v", err)
+	}
+	if res.OutboundTag != "blocked" {
+		t.Fatalf("overridden balancer must route to blocked, got %+v", res)
+	}
+	if err := api.SetBalancerTarget("b1", ""); err != nil {
+		t.Fatalf("SetBalancerTarget(clear): %v", err)
+	}
+	info, err = api.GetBalancerInfo("b1")
+	if err != nil {
+		t.Fatalf("GetBalancerInfo after clear: %v", err)
+	}
+	if info.Override != "" {
+		t.Fatalf("override after clear = %q, want empty", info.Override)
+	}
+}
+
+func freePort(t *testing.T) int {
+	t.Helper()
+	l, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer l.Close()
+	return l.Addr().(*net.TCPAddr).Port
+}
+
+func waitForPort(t *testing.T, port int) {
+	t.Helper()
+	deadline := time.Now().Add(15 * time.Second)
+	addr := fmt.Sprintf("127.0.0.1:%d", port)
+	for time.Now().Before(deadline) {
+		conn, err := net.DialTimeout("tcp", addr, time.Second)
+		if err == nil {
+			conn.Close()
+			return
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+	t.Fatalf("xray api port %d did not open in time", port)
+}

+ 6 - 0
internal/xray/config.go

@@ -66,6 +66,12 @@ func (c *Config) Equals(other *Config) bool {
 	if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
 		return false
 	}
+	if !bytes.Equal(c.Observatory, other.Observatory) {
+		return false
+	}
+	if !bytes.Equal(c.BurstObservatory, other.BurstObservatory) {
+		return false
+	}
 	if !bytes.Equal(c.Metrics, other.Metrics) {
 		return false
 	}

+ 361 - 0
internal/xray/hot_diff.go

@@ -0,0 +1,361 @@
+package xray
+
+import (
+	"bytes"
+	"encoding/json"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+// HotDiff describes the gRPC API operations needed to bring a running Xray
+// instance from one generated config to another without restarting the
+// process. It only covers the sections Xray can reload at runtime: inbounds,
+// outbounds and routing rules/balancers.
+type HotDiff struct {
+	RemovedInboundTags  []string
+	AddedInbounds       [][]byte
+	RemovedOutboundTags []string
+	AddedOutbounds      [][]byte
+	RoutingConfig       []byte // full new routing section; nil when unchanged
+}
+
+// Empty reports whether the diff contains no operations.
+func (d *HotDiff) Empty() bool {
+	return len(d.RemovedInboundTags) == 0 &&
+		len(d.AddedInbounds) == 0 &&
+		len(d.RemovedOutboundTags) == 0 &&
+		len(d.AddedOutbounds) == 0 &&
+		d.RoutingConfig == nil
+}
+
+// ComputeHotDiff compares two generated configs and returns the API operations
+// that transform a running instance from oldCfg to newCfg. ok is false when
+// the change touches anything that has no runtime reload API (log, dns,
+// policy, ...) and therefore requires a full process restart.
+func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
+	if oldCfg == nil || newCfg == nil {
+		return nil, false
+	}
+
+	// Sections without a reload API must be semantically identical.
+	// Comparison is whitespace-insensitive: a template save that merely
+	// reformats the JSON (frontend textarea, API clients) must not be
+	// mistaken for a real change that forces a restart.
+	static := []struct {
+		name     string
+		old, new json_util.RawMessage
+	}{
+		{"log", oldCfg.LogConfig, newCfg.LogConfig},
+		{"dns", oldCfg.DNSConfig, newCfg.DNSConfig},
+		{"transport", oldCfg.Transport, newCfg.Transport},
+		{"policy", oldCfg.Policy, newCfg.Policy},
+		{"api", oldCfg.API, newCfg.API},
+		{"stats", oldCfg.Stats, newCfg.Stats},
+		{"reverse", oldCfg.Reverse, newCfg.Reverse},
+		{"fakedns", oldCfg.FakeDNS, newCfg.FakeDNS},
+		{"observatory", oldCfg.Observatory, newCfg.Observatory},
+		{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
+		{"metrics", oldCfg.Metrics, newCfg.Metrics},
+		{"geodata", oldCfg.Geodata, newCfg.Geodata},
+	}
+	for _, section := range static {
+		if !rawEqualNormalized(section.old, section.new) {
+			logger.Debug("hot diff: section [", section.name, "] changed and has no reload API")
+			return nil, false
+		}
+	}
+
+	diff := &HotDiff{}
+
+	if ok := diffInbounds(oldCfg, newCfg, diff); !ok {
+		logger.Debug("hot diff: inbound change is not API-applicable")
+		return nil, false
+	}
+	if ok := diffOutbounds(oldCfg, newCfg, diff); !ok {
+		logger.Debug("hot diff: outbound change is not API-applicable (default outbound or tags)")
+		return nil, false
+	}
+	if ok := diffRouting(oldCfg, newCfg, diff); !ok {
+		logger.Debug("hot diff: routing change is not API-applicable (domainStrategy or section shape)")
+		return nil, false
+	}
+
+	return diff, true
+}
+
+// diffInbounds fills diff with inbound removals/additions (a changed inbound
+// becomes remove+add). The api inbound carries the gRPC server the panel is
+// talking through, so any change touching it forces a restart.
+func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
+	oldByTag, ok := inboundsByTag(oldCfg.InboundConfigs)
+	if !ok {
+		return false
+	}
+	newByTag, ok := inboundsByTag(newCfg.InboundConfigs)
+	if !ok {
+		return false
+	}
+
+	apiTag := apiTagFromConfig(newCfg.API)
+
+	for i := range oldCfg.InboundConfigs {
+		oldIb := &oldCfg.InboundConfigs[i]
+		newIb, exists := newByTag[oldIb.Tag]
+		if exists && inboundEqualNormalized(oldIb, newIb) {
+			continue
+		}
+		if oldIb.Tag == apiTag || oldIb.Tag == "api" {
+			return false
+		}
+		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
+		if exists {
+			raw, err := json.Marshal(newIb)
+			if err != nil {
+				return false
+			}
+			diff.AddedInbounds = append(diff.AddedInbounds, raw)
+		}
+	}
+	for i := range newCfg.InboundConfigs {
+		newIb := &newCfg.InboundConfigs[i]
+		if _, exists := oldByTag[newIb.Tag]; exists {
+			continue
+		}
+		if newIb.Tag == apiTag || newIb.Tag == "api" {
+			return false
+		}
+		raw, err := json.Marshal(newIb)
+		if err != nil {
+			return false
+		}
+		diff.AddedInbounds = append(diff.AddedInbounds, raw)
+	}
+	return true
+}
+
+// diffOutbounds fills diff with outbound removals/additions keyed by tag.
+// The first outbound is xray's default handler and the API can only append,
+// so any change to its identity or content forces a restart. Reordering of
+// the remaining outbounds is ignored — routing addresses them by tag.
+func diffOutbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
+	oldOut, ok := parseOutbounds(oldCfg.OutboundConfigs)
+	if !ok {
+		return false
+	}
+	newOut, ok := parseOutbounds(newCfg.OutboundConfigs)
+	if !ok {
+		return false
+	}
+
+	if (len(oldOut) == 0) != (len(newOut) == 0) {
+		return false
+	}
+	if len(oldOut) > 0 {
+		if oldOut[0].tag != newOut[0].tag || !bytes.Equal(oldOut[0].norm, newOut[0].norm) {
+			return false
+		}
+	}
+
+	oldByTag := make(map[string]outboundEntry, len(oldOut))
+	for _, e := range oldOut {
+		oldByTag[e.tag] = e
+	}
+	newByTag := make(map[string]outboundEntry, len(newOut))
+	for _, e := range newOut {
+		newByTag[e.tag] = e
+	}
+
+	for _, oldE := range oldOut {
+		newE, exists := newByTag[oldE.tag]
+		if exists && bytes.Equal(oldE.norm, newE.norm) {
+			continue
+		}
+		diff.RemovedOutboundTags = append(diff.RemovedOutboundTags, oldE.tag)
+		if exists {
+			diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
+		}
+	}
+	for _, newE := range newOut {
+		if _, exists := oldByTag[newE.tag]; !exists {
+			diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
+		}
+	}
+	return true
+}
+
+// diffRouting decides whether the routing change is limited to rules and
+// balancers — the only parts RoutingService.AddRule can replace at runtime.
+// domainStrategy/domainMatcher and any other key in the section are fixed at
+// process start.
+func diffRouting(oldCfg, newCfg *Config, diff *HotDiff) bool {
+	if bytes.Equal(oldCfg.RouterConfig, newCfg.RouterConfig) {
+		return true
+	}
+	// No routing section at start likely means no router feature (and no
+	// RoutingService) in the running instance — only a restart can add it.
+	if len(oldCfg.RouterConfig) == 0 || len(newCfg.RouterConfig) == 0 {
+		return false
+	}
+	oldRest, ok := routingWithoutReloadable(oldCfg.RouterConfig)
+	if !ok {
+		return false
+	}
+	newRest, ok := routingWithoutReloadable(newCfg.RouterConfig)
+	if !ok {
+		return false
+	}
+	if !bytes.Equal(oldRest, newRest) {
+		return false
+	}
+	diff.RoutingConfig = newCfg.RouterConfig
+	return true
+}
+
+// routingWithoutReloadable returns the routing section normalized with the
+// runtime-reloadable keys removed, for comparing the restart-only remainder.
+func routingWithoutReloadable(raw []byte) ([]byte, bool) {
+	parsed := map[string]any{}
+	if len(raw) > 0 {
+		decoder := json.NewDecoder(bytes.NewReader(raw))
+		decoder.UseNumber()
+		if err := decoder.Decode(&parsed); err != nil {
+			return nil, false
+		}
+	}
+	delete(parsed, "rules")
+	delete(parsed, "balancers")
+	out, err := json.Marshal(parsed)
+	if err != nil {
+		return nil, false
+	}
+	return out, true
+}
+
+// inboundEqualNormalized compares two inbounds ignoring JSON formatting in
+// their raw sections, so a reformatted template does not read as a changed
+// inbound.
+func inboundEqualNormalized(a, b *InboundConfig) bool {
+	return a.Port == b.Port &&
+		a.Protocol == b.Protocol &&
+		a.Tag == b.Tag &&
+		rawEqualNormalized(a.Listen, b.Listen) &&
+		rawEqualNormalized(a.Settings, b.Settings) &&
+		rawEqualNormalized(a.StreamSettings, b.StreamSettings) &&
+		rawEqualNormalized(a.Sniffing, b.Sniffing)
+}
+
+// rawEqualNormalized reports whether two raw JSON values are semantically
+// equal: whitespace, object key order and an explicit `null` versus an
+// absent section are all ignored. UI editors rebuild objects on save (new
+// key order) and emit `null` for switched-off sections — none of that is a
+// reason to restart the core. Number precision is preserved via json.Number,
+// so genuinely different values never compare equal. Unparsable values only
+// compare equal byte-for-byte.
+func rawEqualNormalized(a, b json_util.RawMessage) bool {
+	if bytes.Equal(a, b) {
+		return true
+	}
+	na, ok := canonicalJSON(a)
+	if !ok {
+		return false
+	}
+	nb, ok := canonicalJSON(b)
+	if !ok {
+		return false
+	}
+	return bytes.Equal(na, nb)
+}
+
+// canonicalJSON renders a JSON value in canonical form: sorted object keys,
+// no insignificant whitespace, exact number digits (json.Number). Empty
+// input and JSON null both canonicalize to nil.
+func canonicalJSON(raw json_util.RawMessage) ([]byte, bool) {
+	if len(raw) == 0 {
+		return nil, true
+	}
+	decoder := json.NewDecoder(bytes.NewReader(raw))
+	decoder.UseNumber()
+	var value any
+	if err := decoder.Decode(&value); err != nil {
+		return nil, false
+	}
+	if value == nil {
+		return nil, true
+	}
+	out, err := json.Marshal(value)
+	if err != nil {
+		return nil, false
+	}
+	return out, true
+}
+
+// inboundsByTag indexes inbounds by tag; ok is false when a tag is empty or
+// duplicated, since such handlers can't be addressed through the API.
+func inboundsByTag(inbounds []InboundConfig) (map[string]*InboundConfig, bool) {
+	byTag := make(map[string]*InboundConfig, len(inbounds))
+	for i := range inbounds {
+		tag := inbounds[i].Tag
+		if tag == "" {
+			return nil, false
+		}
+		if _, dup := byTag[tag]; dup {
+			return nil, false
+		}
+		byTag[tag] = &inbounds[i]
+	}
+	return byTag, true
+}
+
+type outboundEntry struct {
+	tag  string
+	raw  []byte // original JSON, used for AddOutbound
+	norm []byte // canonical JSON, used for change detection
+}
+
+// parseOutbounds splits the outbounds array into per-entry raw/normalized
+// JSON. ok is false when the array is unparsable or an entry has an empty or
+// duplicate tag — those can't be addressed through the API.
+func parseOutbounds(raw json_util.RawMessage) ([]outboundEntry, bool) {
+	if len(raw) == 0 {
+		return nil, true
+	}
+	var elems []json.RawMessage
+	if err := json.Unmarshal(raw, &elems); err != nil {
+		return nil, false
+	}
+	entries := make([]outboundEntry, 0, len(elems))
+	seen := make(map[string]struct{}, len(elems))
+	for _, elem := range elems {
+		var meta struct {
+			Tag string `json:"tag"`
+		}
+		if err := json.Unmarshal(elem, &meta); err != nil {
+			return nil, false
+		}
+		if meta.Tag == "" {
+			return nil, false
+		}
+		if _, dup := seen[meta.Tag]; dup {
+			return nil, false
+		}
+		seen[meta.Tag] = struct{}{}
+		norm, ok := canonicalJSON(json_util.RawMessage(elem))
+		if !ok {
+			return nil, false
+		}
+		entries = append(entries, outboundEntry{tag: meta.Tag, raw: elem, norm: norm})
+	}
+	return entries, true
+}
+
+// apiTagFromConfig extracts api.tag from the api section, defaulting to "api".
+func apiTagFromConfig(api json_util.RawMessage) string {
+	var parsed struct {
+		Tag string `json:"tag"`
+	}
+	if len(api) > 0 && json.Unmarshal(api, &parsed) == nil && parsed.Tag != "" {
+		return parsed.Tag
+	}
+	return "api"
+}

+ 265 - 0
internal/xray/hot_diff_test.go

@@ -0,0 +1,265 @@
+package xray
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+
+	"github.com/op/go-logging"
+)
+
+func TestMain(m *testing.M) {
+	// ComputeHotDiff logs the section that blocks a hot apply; the package
+	// logger must exist before any test exercises a blocked path.
+	xuilogger.InitLogger(logging.ERROR)
+	os.Exit(m.Run())
+}
+
+func makeHotConfig() *Config {
+	return &Config{
+		LogConfig:       json_util.RawMessage(`{"loglevel":"warning"}`),
+		RouterConfig:    json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
+		OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`),
+		Policy:          json_util.RawMessage(`{}`),
+		API:             json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`),
+		Stats:           json_util.RawMessage(`{}`),
+		Metrics:         json_util.RawMessage(`{}`),
+		InboundConfigs: []InboundConfig{
+			{
+				Port:     62789,
+				Protocol: "tunnel",
+				Tag:      "api",
+				Listen:   json_util.RawMessage(`"127.0.0.1"`),
+				Settings: json_util.RawMessage(`{}`),
+			},
+			{
+				Port:     1080,
+				Protocol: "vless",
+				Tag:      "inbound-1080",
+				Listen:   json_util.RawMessage(`"0.0.0.0"`),
+				Settings: json_util.RawMessage(`{"clients":[]}`),
+			},
+		},
+	}
+}
+
+func TestComputeHotDiff_NoChanges(t *testing.T) {
+	diff, ok := ComputeHotDiff(makeHotConfig(), makeHotConfig())
+	if !ok {
+		t.Fatal("identical configs must be hot-appliable")
+	}
+	if !diff.Empty() {
+		t.Fatalf("identical configs must produce an empty diff, got %+v", diff)
+	}
+}
+
+func TestComputeHotDiff_FormattingOnlyChangeIsEmptyDiff(t *testing.T) {
+	oldCfg := makeHotConfig()
+	newCfg := makeHotConfig()
+	// Reformat every section the way a frontend textarea save would.
+	newCfg.LogConfig = json_util.RawMessage("{\n  \"loglevel\": \"warning\"\n}")
+	newCfg.Policy = json_util.RawMessage("{ }")
+	newCfg.API = json_util.RawMessage("{\n  \"services\": [\"HandlerService\", \"StatsService\", \"RoutingService\"],\n  \"tag\": \"api\"\n}")
+	newCfg.OutboundConfigs = json_util.RawMessage("[\n  {\"protocol\": \"freedom\", \"tag\": \"direct\"},\n  {\"protocol\": \"blackhole\", \"tag\": \"blocked\"}\n]")
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage("{\n  \"clients\": []\n}")
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("formatting-only change must be hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 ||
+		len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
+		t.Fatalf("formatting-only change must produce no handler ops, got %+v", diff)
+	}
+}
+
+func TestComputeHotDiff_CanonicalEquality(t *testing.T) {
+	// Key reorder in a static section (the DNS editor rebuilds the object on
+	// save) must not read as a change.
+	oldCfg := makeHotConfig()
+	oldCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"],"queryStrategy":"UseIP","tag":"dns-in"}`)
+	newCfg := makeHotConfig()
+	newCfg.DNSConfig = json_util.RawMessage(`{"tag":"dns-in","queryStrategy":"UseIP","servers":["1.1.1.1"]}`)
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok || !diff.Empty() {
+		t.Fatalf("dns key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
+	}
+
+	// Explicit null and an absent section are the same thing.
+	newCfg = makeHotConfig()
+	newCfg.FakeDNS = json_util.RawMessage(`null`)
+	diff, ok = ComputeHotDiff(makeHotConfig(), newCfg)
+	if !ok || !diff.Empty() {
+		t.Fatalf("fakedns null vs absent must be an empty hot diff, ok=%v diff=%+v", ok, diff)
+	}
+
+	// A real DNS change still forces a restart — there is no reload API.
+	newCfg = makeHotConfig()
+	newCfg.DNSConfig = json_util.RawMessage(`{"servers":["8.8.8.8"]}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("real dns change must force a restart")
+	}
+
+	// Large integers keep full precision during normalization: two values
+	// that only differ past float64 precision must still read as a change.
+	oldCfg = makeHotConfig()
+	oldCfg.Policy = json_util.RawMessage(`{"big":9007199254740993}`)
+	newCfg = makeHotConfig()
+	newCfg.Policy = json_util.RawMessage(`{"big":9007199254740992}`)
+	if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+		t.Fatal("values differing past float64 precision must not compare equal")
+	}
+
+	// Reordered keys inside the first (default) outbound must not force a
+	// restart — the form editor rebuilds the object on save.
+	oldCfg = makeHotConfig()
+	oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
+	newCfg = makeHotConfig()
+	newCfg.OutboundConfigs = json_util.RawMessage(`[{"tag":"direct","settings":{"domainStrategy":"AsIs"},"protocol":"freedom"},{"protocol":"blackhole","tag":"blocked"}]`)
+	diff, ok = ComputeHotDiff(oldCfg, newCfg)
+	if !ok || !diff.Empty() {
+		t.Fatalf("first outbound key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
+	}
+}
+
+func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
+	newCfg := makeHotConfig()
+	newCfg.LogConfig = json_util.RawMessage(`{"loglevel":"debug"}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("log change must force a restart")
+	}
+
+	newCfg = makeHotConfig()
+	newCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"]}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("dns change must force a restart")
+	}
+
+	newCfg = makeHotConfig()
+	newCfg.Observatory = json_util.RawMessage(`{"subjectSelector":["wg"]}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("observatory change must force a restart")
+	}
+}
+
+func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
+	oldCfg := makeHotConfig()
+	newCfg := makeHotConfig()
+	// change existing
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`)
+	// add new
+	newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
+		Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
+		Settings: json_util.RawMessage(`{}`),
+	})
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("inbound-only change must be hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 1 || diff.RemovedInboundTags[0] != "inbound-1080" {
+		t.Fatalf("expected changed inbound to be removed, got %v", diff.RemovedInboundTags)
+	}
+	if len(diff.AddedInbounds) != 2 {
+		t.Fatalf("expected re-add + new add, got %d", len(diff.AddedInbounds))
+	}
+	if diff.RoutingConfig != nil || len(diff.AddedOutbounds) != 0 || len(diff.RemovedOutboundTags) != 0 {
+		t.Fatalf("unexpected non-inbound operations: %+v", diff)
+	}
+}
+
+func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[0].Port = 62790
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("api inbound change must force a restart")
+	}
+}
+
+func TestComputeHotDiff_OutboundChangeAndReorder(t *testing.T) {
+	oldCfg := makeHotConfig()
+	newCfg := makeHotConfig()
+	// change a non-first outbound + add one
+	newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","settings":{},"tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("outbound-only change must be hot-appliable")
+	}
+	if len(diff.RemovedOutboundTags) != 1 || diff.RemovedOutboundTags[0] != "blocked" {
+		t.Fatalf("expected changed outbound to be removed, got %v", diff.RemovedOutboundTags)
+	}
+	if len(diff.AddedOutbounds) != 2 {
+		t.Fatalf("expected re-add + new add, got %d", len(diff.AddedOutbounds))
+	}
+	for _, raw := range diff.AddedOutbounds {
+		if !strings.Contains(string(raw), `"tag"`) {
+			t.Fatalf("added outbound JSON must be the raw element, got %s", raw)
+		}
+	}
+
+	// pure reorder of non-first outbounds must be a no-op
+	reordered := makeHotConfig()
+	reordered.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"},{"protocol":"blackhole","tag":"blocked"}]`)
+	base := makeHotConfig()
+	base.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
+	diff, ok = ComputeHotDiff(base, reordered)
+	if !ok || !diff.Empty() {
+		t.Fatalf("reorder of non-first outbounds must be an empty hot diff, ok=%v diff=%+v", ok, diff)
+	}
+}
+
+func TestComputeHotDiff_FirstOutboundChangeNeedsRestart(t *testing.T) {
+	newCfg := makeHotConfig()
+	// change the default (first) outbound content
+	newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("changing the default outbound must force a restart")
+	}
+
+	// swap which outbound comes first
+	newCfg = makeHotConfig()
+	newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"blackhole","tag":"blocked"},{"protocol":"freedom","tag":"direct"}]`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("changing the first outbound must force a restart")
+	}
+}
+
+func TestComputeHotDiff_TaglessOutboundNeedsRestart(t *testing.T) {
+	newCfg := makeHotConfig()
+	newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole"}]`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("tagless outbound must force a restart")
+	}
+}
+
+func TestComputeHotDiff_RoutingRulesChange(t *testing.T) {
+	newCfg := makeHotConfig()
+	newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","ip":["geoip:private"],"outboundTag":"blocked"}]}`)
+
+	diff, ok := ComputeHotDiff(makeHotConfig(), newCfg)
+	if !ok {
+		t.Fatal("rules-only routing change must be hot-appliable")
+	}
+	if diff.RoutingConfig == nil {
+		t.Fatal("routing diff must carry the new routing section")
+	}
+
+	// balancers are reloadable too
+	newCfg = makeHotConfig()
+	newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"b1","selector":["wg"]}]}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); !ok {
+		t.Fatal("balancer-only routing change must be hot-appliable")
+	}
+}
+
+func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
+	newCfg := makeHotConfig()
+	newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"IPIfNonMatch","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`)
+	if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
+		t.Fatal("domainStrategy change must force a restart")
+	}
+}

+ 7 - 0
internal/xray/process.go

@@ -249,6 +249,13 @@ func (p *Process) GetConfig() *Config {
 	return p.config
 }
 
+// SetConfig replaces the stored configuration snapshot after the running
+// process has been reconciled with it through the gRPC API (hot apply), so
+// later change detection compares against what is actually running.
+func (p *Process) SetConfig(config *Config) {
+	p.config = config
+}
+
 // GetOnlineClients returns the union of locally-online clients and
 // node-online clients from every registered remote panel. Dedupes by
 // email so a client connected to both a local and a node-managed inbound

+ 18 - 1
main.go

@@ -9,6 +9,7 @@ import (
 	"os"
 	"os/signal"
 	"syscall"
+	"time"
 	_ "unsafe"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -403,6 +404,11 @@ func GetApiToken(getApiToken bool) {
 	if !getApiToken {
 		return
 	}
+	err := database.InitDB(config.GetDBPath())
+	if err != nil {
+		fmt.Println("open database failed, error info:", err)
+		return
+	}
 	apiTokenService := panel.ApiTokenService{}
 	tokens, err := apiTokenService.List()
 	if err != nil {
@@ -410,7 +416,18 @@ func GetApiToken(getApiToken bool) {
 		return
 	}
 	if len(tokens) > 0 {
-		fmt.Println("apiToken:", tokens[0].Token)
+		fmt.Printf("There are %d API token(s) configured. Existing tokens cannot be retrieved in plaintext because only hashes are stored.\n", len(tokens))
+		fmt.Println("If you have lost your token, you can manage and generate new tokens through the Panel UI (Settings -> API Tokens).")
+		
+		// Create a new fallback token so the CLI is still useful without the UI
+		fallbackName := fmt.Sprintf("cli-fallback-%d", time.Now().Unix())
+		created, err := apiTokenService.Create(fallbackName)
+		if err != nil {
+			fmt.Println("Failed to create a fallback API token:", err)
+			return
+		}
+		fmt.Println("\nA new fallback token has been generated for your convenience:")
+		fmt.Println("apiToken:", created.Token)
 		return
 	}
 	created, err := apiTokenService.Create("install")

+ 8 - 0
update.sh

@@ -751,6 +751,8 @@ prompt_and_setup_ssl() {
 }
 
 config_after_update() {
+    local panel_needs_restart=0
+
     echo -e "${yellow}x-ui settings:${plain}"
     ${xui_folder}/x-ui setting -show true
     ${xui_folder}/x-ui migrate
@@ -798,6 +800,7 @@ config_after_update() {
         local config_webBasePath=$(gen_random_string 18)
         ${xui_folder}/x-ui setting -webBasePath "${config_webBasePath}"
         existing_webBasePath="${config_webBasePath}"
+        panel_needs_restart=1
         echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
     fi
 
@@ -832,6 +835,11 @@ config_after_update() {
         echo -e "${green}Access URL: https://${cert_domain}:${existing_port}/${existing_webBasePath}${plain}"
         echo -e "${green}═══════════════════════════════════════════${plain}"
     fi
+
+    if [[ "$panel_needs_restart" -eq 1 ]]; then
+        echo -e "${yellow}Restarting panel to apply the new web base path...${plain}"
+        systemctl restart x-ui 2> /dev/null || rc-service x-ui restart 2> /dev/null
+    fi
 }
 
 update_x-ui() {

+ 3 - 3
x-ui.sh

@@ -2100,7 +2100,7 @@ install_iplimit() {
         case "${release}" in
             ubuntu)
                 apt-get update
-                if [[ "${os_version}" -ge 24 ]]; then
+                if [[ "${os_version}" -ge 2400 ]]; then
                     apt-get install python3-pip -y
                     python3 -m pip install pyasynchat --break-system-packages
                 fi
@@ -2302,8 +2302,8 @@ create_iplimit_jails() {
     # Uncomment 'allowipv6 = auto' in fail2ban.conf
     sed -i 's/#allowipv6 = auto/allowipv6 = auto/g' /etc/fail2ban/fail2ban.conf
 
-    # On Debian 12+ fail2ban's default backend should be changed to systemd
-    if [[ "${release}" == "debian" && ${os_version} -ge 12 ]]; then
+    # On Debian 12+ and Ubuntu 22.04+ fail2ban's default backend should be changed to systemd
+    if [[ ( "${release}" == "debian" && ${os_version} -ge 12 ) || ( "${release}" == "ubuntu" && ${os_version} -ge 2200 ) ]]; then
         sed -i '0,/action =/s/backend = auto/backend = systemd/' /etc/fail2ban/jail.conf
     fi