Bladeren bron

feat: apply inbound/outbound/routing changes live via Xray gRPC API

Add a hot-apply layer that computes a diff between the old and new
generated config and applies only the changed parts through the Xray
gRPC HandlerService and RoutingService, avoiding a full process restart
whenever possible. A restart is still performed when sections that have
no reload API (log, dns, policy, observatory, ...) actually change.

Key additions:
- internal/xray/hot_diff.go: ComputeHotDiff with canonical-JSON
  comparison (sorted keys, null=absent, full number precision) so UI
  reformatting never triggers a spurious restart
- internal/xray/api.go: AddOutbound/DelOutbound, ApplyRoutingConfig,
  GetBalancerInfo, SetBalancerTarget, TestRoute gRPC wrappers
- internal/web/service/xray.go: tryHotApply, ensureAPIServices,
  GetBalancersStatus, OverrideBalancer, TestRoute service methods
- internal/web/controller/xray_setting.go: balancerStatus,
  balancerOverride, routeTest API endpoints
- frontend: BalancersTab live-status/override columns, RouteTester
  component, Restart button removed (Save now hot-applies)
- balancer-helpers.ts: syncObservatories never creates observatory
  sections for random/roundRobin balancers (no reload API → restart)
- i18n: balancerLive/Override/routeTester keys added to all 13 locales
MHSanaei 11 uur geleden
bovenliggende
commit
6b16d8c37a
32 gewijzigde bestanden met toevoegingen van 2209 en 109 verwijderingen
  1. 120 0
      frontend/public/openapi.json
  2. 2 22
      frontend/src/hooks/useXraySetting.ts
  3. 34 0
      frontend/src/pages/api-docs/endpoints.ts
  4. 0 29
      frontend/src/pages/xray/XrayPage.tsx
  5. 97 53
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  6. 59 0
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  7. 146 0
      frontend/src/pages/xray/routing/RouteTester.tsx
  8. 7 1
      frontend/src/pages/xray/routing/RoutingTab.tsx
  9. 60 0
      frontend/src/test/balancer-observatory-sync.test.ts
  10. 86 1
      internal/web/controller/xray_setting.go
  11. 2 1
      internal/web/service/config.json
  12. 229 2
      internal/web/service/xray.go
  13. 43 0
      internal/web/service/xray_config_inject_test.go
  14. 15 0
      internal/web/translation/ar-EG.json
  15. 15 0
      internal/web/translation/en-US.json
  16. 15 0
      internal/web/translation/es-ES.json
  17. 15 0
      internal/web/translation/fa-IR.json
  18. 15 0
      internal/web/translation/id-ID.json
  19. 15 0
      internal/web/translation/ja-JP.json
  20. 15 0
      internal/web/translation/pt-BR.json
  21. 15 0
      internal/web/translation/ru-RU.json
  22. 15 0
      internal/web/translation/tr-TR.json
  23. 15 0
      internal/web/translation/uk-UA.json
  24. 15 0
      internal/web/translation/vi-VN.json
  25. 15 0
      internal/web/translation/zh-CN.json
  26. 15 0
      internal/web/translation/zh-TW.json
  27. 250 0
      internal/xray/api.go
  28. 240 0
      internal/xray/api_e2e_test.go
  29. 6 0
      internal/xray/config.go
  30. 361 0
      internal/xray/hot_diff.go
  31. 265 0
      internal/xray/hot_diff_test.go
  32. 7 0
      internal/xray/process.go

+ 120 - 0
frontend/public/openapi.json

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

+ 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',

+ 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

+ 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();
+  });
+});

+ 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) {

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

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

+ 229 - 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)
 
@@ -306,6 +307,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 +420,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 +507,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 +530,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()

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

@@ -0,0 +1,43 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+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)
+	}
+}

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

@@ -1183,6 +1183,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": "أولوية كل قاعدة مهمة جداً!",

+ 15 - 0
internal/web/translation/en-US.json

@@ -1186,6 +1186,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!",

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

@@ -1183,6 +1183,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!",

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

@@ -1183,6 +1183,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": "اولویت هر قانون مهم است",

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

@@ -1183,6 +1183,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!",

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

@@ -1183,6 +1183,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": "各ルールの優先順位が重要です",

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

@@ -1183,6 +1183,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!",

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

@@ -1183,6 +1183,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": "Важен приоритет каждого правила!",

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

@@ -1184,6 +1184,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!",

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

@@ -1183,6 +1183,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": "Пріоритет кожного правила важливий!",

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

@@ -1183,6 +1183,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!",

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

@@ -1183,6 +1183,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": "每条规则的优先级都很重要",

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

@@ -1183,6 +1183,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