9 Commity f07d092af0 ... b11c51e736

Autor SHA1 Wiadomość Data
  MHSanaei b11c51e736 ci(claude-bot): tune models, Copilot-style PR review, issue research mode 1 dzień temu
  MHSanaei 0d764f1bb5 feat(iplimit): auto-install fail2ban on install and update 1 dzień temu
  MHSanaei 683653674c fix(api-docs): exclude /panel/outbound and /panel/routing from route guard 1 dzień temu
  MHSanaei ce8b1bed77 feat(iplimit): gate IP limit on fail2ban and reset stale limits 1 dzień temu
  MHSanaei 718b7e16e1 feat(sidebar): move Routing/Outbounds to top-level items with clean URLs 1 dzień temu
  MHSanaei 20094c8d35 perf(settings): save all settings in one transaction 1 dzień temu
  MHSanaei a7e959ff49 feat(backup): name DB backup files after the server address 1 dzień temu
  Rick Sanchez 1b102ff9f7 fix(install): support IPv6-only hosts (#5487) 1 dzień temu
  Sanaei adc64bb804 fix(nodes): cloned-node attribution, node-hosted client display (online/speed/counts), and sync robustness (#5488) 1 dzień temu
56 zmienionych plików z 1556 dodań i 256 usunięć
  1. 82 28
      .github/workflows/claude-bot.yml
  2. 51 0
      frontend/public/openapi.json
  3. 41 0
      frontend/src/api/queries/useFail2banStatusQuery.ts
  4. 1 0
      frontend/src/api/queryKeys.ts
  5. 2 0
      frontend/src/generated/examples.ts
  6. 10 0
      frontend/src/generated/schemas.ts
  7. 2 0
      frontend/src/generated/types.ts
  8. 2 0
      frontend/src/generated/zod.ts
  9. 2 0
      frontend/src/hooks/usePageTitle.ts
  10. 5 6
      frontend/src/layouts/AppSidebar.tsx
  11. 6 0
      frontend/src/pages/api-docs/endpoints.ts
  12. 12 2
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  13. 20 11
      frontend/src/pages/clients/ClientFormModal.tsx
  14. 6 1
      frontend/src/pages/inbounds/list/InboundList.tsx
  15. 12 12
      frontend/src/pages/inbounds/list/useInboundColumns.tsx
  16. 44 16
      frontend/src/pages/inbounds/useInbounds.ts
  17. 30 9
      frontend/src/pages/nodes/NodeList.tsx
  18. 2 1
      frontend/src/pages/xray/XrayPage.tsx
  19. 2 0
      frontend/src/routes.tsx
  20. 2 0
      frontend/src/schemas/node.ts
  21. 39 13
      install.sh
  22. 97 0
      internal/database/db.go
  23. 2 0
      internal/database/model/model.go
  24. 2 1
      internal/web/controller/api_docs_test.go
  25. 6 5
      internal/web/controller/server.go
  26. 2 0
      internal/web/controller/spa.go
  27. 4 3
      internal/web/job/check_client_ip_job.go
  28. 105 7
      internal/web/job/node_traffic_sync_job.go
  29. 38 0
      internal/web/service/backup_filename_test.go
  30. 3 0
      internal/web/service/email/subscriber.go
  31. 60 12
      internal/web/service/inbound_node.go
  32. 31 7
      internal/web/service/inbound_node_ips.go
  33. 2 2
      internal/web/service/inbound_node_ips_test.go
  34. 180 52
      internal/web/service/node.go
  35. 87 0
      internal/web/service/node_client_breakdown_test.go
  36. 98 0
      internal/web/service/node_origin_guid_test.go
  37. 162 0
      internal/web/service/node_shared_guid_test.go
  38. 39 28
      internal/web/service/node_tree.go
  39. 106 0
      internal/web/service/server.go
  40. 32 10
      internal/web/service/setting.go
  41. 1 5
      internal/web/service/tgbot/tgbot_report.go
  42. 5 0
      internal/web/translation/ar-EG.json
  43. 5 0
      internal/web/translation/en-US.json
  44. 5 0
      internal/web/translation/es-ES.json
  45. 5 0
      internal/web/translation/fa-IR.json
  46. 5 0
      internal/web/translation/id-ID.json
  47. 5 0
      internal/web/translation/ja-JP.json
  48. 5 0
      internal/web/translation/pt-BR.json
  49. 5 0
      internal/web/translation/ru-RU.json
  50. 5 0
      internal/web/translation/tr-TR.json
  51. 5 0
      internal/web/translation/uk-UA.json
  52. 5 0
      internal/web/translation/vi-VN.json
  53. 5 0
      internal/web/translation/zh-CN.json
  54. 5 0
      internal/web/translation/zh-TW.json
  55. 39 22
      update.sh
  56. 22 3
      x-ui.sh

Plik diff jest za duży
+ 82 - 28
.github/workflows/claude-bot.yml


+ 51 - 0
frontend/public/openapi.json

@@ -1826,6 +1826,10 @@
       "Node": {
         "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
         "properties": {
+          "activeCount": {
+            "example": 23,
+            "type": "integer"
+          },
           "address": {
             "example": "node1.example.com",
             "type": "string"
@@ -1863,6 +1867,10 @@
             "example": 1,
             "type": "integer"
           },
+          "disabledCount": {
+            "example": 3,
+            "type": "integer"
+          },
           "enable": {
             "example": true,
             "type": "boolean"
@@ -1993,6 +2001,7 @@
           }
         },
         "required": [
+          "activeCount",
           "address",
           "allowPrivateAddress",
           "apiToken",
@@ -2003,6 +2012,7 @@
           "cpuPct",
           "createdAt",
           "depletedCount",
+          "disabledCount",
           "enable",
           "guid",
           "id",
@@ -3306,6 +3316,45 @@
         }
       }
     },
+    "/panel/api/server/fail2banStatus": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Reports whether per-client IP limits can be enforced on this host. The panel uses it to gate the \"IP Limit\" field, since enforcement depends on Fail2ban being installed.",
+        "operationId": "get_panel_api_server_fail2banStatus",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "enabled": true,
+                    "installed": true,
+                    "usable": true,
+                    "windows": false
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/cpuHistory/{bucket}": {
       "get": {
         "tags": [
@@ -6683,6 +6732,7 @@
                   "success": true,
                   "obj": [
                     {
+                      "activeCount": 23,
                       "address": "node1.example.com",
                       "allowPrivateAddress": false,
                       "apiToken": "abcdef0123456789",
@@ -6693,6 +6743,7 @@
                       "cpuPct": 23.5,
                       "createdAt": 1700000000,
                       "depletedCount": 1,
+                      "disabledCount": 3,
                       "enable": true,
                       "guid": "",
                       "id": 1,

+ 41 - 0
frontend/src/api/queries/useFail2banStatusQuery.ts

@@ -0,0 +1,41 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { keys } from '@/api/queryKeys';
+
+export interface Fail2banStatus {
+  enabled: boolean;
+  installed: boolean;
+  usable: boolean;
+  windows: boolean;
+}
+
+const FAIL_OPEN_STATUS: Fail2banStatus = {
+  enabled: true,
+  installed: true,
+  usable: true,
+  windows: false,
+};
+
+async function fetchFail2banStatus(): Promise<Fail2banStatus> {
+  const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, { silent: true });
+  if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch fail2ban status');
+  return { ...FAIL_OPEN_STATUS, ...msg.obj };
+}
+
+export function getLimitIpNotice(status: Fail2banStatus, t: (key: string) => string): string {
+  if (status.usable) return '';
+  if (!status.enabled) return t('pages.clients.limitIpDisabled');
+  if (status.windows) return t('pages.clients.limitIpFail2banWindows');
+  return t('pages.clients.limitIpFail2banMissing');
+}
+
+export function useFail2banStatusQuery() {
+  const query = useQuery({
+    queryKey: keys.server.fail2banStatus(),
+    queryFn: fetchFail2banStatus,
+    staleTime: 60_000,
+  });
+
+  return query.data ?? FAIL_OPEN_STATUS;
+}

+ 1 - 0
frontend/src/api/queryKeys.ts

@@ -1,6 +1,7 @@
 export const keys = {
   server: {
     status: () => ['server', 'status'] as const,
+    fail2banStatus: () => ['server', 'fail2banStatus'] as const,
   },
   nodes: {
     root: () => ['nodes'] as const,

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

@@ -396,6 +396,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "success": false
   },
   "Node": {
+    "activeCount": 23,
     "address": "node1.example.com",
     "allowPrivateAddress": false,
     "apiToken": "abcdef0123456789",
@@ -406,6 +407,7 @@ export const EXAMPLES: Record<string, unknown> = {
     "cpuPct": 23.5,
     "createdAt": 1700000000,
     "depletedCount": 1,
+    "disabledCount": 3,
     "enable": true,
     "guid": "",
     "id": 1,

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

@@ -1800,6 +1800,10 @@ export const SCHEMAS: Record<string, unknown> = {
   "Node": {
     "description": "Node represents a remote 3x-ui panel registered with the central panel.\nThe central panel polls each node's existing /panel/api/server/status\nendpoint over HTTP using the per-node ApiToken to populate the runtime\nstatus fields below.",
     "properties": {
+      "activeCount": {
+        "example": 23,
+        "type": "integer"
+      },
       "address": {
         "example": "node1.example.com",
         "type": "string"
@@ -1837,6 +1841,10 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": 1,
         "type": "integer"
       },
+      "disabledCount": {
+        "example": 3,
+        "type": "integer"
+      },
       "enable": {
         "example": true,
         "type": "boolean"
@@ -1967,6 +1975,7 @@ export const SCHEMAS: Record<string, unknown> = {
       }
     },
     "required": [
+      "activeCount",
       "address",
       "allowPrivateAddress",
       "apiToken",
@@ -1977,6 +1986,7 @@ export const SCHEMAS: Record<string, unknown> = {
       "cpuPct",
       "createdAt",
       "depletedCount",
+      "disabledCount",
       "enable",
       "guid",
       "id",

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

@@ -394,6 +394,7 @@ export interface Msg {
 }
 
 export interface Node {
+  activeCount: number;
   address: string;
   allowPrivateAddress: boolean;
   apiToken: string;
@@ -404,6 +405,7 @@ export interface Node {
   cpuPct: number;
   createdAt: number;
   depletedCount: number;
+  disabledCount: number;
   enable: boolean;
   guid: string;
   id: number;

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

@@ -423,6 +423,7 @@ export const MsgSchema = z.object({
 export type Msg = z.infer<typeof MsgSchema>;
 
 export const NodeSchema = z.object({
+  activeCount: z.number().int(),
   address: z.string(),
   allowPrivateAddress: z.boolean(),
   apiToken: z.string(),
@@ -433,6 +434,7 @@ export const NodeSchema = z.object({
   cpuPct: z.number(),
   createdAt: z.number().int(),
   depletedCount: z.number().int(),
+  disabledCount: z.number().int(),
   enable: z.boolean(),
   guid: z.string(),
   id: z.number().int(),

+ 2 - 0
frontend/src/hooks/usePageTitle.ts

@@ -10,6 +10,8 @@ const TITLE_KEYS: Record<string, string> = {
   '/nodes': 'menu.nodes',
   '/settings': 'menu.settings',
   '/xray': 'menu.xray',
+  '/outbound': 'menu.outbounds',
+  '/routing': 'menu.routing',
   '/api-docs': 'menu.apiDocs',
 };
 

+ 5 - 6
frontend/src/layouts/AppSidebar.tsx

@@ -42,7 +42,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
 
-type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound';
+type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'hosts' | 'logout' | 'apidocs' | 'outbound' | 'routing';
 
 const iconByName: Record<IconName, ComponentType> = {
   dashboard: DashboardOutlined,
@@ -56,6 +56,7 @@ const iconByName: Record<IconName, ComponentType> = {
   logout: LogoutOutlined,
   apidocs: ApiOutlined,
   outbound: ExportOutlined,
+  routing: SwapOutlined,
 };
 
 function readCollapsed(): boolean {
@@ -142,7 +143,8 @@ export default function AppSidebar() {
     { key: '/groups', icon: 'groups', title: t('menu.groups') },
     { key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
     { key: '/hosts', icon: 'hosts', title: t('menu.hosts') },
-    { key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
+    { key: '/outbound', icon: 'outbound', title: t('menu.outbounds') },
+    { key: '/routing', icon: 'routing', title: t('menu.routing') },
     { key: '/settings', icon: 'setting', title: t('menu.settings') },
     { key: '/xray', icon: 'tool', title: t('menu.xray') },
     { key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
@@ -168,7 +170,6 @@ export default function AppSidebar() {
 
   const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
     { key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
-    { key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
     { key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
     { key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
     { key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
@@ -182,9 +183,7 @@ export default function AppSidebar() {
       ? `/xray${hash || '#basic'}`
       : (pathname === '' ? '/' : pathname);
 
-  // The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
-  // Xray Configs submenu for it.
-  const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
+  const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
   const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
   useEffect(() => {
     if (openSubmenu) {

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

@@ -252,6 +252,12 @@ export const sections: readonly Section[] = [
         summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
         response: '{\n  "success": true,\n  "obj": {\n    "cpu": 12.5,\n    "mem": { "current": 2147483648, "total": 8589934592 },\n    "swap": { "current": 0, "total": 4294967296 },\n    "disk": { "current": 53687091200, "total": 268435456000 },\n    "netIO": { "up": 1073741824, "down": 2147483648 },\n    "xray": { "state": "running", "version": "v25.10.31" },\n    "tcpCount": 42,\n    "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n  }\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/fail2banStatus',
+        summary: 'Reports whether per-client IP limits can be enforced on this host. The panel uses it to gate the "IP Limit" field, since enforcement depends on Fail2ban being installed.',
+        response: '{\n  "success": true,\n  "obj": {\n    "enabled": true,\n    "installed": true,\n    "usable": true,\n    "windows": false\n  }\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/cpuHistory/:bucket',

+ 12 - 2
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, message } from 'antd';
+import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tooltip, message } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
@@ -10,6 +10,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { useClients, type InboundOption } from '@/hooks/useClients';
+import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -62,6 +63,9 @@ export default function ClientBulkAddModal({
   const [form, setForm] = useState<FormState>(emptyForm);
   const [delayedStart, setDelayedStart] = useState(false);
   const [saving, setSaving] = useState(false);
+  const fail2ban = useFail2banStatusQuery();
+  const limitIpDisabled = !fail2ban.usable;
+  const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   useEffect(() => {
     if (!open) return;
@@ -311,7 +315,13 @@ export default function ClientBulkAddModal({
           )}
 
           <Form.Item label={t('pages.clients.limitIp')}>
-            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+            <Tooltip title={limitIpNotice || undefined}>
+              <span style={{ display: 'inline-flex' }}>
+                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
+                  style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
+                  onChange={(v) => update('limitIp', Number(v) || 0)} />
+              </span>
+            </Tooltip>
           </Form.Item>
 
           <Form.Item label={t('pages.clients.totalGB')}>

+ 20 - 11
frontend/src/pages/clients/ClientFormModal.tsx

@@ -28,6 +28,7 @@ import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
+import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -182,6 +183,9 @@ export default function ClientFormModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const fail2ban = useFail2banStatusQuery();
+  const limitIpDisabled = !fail2ban.usable;
+  const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   function update<K extends keyof FormState>(key: K, value: FormState[K]) {
     setForm((prev) => ({ ...prev, [key]: value }));
@@ -550,17 +554,22 @@ export default function ClientFormModal({
                       </Col>
                       <Col xs={24} md={6}>
                         <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <InputNumber value={form.limitIp} min={0} style={{ flex: 1 }}
-                              onChange={(v) => update('limitIp', Number(v) || 0)} />
-                            {isEdit && (
-                              <Tooltip title={t('pages.clients.ipLog')}>
-                                <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                                  {clientIps.length > 0 ? clientIps.length : ''}
-                                </Button>
-                              </Tooltip>
-                            )}
-                          </Space.Compact>
+                          <Tooltip title={limitIpNotice || undefined}>
+                            <span style={{ display: 'flex', width: '100%' }}>
+                              <Space.Compact style={{ display: 'flex', flex: 1 }}>
+                                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
+                                  style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
+                                  onChange={(v) => update('limitIp', Number(v) || 0)} />
+                                {isEdit && (
+                                  <Tooltip title={t('pages.clients.ipLog')}>
+                                    <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                                      {clientIps.length > 0 ? clientIps.length : ''}
+                                    </Button>
+                                  </Tooltip>
+                                )}
+                              </Space.Compact>
+                            </span>
+                          </Tooltip>
                         </Form.Item>
                       </Col>
                     </Row>

+ 6 - 1
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -133,6 +133,11 @@ export default function InboundList({
     onSwitchEnable,
   });
 
+  const tableScrollX = useMemo(
+    () => columns.reduce((sum, c) => sum + (typeof c.width === 'number' ? c.width : 0), 0),
+    [columns],
+  );
+
   const paginationFor = (rows: DBInboundRecord[]) => {
     const size = pageSize > 0 ? pageSize : rows.length || 1;
     return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
@@ -252,7 +257,7 @@ export default function InboundList({
               onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
             }}
             pagination={paginationFor(visibleInbounds)}
-            scroll={{ x: 1000 }}
+            scroll={{ x: tableScrollX }}
             style={{ marginTop: 10 }}
             size="small"
             locale={{

+ 12 - 12
frontend/src/pages/inbounds/list/useInboundColumns.tsx

@@ -57,13 +57,13 @@ export function useInboundColumns({
         dataIndex: 'id',
         key: 'id',
         align: 'right',
-        width: 30,
+        width: 60,
       },
       {
         title: t('pages.inbounds.operate'),
         key: 'action',
         align: 'center',
-        width: 60,
+        width: 70,
         render: (_, record) => (
           <RowActionsCell
             record={record}
@@ -77,7 +77,7 @@ export function useInboundColumns({
         title: t('pages.inbounds.enable'),
         key: 'enable',
         align: 'center',
-        width: 35,
+        width: 80,
         render: (_, record) => (
           <Switch
             checked={record.enable}
@@ -93,7 +93,7 @@ export function useInboundColumns({
         dataIndex: 'remark',
         key: 'remark',
         align: 'center',
-        width: 60,
+        width: 90,
       });
     }
 
@@ -102,7 +102,7 @@ export function useInboundColumns({
         title: t('pages.inbounds.node'),
         key: 'node',
         align: 'center',
-        width: 60,
+        width: 130,
         render: (_, record) => {
           if (record.nodeId == null) {
             return <Tag color="default">{t('pages.inbounds.localPanel')}</Tag>;
@@ -128,7 +128,7 @@ export function useInboundColumns({
         dataIndex: 'subSortIndex',
         key: 'subSortIndex',
         align: 'right',
-        width: 70,
+        width: 90,
       });
     }
 
@@ -138,13 +138,13 @@ export function useInboundColumns({
         dataIndex: 'port',
         key: 'port',
         align: 'center',
-        width: 40,
+        width: 80,
       },
       {
         title: t('pages.inbounds.protocol'),
         key: 'protocol',
         align: 'left',
-        width: 130,
+        width: 190,
         render: (_, record) => {
           const tags: ReactElement[] = [<Tag key="p" color="purple">{record.protocol}</Tag>];
           if (record.isWireguard || record.isHysteria) {
@@ -172,7 +172,7 @@ export function useInboundColumns({
         title: t('clients'),
         key: 'clients',
         align: 'left',
-        width: 110,
+        width: 200,
         render: (_, record) => {
           const cc = clientCount[record.id];
           if (!cc) return null;
@@ -237,7 +237,7 @@ export function useInboundColumns({
         title: t('pages.inbounds.traffic'),
         key: 'traffic',
         align: 'center',
-        width: 90,
+        width: 140,
         render: (_, record) => (
           <Popover
             content={(
@@ -269,7 +269,7 @@ export function useInboundColumns({
         title: t('pages.inbounds.speed'),
         key: 'speed',
         align: 'center',
-        width: 90,
+        width: 110,
         render: (_, record) => {
           const speed = inboundSpeed[record.id];
           if (!isActiveSpeed(speed)) {
@@ -282,7 +282,7 @@ export function useInboundColumns({
         title: t('pages.inbounds.expireDate'),
         key: 'expiryTime',
         align: 'center',
-        width: 40,
+        width: 100,
         render: (_, record) => {
           if (record.expiryTime > 0) {
             return (

+ 44 - 16
frontend/src/pages/inbounds/useInbounds.ts

@@ -32,6 +32,14 @@ type DBInboundInstance = InstanceType<typeof DBInbound>;
 // deltas accumulated over this window, so dividing by it yields bytes/sec.
 const TRAFFIC_POLL_INTERVAL_S = 5;
 
+// Speed is delta-derived, so it can't be recomputed until the first poll after
+// mount; navigating away and back would otherwise blank the column for up to one
+// poll. Cache the last speed map across mounts (module scope) and reseed from it
+// while recent, so returning to the page shows the last throughput immediately
+// and the next poll refreshes it.
+const SPEED_CACHE_TTL_MS = 15000;
+let inboundSpeedCache: { at: number; data: Record<number, InboundSpeedEntry> } = { at: 0, data: {} };
+
 interface TrafficDelta {
   Tag: string;
   Up: number;
@@ -191,7 +199,12 @@ export function useInbounds() {
   const [clientCount, setClientCount] = useState<Record<number, ClientRollup>>({});
   const [statsVersion, setStatsVersion] = useState(0);
 
-  const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>({});
+  const [inboundSpeed, setInboundSpeed] = useState<Record<number, InboundSpeedEntry>>(() =>
+    Date.now() - inboundSpeedCache.at < SPEED_CACHE_TTL_MS ? inboundSpeedCache.data : {},
+  );
+  useEffect(() => {
+    inboundSpeedCache = { at: Date.now(), data: inboundSpeed };
+  }, [inboundSpeed]);
 
   const [onlineClients, setOnlineClients] = useState<string[]>([]);
   const onlineClientsRef = useRef<string[]>([]);
@@ -399,6 +412,7 @@ export function useInbounds() {
       if (!payload || typeof payload !== 'object') return;
       const p = payload as {
         traffics?: TrafficDelta[];
+        nodeTraffics?: TrafficDelta[];
         onlineClients?: string[];
         onlineByGuid?: Record<string, string[]>;
         activeInbounds?: Record<string, string[]>;
@@ -417,26 +431,40 @@ export function useInbounds() {
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
       }
-      // Full-replace each poll so idle inbounds (and an empty array after an
-      // Xray stat reset) clear their speed instead of showing a stale value.
-      if (Array.isArray(p.traffics)) {
+      // Speed arrives from two independent 5s polls: the local Xray poll sends
+      // `traffics` (local inbounds) and the node sync sends `nodeTraffics` (node
+      // inbounds). Each replaces speed only within its own scope so the two don't
+      // clobber each other; an idle in-scope inbound — absent from its payload —
+      // clears instead of showing a stale value.
+      const applyTraffics = (
+        traffics: TrafficDelta[],
+        inScope: (ib: DBInboundInstance) => boolean,
+      ) => {
         const byTag = new Map<string, TrafficDelta>();
-        for (const tr of p.traffics) {
+        for (const tr of traffics) {
           if (!tr || typeof tr.Tag !== 'string') continue;
           if (tr.IsInbound === false) continue;
           byTag.set(tr.Tag, tr);
         }
-        const nextSpeed: Record<number, InboundSpeedEntry> = {};
-        for (const ib of dbInboundsRef.current) {
-          const delta = byTag.get(ib.tag);
-          if (!delta) continue;
-          nextSpeed[ib.id] = {
-            up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
-            down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
-          };
-        }
-        setInboundSpeed(nextSpeed);
-      }
+        setInboundSpeed((prev) => {
+          const next = { ...prev };
+          for (const ib of dbInboundsRef.current) {
+            if (!inScope(ib)) continue;
+            const delta = byTag.get(ib.tag);
+            if (delta) {
+              next[ib.id] = {
+                up: (delta.Up || 0) / TRAFFIC_POLL_INTERVAL_S,
+                down: (delta.Down || 0) / TRAFFIC_POLL_INTERVAL_S,
+              };
+            } else {
+              delete next[ib.id];
+            }
+          }
+          return next;
+        });
+      };
+      if (Array.isArray(p.traffics)) applyTraffics(p.traffics, (ib) => ib.nodeId == null);
+      if (Array.isArray(p.nodeTraffics)) applyTraffics(p.nodeTraffics, (ib) => ib.nodeId != null);
       rebuildClientCount();
     },
     [rebuildClientCount],

+ 30 - 9
frontend/src/pages/nodes/NodeList.tsx

@@ -28,6 +28,7 @@ import {
   PlusOutlined,
   RightOutlined,
   SafetyCertificateOutlined,
+  TeamOutlined,
   ThunderboltOutlined,
 } from '@ant-design/icons';
 
@@ -384,15 +385,29 @@ export default function NodeList({
     {
       title: t('clients'),
       align: 'center',
-      width: 160,
+      width: 180,
       render: (_value, record) => (
-        <Space size={4}>
-          <Tag color="green">{record.clientCount || 0}</Tag>
-          {record.onlineCount ? (
-            <Tag color="blue">{record.onlineCount} {t('online')}</Tag>
+        <Space size={2}>
+          <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}><TeamOutlined /> {record.clientCount || 0}</Tag>
+          {record.activeCount ? (
+            <Tooltip title={t('subscription.active')}>
+              <Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.activeCount}</Tag>
+            </Tooltip>
+          ) : null}
+          {record.disabledCount ? (
+            <Tooltip title={t('disabled')}>
+              <Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.disabledCount}</Tag>
+            </Tooltip>
           ) : null}
           {record.depletedCount ? (
-            <Tag color="red">{record.depletedCount} {t('depleted')}</Tag>
+            <Tooltip title={t('depleted')}>
+              <Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.depletedCount}</Tag>
+            </Tooltip>
+          ) : null}
+          {record.onlineCount ? (
+            <Tooltip title={t('online')}>
+              <Tag color="blue" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{record.onlineCount}</Tag>
+            </Tooltip>
           ) : null}
         </Space>
       ),
@@ -587,13 +602,19 @@ export default function NodeList({
                 </div>
                 <div className="stat-row">
                   <span className="stat-label">{t('clients')}</span>
-                  <Tag color="green">{statsNode.clientCount || 0}</Tag>
-                  {statsNode.onlineCount ? (
-                    <Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
+                  <Tag><TeamOutlined /> {statsNode.clientCount || 0}</Tag>
+                  {statsNode.activeCount ? (
+                    <Tag color="green">{statsNode.activeCount} {t('subscription.active')}</Tag>
+                  ) : null}
+                  {statsNode.disabledCount ? (
+                    <Tag>{statsNode.disabledCount} {t('disabled')}</Tag>
                   ) : null}
                   {statsNode.depletedCount ? (
                     <Tag color="red">{statsNode.depletedCount} {t('depleted')}</Tag>
                   ) : null}
+                  {statsNode.onlineCount ? (
+                    <Tag color="blue">{statsNode.onlineCount} {t('online')}</Tag>
+                  ) : null}
                 </div>
                 <div className="stat-row">
                   <span className="stat-label">{t('pages.nodes.lastHeartbeat')}</span>

+ 2 - 1
frontend/src/pages/xray/XrayPage.tsx

@@ -78,7 +78,8 @@ export default function XrayPage() {
   const [advSettings, setAdvSettings] = useState<AdvKey>('xraySetting');
   const location = useLocation();
   const navigate = useNavigate();
-  const sectionSlug = location.hash.replace(/^#/, '');
+  const pathSection = location.pathname === '/outbound' ? 'outbound' : location.pathname === '/routing' ? 'routing' : '';
+  const sectionSlug = pathSection || location.hash.replace(/^#/, '');
   const activeSection = SECTION_SLUGS.includes(sectionSlug) ? sectionSlug : 'basic';
 
   const mutate = useCallback(

+ 2 - 0
frontend/src/routes.tsx

@@ -30,6 +30,8 @@ const routes: RouteObject[] = [
       { path: 'hosts', element: withSuspense(<HostsPage />) },
       { path: 'settings', element: withSuspense(<SettingsPage />) },
       { path: 'xray', element: withSuspense(<XrayPage />) },
+      { path: 'outbound', element: withSuspense(<XrayPage />) },
+      { path: 'routing', element: withSuspense(<XrayPage />) },
       { path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
     ],
   },

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

@@ -20,6 +20,8 @@ export const NodeRecordSchema = z.object({
   inboundCount: z.number().optional(),
   clientCount: z.number().optional(),
   onlineCount: z.number().optional(),
+  activeCount: z.number().optional(),
+  disabledCount: z.number().optional(),
   depletedCount: z.number().optional(),
   lastHeartbeat: z.number().optional(),
   lastError: z.string().optional(),

+ 39 - 13
install.sh

@@ -1300,6 +1300,32 @@ EOF
     ${xui_folder}/x-ui migrate
 }
 
+# setup_fail2ban auto-installs and configures fail2ban for the IP Limit feature
+# by invoking the freshly installed x-ui CLI. IP Limit is load-bearing on
+# fail2ban (without it the panel disables the limitIp field and zeroes existing
+# limits), so a fresh install should make it work out of the box, just like the
+# Docker image already does. Non-fatal by design: a fail2ban failure must never
+# abort the panel install.
+setup_fail2ban() {
+    if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
+        echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
+    if [[ ! -x /usr/bin/x-ui ]]; then
+        echo -e "${yellow}x-ui CLI not found; skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
+    echo -e "${green}Setting up Fail2ban for the IP Limit feature...${plain}"
+    if /usr/bin/x-ui setup-fail2ban; then
+        echo -e "${green}Fail2ban setup complete.${plain}"
+    else
+        echo -e "${yellow}Fail2ban setup did not finish; IP Limit stays disabled until you run 'x-ui' and open the IP Limit menu. Continuing.${plain}"
+    fi
+    return 0
+}
+
 install_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -1307,15 +1333,11 @@ install_x-ui() {
     if [ $# == 0 ]; then
         tag_version=$(curl -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
         if [[ ! -n "$tag_version" ]]; then
-            echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
-            tag_version=$(curl -4 -Ls --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 60 "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
-            if [[ ! -n "$tag_version" ]]; then
-                echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
-                exit 1
-            fi
+            echo -e "${red}Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later${plain}"
+            exit 1
         fi
         echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
-        curl -4fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
+        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Downloading x-ui failed, please be sure that your server can access GitHub ${plain}"
             exit 1
@@ -1332,13 +1354,13 @@ install_x-ui() {
 
         url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
         echo -e "Beginning to install x-ui $1"
-        curl -4fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
+        curl -fLR --retry 5 --retry-delay 3 --connect-timeout 15 --max-time 300 -o ${xui_folder}-linux-$(arch).tar.gz ${url}
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Download x-ui $1 failed, please check if the version exists ${plain}"
             exit 1
         fi
     fi
-    curl -4fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
+    curl -fLRo /usr/bin/x-ui-temp https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh
     if [[ $? -ne 0 ]]; then
         echo -e "${red}Failed to download x-ui.sh${plain}"
         exit 1
@@ -1404,7 +1426,7 @@ install_x-ui() {
     fi
 
     if [[ $release == "alpine" ]]; then
-        curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
+        curl -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
         if [[ $? -ne 0 ]]; then
             echo -e "${red}Failed to download x-ui.rc${plain}"
             exit 1
@@ -1461,13 +1483,13 @@ install_x-ui() {
             echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
             case "${release}" in
                 ubuntu | debian | armbian)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
+                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
                     ;;
                 arch | manjaro | parch)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
+                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
                     ;;
                 *)
-                    curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
+                    curl -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
                     ;;
             esac
 
@@ -1491,6 +1513,10 @@ install_x-ui() {
         fi
     fi
 
+    # IP Limit relies on fail2ban; install + configure it now so the feature
+    # works out of the box (no-op when XUI_ENABLE_FAIL2BAN=false). Never fatal.
+    setup_fail2ban
+
     echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
     echo -e ""
     echo -e "┌───────────────────────────────────────────────────────┐

+ 97 - 0
internal/database/db.go

@@ -11,7 +11,9 @@ import (
 	"log"
 	"math"
 	"os"
+	"os/exec"
 	"path"
+	"runtime"
 	"slices"
 	"strconv"
 	"strings"
@@ -464,9 +466,104 @@ func runSeeders(isUsersEmpty bool) error {
 	if err := seedHostsFromExternalProxy(); err != nil {
 		return err
 	}
+
+	// Self-gated on the "ResetIpLimitNoFail2ban" row.
+	if err := resetIpLimitsWithoutFail2ban(); err != nil {
+		return err
+	}
 	return nil
 }
 
+// resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
+// fail2ban can't enforce it (not installed, or the integration disabled). The
+// limit silently does nothing there yet kept logging a repeated warning, so a
+// stale value is just misleading — the panel also disables the field on these
+// hosts. One-time, self-gated on the seeder row.
+func resetIpLimitsWithoutFail2ban() error {
+	var history []string
+	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
+		return err
+	}
+	if slices.Contains(history, "ResetIpLimitNoFail2ban") {
+		return nil
+	}
+
+	if fail2banCanEnforce() {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
+	}
+
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.Settings) == "" {
+				continue
+			}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				log.Printf("ResetIpLimitNoFail2ban: skip inbound %d (invalid settings json): %v", inbound.Id, err)
+				continue
+			}
+			clients, ok := settings["clients"].([]any)
+			if !ok {
+				continue
+			}
+			mutated := false
+			for i, raw := range clients {
+				obj, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				v, present := obj["limitIp"]
+				if !present {
+					continue
+				}
+				if n, isNum := v.(float64); isNum && n == 0 {
+					continue
+				}
+				obj["limitIp"] = 0
+				clients[i] = obj
+				mutated = true
+			}
+			if !mutated {
+				continue
+			}
+			settings["clients"] = clients
+			newSettings, err := json.MarshalIndent(settings, "", "  ")
+			if err != nil {
+				log.Printf("ResetIpLimitNoFail2ban: skip inbound %d (marshal failed): %v", inbound.Id, err)
+				continue
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+				Update("settings", string(newSettings)).Error; err != nil {
+				return err
+			}
+		}
+		if err := tx.Model(&model.ClientRecord{}).Where("limit_ip <> ?", 0).
+			Update("limit_ip", 0).Error; err != nil {
+			return err
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
+	})
+}
+
+// fail2banCanEnforce reports whether per-client IP limits can actually be
+// enforced on this host: the integration must be enabled (XUI_ENABLE_FAIL2BAN)
+// and fail2ban-client must be present. Mirrors the service-layer check, kept
+// local to avoid an import cycle.
+func fail2banCanEnforce() bool {
+	if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
+		return false
+	}
+	if runtime.GOOS == "windows" {
+		return false
+	}
+	return exec.Command("fail2ban-client", "-h").Run() == nil
+}
+
 // clearLegacyProxySettings drops the deprecated panelProxy/tgBotProxy rows so a
 // stale tgBotProxy no longer masks the panelOutbound egress fallback.
 func clearLegacyProxySettings() error {

+ 2 - 0
internal/database/model/model.go

@@ -545,6 +545,8 @@ type Node struct {
 	InboundCount  int `json:"inboundCount" gorm:"-" example:"5"`
 	ClientCount   int `json:"clientCount" gorm:"-" example:"27"`
 	OnlineCount   int `json:"onlineCount" gorm:"-" example:"3"`
+	ActiveCount   int `json:"activeCount" gorm:"-" example:"23"`
+	DisabledCount int `json:"disabledCount" gorm:"-" example:"3"`
 	DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
 
 	// ParentGuid + Transitive are set only when a node is surfaced as part of a

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

@@ -133,7 +133,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			"/": true, "/panel/": true, "/panel/inbounds": true,
 			"/panel/clients": true, "/panel/groups": true,
 			"/panel/nodes": true, "/panel/settings": true,
-			"/panel/xray": true, "/panel/api-docs": true,
+			"/panel/xray": true, "/panel/outbound": true,
+			"/panel/routing": true, "/panel/api-docs": true,
 		}
 		if spaPages[r.Path] {
 			continue

+ 6 - 5
internal/web/controller/server.go

@@ -8,7 +8,6 @@ import (
 	"strconv"
 	"time"
 
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
@@ -64,6 +63,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
 	g.GET("/getNewVlessEnc", a.getNewVlessEnc)
 	g.GET("/clientIps", a.getClientIps)
+	g.GET("/fail2banStatus", a.getFail2banStatus)
 
 	g.POST("/stopXrayService", a.stopXrayService)
 	g.POST("/restartXrayService", a.restartXrayService)
@@ -104,6 +104,10 @@ func (a *ServerController) startTask() {
 // status returns the current server status information.
 func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
 
+func (a *ServerController) getFail2banStatus(c *gin.Context) {
+	jsonObj(c, a.serverService.GetFail2banStatus(), nil)
+}
+
 func parseHistoryBucket(c *gin.Context) (int, bool) {
 	bucket, err := strconv.Atoi(c.Param("bucket"))
 	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
@@ -294,10 +298,7 @@ func (a *ServerController) getDb(c *gin.Context) {
 		return
 	}
 
-	filename := "x-ui.db"
-	if database.IsPostgres() {
-		filename = "x-ui.dump"
-	}
+	filename := a.serverService.BackupFilename()
 	if !filenameRegex.MatchString(filename) {
 		c.AbortWithError(http.StatusBadRequest, fmt.Errorf("invalid filename"))
 		return

+ 2 - 0
internal/web/controller/spa.go

@@ -40,6 +40,8 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	g.GET("/nodes", a.panelSPA)
 	g.GET("/settings", a.panelSPA)
 	g.GET("/xray", a.panelSPA)
+	g.GET("/outbound", a.panelSPA)
+	g.GET("/routing", a.panelSPA)
 	g.GET("/api-docs", a.panelSPA)
 
 	// SPA pages built by Vite don't have a server-rendered <meta name="csrf-token">,

+ 4 - 3
internal/web/job/check_client_ip_job.go

@@ -88,11 +88,12 @@ func (j *CheckClientIpJob) Run() {
 	}
 }
 
-// resolveEnforce decides whether limits can actually be enforced this run,
-// warning when fail2ban is missing on a platform that needs it.
+// resolveEnforce decides whether limits can actually be enforced this run.
+// Without fail2ban on a platform that needs it the limit can't be applied, so
+// enforcement is skipped (the panel resets these limits to 0 on upgrade and
+// disables the field, so this is normally a no-op).
 func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
 	if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
-		logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
 		return false
 	}
 	return hasLimit

+ 105 - 7
internal/web/job/node_traffic_sync_job.go

@@ -12,6 +12,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 const (
@@ -21,8 +22,18 @@ const (
 	nodeClientIpSyncInterval      = 10 * time.Second
 	nodeClientIpSyncTimeout       = 6 * time.Second
 	nodeGlobalPushInterval        = 30 * time.Second
+	// nodeInboundSpeedWindowMs is the poll window node-inbound speed deltas are
+	// normalized to; it MUST match the dashboard's TRAFFIC_POLL_INTERVAL_S (5s),
+	// the fixed divisor the frontend applies to turn a delta into a rate.
+	nodeInboundSpeedWindowMs int64 = 5000
 )
 
+// inboundSample is a node inbound's last-seen cumulative up/down and the time
+// (unix millis) its counter last changed, used to derive a normalized speed.
+type inboundSample struct {
+	up, down, at int64
+}
+
 type NodeTrafficSyncJob struct {
 	nodeService    service.NodeService
 	inboundService service.InboundService
@@ -34,6 +45,14 @@ type NodeTrafficSyncJob struct {
 	lastIpSync     int64
 	globalPushMu   sync.Mutex
 	lastGlobalPush int64
+	// noGuidIpEndpoint tracks nodes (by id) whose client-IP attribution endpoint
+	// returned 404, so an old-build node is noted once instead of every cycle.
+	noGuidIpEndpoint sync.Map
+	// prevInboundTotals holds the previous poll's cumulative up/down (and the time
+	// the counter last changed) per node inbound tag, so the next poll can derive
+	// a per-inbound speed delta — node inbounds have no local Xray poll. Touched
+	// only from Run (serialized).
+	prevInboundTotals map[string]inboundSample
 }
 
 type atomicBool struct {
@@ -137,6 +156,10 @@ func (j *NodeTrafficSyncJob) Run() {
 	// xray's clients and inbounds still age out between traffic polls.
 	j.inboundService.RefreshLocalOnlineClients(nil, nil)
 
+	// Derive per-node-inbound speed every tick (keeps the baseline fresh even
+	// with no dashboard open); only broadcast it when someone is watching.
+	inboundSpeed := j.nodeInboundSpeed()
+
 	if !websocket.HasClients() {
 		return
 	}
@@ -145,12 +168,18 @@ func (j *NodeTrafficSyncJob) Run() {
 	if online == nil {
 		online = []string{}
 	}
-	websocket.BroadcastTraffic(map[string]any{
+	trafficPayload := map[string]any{
 		"onlineClients":  online,
 		"onlineByGuid":   j.inboundService.GetOnlineClientsByGuid(),
 		"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
 		"lastOnlineMap":  lastOnline,
-	})
+	}
+	// Always send the key so the dashboard clears node inbounds that went idle
+	// this tick. A nil result (query error) marshals to null and is skipped
+	// client-side, leaving the last shown value untouched; an empty (non-nil)
+	// slice marshals to [] and clears stale speeds.
+	trafficPayload["nodeTraffics"] = inboundSpeed
+	websocket.BroadcastTraffic(trafficPayload)
 
 	clientStats := map[string]any{}
 	if stats, err := j.inboundService.GetAllClientTraffics(); err != nil {
@@ -173,6 +202,65 @@ func (j *NodeTrafficSyncJob) Run() {
 	}
 }
 
+// nodeInboundSpeed derives a per-node-inbound speed delta by diffing the current
+// cumulative up/down against the previous poll's, keyed by the central tag the
+// dashboard matches. The node's counter keeps climbing while the master can't
+// reach it, so the first delta after a gap (node outage, skipped poll, slow
+// node) spans more than one poll window; it is normalized to the fixed
+// nodeInboundSpeedWindowMs using the real elapsed time so the dashboard's fixed
+// divisor yields the true average rate over the gap instead of an impossible
+// one-tick spike. The change timestamp only advances when the value actually
+// moves, so an idle stretch is averaged correctly when traffic resumes. A reset
+// rebaselines to the lower value; a first-seen tag yields no delta until the
+// next poll.
+func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic {
+	totals, err := j.inboundService.GetNodeInboundTrafficTotals()
+	if err != nil {
+		return nil
+	}
+	now := time.Now().UnixMilli()
+	deltas := make([]*xray.Traffic, 0, len(totals))
+	next := make(map[string]inboundSample, len(totals))
+	for tag, cur := range totals {
+		prev, ok := j.prevInboundTotals[tag]
+		if !ok {
+			next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
+			continue
+		}
+		dUp := cur[0] - prev.up
+		dDown := cur[1] - prev.down
+		if dUp <= 0 && dDown <= 0 {
+			// No movement, or a counter reset: hold the change timestamp so a
+			// later jump is averaged over the real elapsed window, not shown as a
+			// spike. Adopt the lower value on a reset.
+			if cur[0] < prev.up || cur[1] < prev.down {
+				next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
+			} else {
+				next[tag] = prev
+			}
+			continue
+		}
+		if dUp < 0 {
+			dUp = 0
+		}
+		if dDown < 0 {
+			dDown = 0
+		}
+		elapsed := now - prev.at
+		if elapsed < nodeInboundSpeedWindowMs {
+			elapsed = nodeInboundSpeedWindowMs
+		}
+		up := dUp * nodeInboundSpeedWindowMs / elapsed
+		down := dDown * nodeInboundSpeedWindowMs / elapsed
+		if up > 0 || down > 0 {
+			deltas = append(deltas, &xray.Traffic{Tag: tag, IsInbound: true, Up: up, Down: down})
+		}
+		next[tag] = inboundSample{up: cur[0], down: cur[1], at: now}
+	}
+	j.prevInboundTotals = next
+	return deltas
+}
+
 // maybePushGlobals broadcasts this panel's aggregated per-client usage to its
 // online nodes so each node can display the client's cross-panel total and
 // enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped
@@ -303,12 +391,22 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 
 	// Per-node IP attribution: pull the node's guid-keyed subtree (its own
 	// observations plus any descendants) so the master can tell which node each
-	// IP is on. Old nodes without the endpoint just return an error — skip them.
+	// IP is on. Old nodes without the endpoint return HTTP 404 every cycle — note
+	// it once per node (re-armed on recovery) instead of flooding the log.
 	if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil {
-		logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
-	} else if len(guidTrees) > 0 {
-		if err := j.inboundService.MergeClientIpsByGuid(guidTrees); err != nil {
-			logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
+		if strings.Contains(err.Error(), "HTTP 404") {
+			if _, seen := j.noGuidIpEndpoint.LoadOrStore(n.Id, true); !seen {
+				logger.Debugf("node traffic sync: node %s has no client-IP attribution endpoint (old build)", n.Name)
+			}
+		} else {
+			logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err)
+		}
+	} else {
+		j.noGuidIpEndpoint.Delete(n.Id)
+		if len(guidTrees) > 0 {
+			if err := j.inboundService.MergeClientIpsByGuid(n, guidTrees); err != nil {
+				logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err)
+			}
 		}
 	}
 }

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

@@ -0,0 +1,38 @@
+package service
+
+import (
+	"regexp"
+	"testing"
+)
+
+// getDb (controller) only accepts a Content-Disposition filename matching this
+// pattern, so every sanitizeBackupHost output must satisfy it.
+var backupFilenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
+
+func TestSanitizeBackupHost(t *testing.T) {
+	cases := []struct {
+		name string
+		in   string
+		want string
+	}{
+		{"domain", "panel.example.com", "panel.example.com"},
+		{"ipv4", "203.0.113.5", "203.0.113.5"},
+		{"ipv6", "2001:db8::1", "2001-db8--1"},
+		{"ipv6 bracketed", "[fe80::1]", "fe80--1"},
+		{"domain with port", "example.com:8443", "example.com-8443"},
+		{"trims edge dots and dashes", "-.example.com.-", "example.com"},
+		{"empty falls back", "", "x-ui"},
+		{"all invalid falls back", ":::", "x-ui"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got := sanitizeBackupHost(tc.in)
+			if got != tc.want {
+				t.Errorf("sanitizeBackupHost(%q) = %q, want %q", tc.in, got, tc.want)
+			}
+			if !backupFilenameRegex.MatchString(got) {
+				t.Errorf("sanitizeBackupHost(%q) = %q, not a valid download filename", tc.in, got)
+			}
+		})
+	}
+}

+ 3 - 0
internal/web/service/email/subscriber.go

@@ -31,6 +31,9 @@ func NewSubscriber(settingService service.SettingService, emailService *EmailSer
 
 // HandleEvent is the eventbus subscriber callback.
 func (s *Subscriber) HandleEvent(e eventbus.Event) {
+	if on, err := s.settingService.GetSmtpEnable(); err != nil || !on {
+		return
+	}
 	if !s.isEventEnabled(e.Type) {
 		return
 	}

+ 60 - 12
internal/web/service/inbound_node.go

@@ -201,6 +201,29 @@ func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnaps
 	return structuralChange, err
 }
 
+// GetNodeInboundTrafficTotals returns the current cumulative up/down for every
+// node-hosted inbound, keyed by tag. The node sync diffs successive snapshots of
+// this to derive per-inbound speed for the dashboard — node inbounds have no
+// local Xray poll to produce live deltas the way local inbounds do.
+func (s *InboundService) GetNodeInboundTrafficTotals() (map[string][2]int64, error) {
+	var rows []struct {
+		Tag  string
+		Up   int64
+		Down int64
+	}
+	if err := database.GetDB().Table("inbounds").
+		Select("tag, up, down").
+		Where("node_id IS NOT NULL").
+		Scan(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make(map[string][2]int64, len(rows))
+	for _, r := range rows {
+		out[r.Tag] = [2]int64{r.Up, r.Down}
+	}
+	return out, nil
+}
+
 func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
 	if snap == nil || nodeID <= 0 {
 		return false, nil
@@ -209,18 +232,23 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	now := time.Now().UnixMilli()
 
 	// originGuidFor attributes a synced inbound to the panel that physically
-	// hosts it: inbounds the node forwards from its own sub-nodes already carry
-	// a non-empty OriginNodeGuid (kept as-is across hops); the node's own local
-	// inbounds report empty, so they are attributed to the node's own GUID. An
-	// empty result (old-build node with no GUID yet) leaves attribution to the
-	// node_id fallback downstream (#4983).
+	// hosts it. A node's OWN inbounds report either an empty origin or — on
+	// builds that set it locally — the node's own panelGuid; both resolve to
+	// selfKey, which is the node's panelGuid unless that GUID is ambiguous
+	// (shared with another node or the master, i.e. a cloned server), in which
+	// case it falls back to the node-unique id so #4983 attribution doesn't
+	// collapse two physical nodes into one bucket. Only a DIFFERENT, non-empty
+	// origin (an inbound the node forwards from its own sub-node) is kept as-is,
+	// so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3.
 	var nodeRow model.Node
 	db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
+	selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid})
+	guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid
 	originGuidFor := func(snapIb *model.Inbound) string {
-		if snapIb.OriginNodeGuid != "" {
+		if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid {
 			return snapIb.OriginNodeGuid
 		}
-		return nodeRow.Guid
+		return selfKey
 	}
 
 	var central []model.Inbound
@@ -494,6 +522,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		if dirty {
 			continue
 		}
+		if len(snapTags) == 0 {
+			// A node mid-restart or with a transient DB error can return an empty
+			// inbound list with success=true. Treat "zero inbounds reported" as
+			// "nothing to say", not "delete all my inbounds" — otherwise a blip
+			// wipes the node's central inbounds and every client on them (and
+			// resets traffic history on re-create). A real per-inbound deletion
+			// still sweeps, because the node keeps reporting its other inbounds.
+			continue
+		}
 		if _, kept := snapTags[c.Tag]; kept {
 			continue
 		}
@@ -810,14 +847,25 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 
 	if p != nil {
 		tree := snap.OnlineTree
-		if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
+		switch {
+		case len(tree) == 0 && len(snap.OnlineEmails) > 0:
 			// Old-build node (no GUID tree): key its flat online list under its
 			// own effective identity so attribution still works for that branch.
-			effectiveGuid := nodeRow.Guid
-			if effectiveGuid == "" {
-				effectiveGuid = synthNodeGuid(nodeID)
+			tree = map[string][]string{selfKey: snap.OnlineEmails}
+		case guidShared && len(tree) > 0:
+			// Newer cloned node: its own clients arrive keyed under the shared
+			// panelGuid. Remap just that entry to the node-unique key so the
+			// clones don't merge; descendant subtrees keep their distinct GUIDs.
+			if _, ok := tree[nodeRow.Guid]; ok {
+				remapped := make(map[string][]string, len(tree))
+				for g, emails := range tree {
+					if g == nodeRow.Guid {
+						g = selfKey
+					}
+					remapped[g] = emails
+				}
+				tree = remapped
 			}
-			tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
 		}
 		p.SetNodeOnlineTree(nodeID, tree)
 	}

+ 31 - 7
internal/web/service/inbound_node_ips.go

@@ -113,8 +113,26 @@ func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[str
 
 // MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own
 // panelGuid subtree plus any descendants) into the local table, preserving which
-// physical node each IP is on across a chain.
-func (s *InboundService) MergeClientIpsByGuid(trees map[string]map[string][]model.ClientIpEntry) error {
+// physical node each IP is on across a chain. When node is non-nil and its own
+// panelGuid is ambiguous (shared with another node or the master — a cloned
+// server), the node's own subtree is remapped to its node-unique key so two
+// clones don't collapse into one attribution row; descendant subtrees keep their
+// distinct GUIDs. A nil node merges the report verbatim.
+func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string]map[string][]model.ClientIpEntry) error {
+	if node != nil && node.Guid != "" {
+		if eff := effectiveNodeKey(node); eff != node.Guid {
+			if sub, ok := trees[node.Guid]; ok {
+				delete(trees, node.Guid)
+				if existing, ok := trees[eff]; ok {
+					for email, ips := range sub {
+						existing[email] = append(existing[email], ips...)
+					}
+				} else {
+					trees[eff] = sub
+				}
+			}
+		}
+	}
 	for guid, perEmail := range trees {
 		if err := upsertNodeClientIps(guid, perEmail); err != nil {
 			return err
@@ -242,18 +260,24 @@ func (s *InboundService) GetClientIpsWithNodes(email string) ([]ClientIpInfo, er
 	return out, nil
 }
 
-// nodeGuidNameMap maps each known node's stable guid to its display name.
+// nodeGuidNameMap maps each known node's attribution key to its display name,
+// keyed by effectiveNodeGuid so a cloned node's IPs (stored under its node-unique
+// key) still resolve to the right name instead of colliding under a shared GUID.
 func (s *InboundService) nodeGuidNameMap() map[string]string {
 	db := database.GetDB()
 	var nodes []model.Node
 	if err := db.Model(&model.Node{}).Find(&nodes).Error; err != nil {
 		return map[string]string{}
 	}
+	ptrs := make([]*model.Node, len(nodes))
+	for i := range nodes {
+		ptrs[i] = &nodes[i]
+	}
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+	ambiguous := ambiguousNodeGuids(ptrs, selfGuid)
 	m := make(map[string]string, len(nodes))
-	for _, n := range nodes {
-		if n.Guid != "" {
-			m[n.Guid] = n.Name
-		}
+	for i := range nodes {
+		m[effectiveNodeGuid(&nodes[i], ambiguous)] = nodes[i].Name
 	}
 	return m
 }

+ 2 - 2
internal/web/service/inbound_node_ips_test.go

@@ -103,7 +103,7 @@ func TestGetClientIpNodeAttribution_NewestGuidWins(t *testing.T) {
 	}); err != nil {
 		t.Fatalf("record gA: %v", err)
 	}
-	if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
+	if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{
 		"gB": {"u@x": {{IP: "9.9.9.9", Timestamp: now}}},
 	}); err != nil {
 		t.Fatalf("merge gB: %v", err)
@@ -145,7 +145,7 @@ func TestGetClientIpsWithNodes_LabelsNodes(t *testing.T) {
 	}); err != nil {
 		t.Fatalf("record local: %v", err)
 	}
-	if err := svc.MergeClientIpsByGuid(map[string]map[string][]model.ClientIpEntry{
+	if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{
 		"node-guid": {"u@x": {{IP: "2.2.2.2", Timestamp: now}}},
 	}); err != nil {
 		t.Fatalf("merge node: %v", err)

+ 180 - 52
internal/web/service/node.go

@@ -14,6 +14,7 @@ import (
 	"slices"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
@@ -122,10 +123,8 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 		return nodes, nil
 	}
 	inboundsByNode := make(map[int][]int, len(nodes))
-	nodeByInbound := make(map[int]int, len(inboundRows))
 	for _, row := range inboundRows {
 		inboundsByNode[row.NodeID] = append(inboundsByNode[row.NodeID], row.Id)
-		nodeByInbound[row.Id] = row.NodeID
 	}
 
 	type clientCountRow struct {
@@ -150,60 +149,105 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
 		}
 	}
 
-	now := time.Now().UnixMilli()
-	type trafficRow struct {
-		InboundID  int `gorm:"column:inbound_id"`
-		Email      string
-		Enable     bool
-		Total      int64
-		Up         int64
-		Down       int64
-		ExpiryTime int64 `gorm:"column:expiry_time"`
-	}
-	var trafficRows []trafficRow
-	inboundIDs := make([]int, 0, len(nodeByInbound))
-	for id := range nodeByInbound {
-		inboundIDs = append(inboundIDs, id)
-	}
-	// Chunk the IN clause to avoid "too many SQL variables" on SQLite
-	// when there are many node-owned inbounds (common with many nodes).
-	// sqliteMaxVars is defined in this package (inbound.go).
-	for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
-		var page []trafficRow
-		if err := db.Table("client_traffics").
-			Select("inbound_id, email, enable, total, up, down, expiry_time").
-			Where("inbound_id IN ?", batch).
-			Scan(&page).Error; err == nil {
-			trafficRows = append(trafficRows, page...)
-		}
-	}
 	depletedByNode := make(map[int]int)
-	if len(trafficRows) > 0 {
-		for _, row := range trafficRows {
-			nodeID, ok := nodeByInbound[row.InboundID]
-			if !ok {
-				continue
-			}
-			expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
-			exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
-			if expired || exhausted || !row.Enable {
-				depletedByNode[nodeID]++
-			}
+	disabledByNode := make(map[int]int)
+	activeByNode := make(map[int]int)
+	statuses, _ := s.nodeClientStatuses()
+	seen := make(map[int]map[int]struct{}, len(nodes))
+	for _, st := range statuses {
+		clientsSeen := seen[st.NodeID]
+		if clientsSeen == nil {
+			clientsSeen = make(map[int]struct{})
+			seen[st.NodeID] = clientsSeen
+		}
+		if _, dup := clientsSeen[st.ClientID]; dup {
+			// A client attached to several inbounds of one node counts once,
+			// matching the distinct ClientCount above.
+			continue
+		}
+		clientsSeen[st.ClientID] = struct{}{}
+		switch {
+		case st.Depleted:
+			depletedByNode[st.NodeID]++
+		case st.Disabled:
+			disabledByNode[st.NodeID]++
+		default:
+			activeByNode[st.NodeID]++
 		}
 	}
 	onlineByGuid := s.onlineEmailsByGuid()
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+	ambiguous := ambiguousNodeGuids(nodes, selfGuid)
 	for _, n := range nodes {
 		n.InboundCount = len(inboundsByNode[n.Id])
 		n.DepletedCount = depletedByNode[n.Id]
+		n.DisabledCount = disabledByNode[n.Id]
+		n.ActiveCount = activeByNode[n.Id]
 		// Online is attributed to the node that physically hosts the client
 		// (by GUID): a client on a sub-node counts under the sub-node, not
 		// the intermediate node it syncs through (#4983).
-		n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
+		n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n, ambiguous)])
 	}
 
 	return nodes, nil
 }
 
+// nodeClientStatus is one node-hosted client's classification, carrying enough
+// identity for callers to bucket it by node id or by attribution GUID.
+type nodeClientStatus struct {
+	InboundID int
+	NodeID    int
+	ClientID  int
+	Depleted  bool
+	Disabled  bool
+}
+
+// nodeClientStatuses classifies every client attached to a node-hosted inbound as
+// depleted / disabled / active, matching client_traffics by EMAIL rather than by
+// inbound_id. client_traffics.inbound_id goes stale after an inbound is
+// delete+recreated, so filtering by it silently drops most rows; the
+// client_inbounds -> clients join is the reliable client set and the email join
+// pulls each client's live counters. Precedence matches the inbound page:
+// depleted (expired/exhausted) wins over disabled.
+func (s *NodeService) nodeClientStatuses() ([]nodeClientStatus, error) {
+	type row struct {
+		InboundID  int   `gorm:"column:inbound_id"`
+		NodeID     int   `gorm:"column:node_id"`
+		ClientID   int   `gorm:"column:client_id"`
+		Enable     bool  `gorm:"column:enable"`
+		Total      int64 `gorm:"column:total"`
+		Up         int64 `gorm:"column:up"`
+		Down       int64 `gorm:"column:down"`
+		ExpiryTime int64 `gorm:"column:expiry_time"`
+	}
+	var rows []row
+	if err := database.GetDB().Table("inbounds").
+		Select("inbounds.id AS inbound_id, inbounds.node_id AS node_id, clients.id AS client_id, " +
+			"clients.enable AS enable, ct.total AS total, ct.up AS up, ct.down AS down, ct.expiry_time AS expiry_time").
+		Joins("JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Joins("LEFT JOIN client_traffics ct ON ct.email = clients.email").
+		Where("inbounds.node_id IS NOT NULL").
+		Scan(&rows).Error; err != nil {
+		return nil, err
+	}
+	now := time.Now().UnixMilli()
+	out := make([]nodeClientStatus, 0, len(rows))
+	for _, r := range rows {
+		st := nodeClientStatus{InboundID: r.InboundID, NodeID: r.NodeID, ClientID: r.ClientID}
+		expired := r.ExpiryTime > 0 && r.ExpiryTime <= now
+		exhausted := r.Total > 0 && r.Up+r.Down >= r.Total
+		switch {
+		case expired || exhausted:
+			st.Depleted = true
+		case !r.Enable:
+			st.Disabled = true
+		}
+		out = append(out, st)
+	}
+	return out, nil
+}
+
 func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
 	svc := InboundService{}
 	byGuid := svc.GetOnlineClientsByGuid()
@@ -218,14 +262,69 @@ func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
 	return out
 }
 
-// effectiveNodeGuid is a node's stable online-attribution key: its reported
-// panelGuid, or a master-local synthetic id when the node is an old build that
-// hasn't reported one yet (#4983).
-func effectiveNodeGuid(n *model.Node) string {
-	if n.Guid != "" {
-		return n.Guid
+// effectiveNodeGuid is a node's stable online/inbound attribution key: its
+// reported panelGuid, or a master-local synthetic node-id fallback when the node
+// has no GUID yet (old build) or its GUID is ambiguous. ambiguous comes from
+// ambiguousNodeGuids.
+func effectiveNodeGuid(n *model.Node, ambiguous map[string]struct{}) string {
+	if n.Guid == "" {
+		return synthNodeGuid(n.Id)
+	}
+	if n.Id > 0 {
+		if _, bad := ambiguous[n.Guid]; bad {
+			return synthNodeGuid(n.Id)
+		}
+	}
+	return n.Guid
+}
+
+// ambiguousNodeGuids returns the panelGuids a node must not be attributed under
+// directly, because doing so would merge two distinct identities: a GUID
+// reported by more than one of this master's direct nodes (cloned node servers
+// ship the same panelGuid in their copied settings), or a GUID equal to the
+// master's own panelGuid (a node cloned from the master). A node holding such a
+// GUID falls back to its node-unique synthNodeGuid. Transitive sub-nodes (Id 0)
+// carry distinct descendant GUIDs by construction and are excluded.
+func ambiguousNodeGuids(nodes []*model.Node, selfGuid string) map[string]struct{} {
+	counts := make(map[string]int, len(nodes))
+	for _, n := range nodes {
+		if n.Id > 0 && n.Guid != "" {
+			counts[n.Guid]++
+		}
+	}
+	ambiguous := make(map[string]struct{})
+	for guid, c := range counts {
+		if c > 1 {
+			ambiguous[guid] = struct{}{}
+		}
 	}
-	return synthNodeGuid(n.Id)
+	if selfGuid != "" {
+		if _, ok := counts[selfGuid]; ok {
+			ambiguous[selfGuid] = struct{}{}
+		}
+	}
+	return ambiguous
+}
+
+// effectiveNodeKey returns one node's attribution key without a preloaded node
+// list — its panelGuid when that GUID uniquely identifies it among the master's
+// nodes and differs from the master's own, otherwise its node-unique
+// synthNodeGuid. Same rule as effectiveNodeGuid + ambiguousNodeGuids, for the
+// write paths that handle a single node (online tree, IP attribution).
+func effectiveNodeKey(node *model.Node) string {
+	if node == nil {
+		return ""
+	}
+	if node.Guid == "" {
+		return synthNodeGuid(node.Id)
+	}
+	var sameGuid int64
+	database.GetDB().Model(&model.Node{}).Where("guid = ?", node.Guid).Count(&sameGuid)
+	masterGuid, _ := (&SettingService{}).GetPanelGuid()
+	if sameGuid > 1 || node.Guid == masterGuid {
+		return synthNodeGuid(node.Id)
+	}
+	return node.Guid
 }
 
 func (s *NodeService) GetById(id int) (*model.Node, error) {
@@ -456,7 +555,9 @@ func (s *NodeService) Delete(id int) error {
 		return common.NewError(fmt.Sprintf("cannot delete node: %d inbound(s) still attached to it; detach or delete them first", attached))
 	}
 	// Capture the node's guid before deleting the row so we can drop its per-node
-	// IP attribution (NodeClientIp is keyed by guid, not node id).
+	// IP attribution. NodeClientIp is keyed by the node's attribution key, which
+	// is its guid normally but its node-unique key for a cloned/ambiguous-guid
+	// node (see effectiveNodeKey) — so we purge both below.
 	var guid string
 	var n model.Node
 	if err := db.Select("guid").Where("id = ?", id).First(&n).Error; err == nil {
@@ -470,10 +571,12 @@ func (s *NodeService) Delete(id int) error {
 		if err := tx.Where("node_id = ?", id).Delete(&model.NodeClientTraffic{}).Error; err != nil {
 			return err
 		}
+		guids := []string{synthNodeGuid(id)}
 		if guid != "" {
-			if err := tx.Where("node_guid = ?", guid).Delete(&model.NodeClientIp{}).Error; err != nil {
-				return err
-			}
+			guids = append(guids, guid)
+		}
+		if err := tx.Where("node_guid IN ?", guids).Delete(&model.NodeClientIp{}).Error; err != nil {
+			return err
 		}
 		return tx.Where("id = ?", id).Delete(&model.Node{}).Error
 	}); err != nil {
@@ -593,6 +696,7 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 	// failed probe) reports none, so the stable identity survives blips.
 	if p.Guid != "" {
 		updates["guid"] = p.Guid
+		s.warnOnDuplicateGuid(id, p.Guid)
 	}
 	if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
 		return err
@@ -607,6 +711,30 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 	return nil
 }
 
+// warnedDupGuid remembers the (nodeID -> guid) pairs already warned about so a
+// cloned-server collision is logged once, not every heartbeat.
+var warnedDupGuid sync.Map
+
+// warnOnDuplicateGuid logs once when a node reports a panelGuid already held by
+// another node or by the master itself (the cloned-server footgun). Attribution
+// still works — it falls back to node-unique keys — but the operator should
+// regenerate the duplicate panelGuid to restore real identity and per-node IP
+// attribution. Re-arms if the collision later clears.
+func (s *NodeService) warnOnDuplicateGuid(id int, guid string) {
+	var clash int64
+	database.GetDB().Model(&model.Node{}).Where("guid = ? AND id <> ?", guid, id).Count(&clash)
+	masterGuid, _ := (&SettingService{}).GetPanelGuid()
+	if clash == 0 && guid != masterGuid {
+		warnedDupGuid.Delete(id)
+		return
+	}
+	if prev, ok := warnedDupGuid.Load(id); ok && prev == guid {
+		return
+	}
+	warnedDupGuid.Store(id, guid)
+	logger.Warningf("node %d reports panelGuid %s already used by another node or the master (cloned server?) — regenerate it on that node so online and IP attribution stay per-node", id, guid)
+}
+
 func (s *NodeService) MarkNodeDirty(id int) error {
 	if id <= 0 {
 		return nil

+ 87 - 0
internal/web/service/node_client_breakdown_test.go

@@ -0,0 +1,87 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// The node-page client breakdown must classify by EMAIL, not by
+// client_traffics.inbound_id — that column goes stale after an inbound is
+// delete+recreated, so filtering by it drops almost every row and undercounts
+// active/disabled/ended. Here the traffic rows carry a bogus inbound_id (999)
+// yet must still be matched to the node's clients by email.
+func TestGetAll_ClientBreakdownMatchesByEmailNotStaleInboundId(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	svc := NodeService{}
+
+	if err := db.Create(&model.Node{Id: 1, Name: "n", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "g"}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	nid := 1
+	ib := &model.Inbound{Tag: "n1-in", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+
+	mk := func(id int, email string, enable bool) {
+		if err := db.Create(&model.ClientRecord{Id: id, Email: email, Enable: enable}).Error; err != nil {
+			t.Fatalf("create client %s: %v", email, err)
+		}
+		if !enable {
+			// Enable has gorm:"default:true", so a zero-value (false) create is
+			// dropped and the DB applies true — force the disabled state explicitly.
+			if err := db.Model(&model.ClientRecord{}).Where("id = ?", id).Update("enable", false).Error; err != nil {
+				t.Fatalf("disable client %s: %v", email, err)
+			}
+		}
+		if err := db.Create(&model.ClientInbound{ClientId: id, InboundId: ib.Id}).Error; err != nil {
+			t.Fatalf("attach client %s: %v", email, err)
+		}
+	}
+	mk(1, "active@x", true)
+	mk(2, "disabled@x", false)
+	mk(3, "depleted@x", true)
+
+	// Traffic rows carry a STALE inbound_id (999), unrelated to the node inbound.
+	const staleInboundID = 999
+	rows := []*xray.ClientTraffic{
+		{InboundId: staleInboundID, Email: "active@x", Enable: true},
+		{InboundId: staleInboundID, Email: "disabled@x", Enable: false},
+		{InboundId: staleInboundID, Email: "depleted@x", Enable: true, Total: 100, Up: 60, Down: 60}, // exhausted
+	}
+	for _, r := range rows {
+		if err := db.Create(r).Error; err != nil {
+			t.Fatalf("create traffic %s: %v", r.Email, err)
+		}
+	}
+
+	nodes, err := svc.GetAll()
+	if err != nil {
+		t.Fatalf("GetAll: %v", err)
+	}
+	var n *model.Node
+	for _, x := range nodes {
+		if x.Id == 1 {
+			n = x
+		}
+	}
+	if n == nil {
+		t.Fatal("node 1 not found")
+	}
+	if n.ClientCount != 3 {
+		t.Errorf("ClientCount = %d, want 3", n.ClientCount)
+	}
+	if n.ActiveCount != 1 {
+		t.Errorf("ActiveCount = %d, want 1 (matched by email despite stale inbound_id)", n.ActiveCount)
+	}
+	if n.DisabledCount != 1 {
+		t.Errorf("DisabledCount = %d, want 1", n.DisabledCount)
+	}
+	if n.DepletedCount != 1 {
+		t.Errorf("DepletedCount = %d, want 1", n.DepletedCount)
+	}
+}

+ 98 - 0
internal/web/service/node_origin_guid_test.go

@@ -70,6 +70,104 @@ func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
 	}
 }
 
+// A cloned node reports its OWN inbound with its own (duplicated) panelGuid as
+// the origin. That must be remapped to the node-unique key, not stored verbatim
+// — otherwise origin_node_guid keeps the shared GUID while online is keyed by
+// the node-unique key, and the inbound page reads an empty bucket (shows
+// offline). A genuinely forwarded sub-node GUID is still kept across the hop.
+func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	// Two nodes share one panelGuid (cloned servers).
+	for _, n := range []*model.Node{
+		{Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"},
+		{Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"},
+	} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node %s: %v", n.Name, err)
+		}
+	}
+
+	snap := &runtime.TrafficSnapshot{
+		Inbounds: []*model.Inbound{
+			{ // node 1's OWN inbound, reporting its own (shared) panelGuid as origin
+				Tag:            "own-443-tcp",
+				Enable:         true,
+				Port:           443,
+				Protocol:       model.VLESS,
+				Settings:       `{"clients":[]}`,
+				OriginNodeGuid: "dup",
+			},
+			{ // forwarded from a sub-node with a distinct guid — kept across the hop
+				Tag:            "fwd-8443-tcp",
+				Enable:         true,
+				Port:           8443,
+				Protocol:       model.VLESS,
+				Settings:       `{"clients":[]}`,
+				OriginNodeGuid: "child-guid",
+			},
+		},
+	}
+
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(1, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	origin := func(tag string) string {
+		var ib model.Inbound
+		if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
+			t.Fatalf("load inbound %q: %v", tag, err)
+		}
+		return ib.OriginNodeGuid
+	}
+
+	if og := origin("own-443-tcp"); og != "node:1" {
+		t.Fatalf("cloned node's own inbound origin = %q, want node:1 (remapped from shared GUID)", og)
+	}
+	if og := origin("fwd-8443-tcp"); og != "child-guid" {
+		t.Fatalf("forwarded inbound origin = %q, want child-guid (kept across the hop)", og)
+	}
+}
+
+// A node mid-restart can return an empty inbound list with success=true. The
+// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes
+// the node's central inbounds and every client on them (what happened to the
+// Germany node: 0 clients but still online).
+func TestSetRemoteTraffic_EmptySnapshotKeepsCentralInbounds(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 1
+	if err := db.Create(&model.Node{
+		Id: nodeID, Name: "n", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "g",
+	}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	nidPtr := nodeID
+	if err := db.Create(&model.Inbound{
+		UserId: 1, NodeID: &nidPtr, Tag: "remote-in", Enable: true,
+		Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`,
+	}).Error; err != nil {
+		t.Fatalf("create central inbound: %v", err)
+	}
+
+	// Empty snapshot — the node reported no inbounds this cycle.
+	svc := InboundService{}
+	if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{}, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var count int64
+	if err := db.Model(&model.Inbound{}).Where("tag = ?", "remote-in").Count(&count).Error; err != nil {
+		t.Fatalf("count inbounds: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("empty snapshot must not delete the central inbound; got count = %d", count)
+	}
+}
+
 func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) {
 	setupConflictDB(t)
 	db := database.GetDB()

+ 162 - 0
internal/web/service/node_shared_guid_test.go

@@ -0,0 +1,162 @@
+package service
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Cloned node servers ship an identical panelGuid in their copied settings, and
+// a node cloned from the master shares the master's own GUID. effectiveNodeGuid
+// must keep each physical node in its own attribution bucket by falling back to
+// the node-unique id for both collision kinds, while leaving a uniquely-named
+// node on its real GUID and never folding transitive (Id 0) nodes.
+func TestEffectiveNodeGuid_DisambiguatesAmbiguousGuids(t *testing.T) {
+	nodes := []*model.Node{
+		{Id: 1, Guid: "dup"},
+		{Id: 2, Guid: "dup"},
+		{Id: 3, Guid: "uniq"},
+		{Id: 4, Guid: ""},
+		{Id: 5, Guid: "master"},
+		{Id: 0, Guid: "transitive"},
+	}
+	ambiguous := ambiguousNodeGuids(nodes, "master")
+
+	if _, ok := ambiguous["dup"]; !ok {
+		t.Fatalf("dup must be flagged ambiguous, got %v", ambiguous)
+	}
+	if _, ok := ambiguous["master"]; !ok {
+		t.Fatalf("a node sharing the master GUID must be flagged, got %v", ambiguous)
+	}
+	if _, ok := ambiguous["uniq"]; ok {
+		t.Fatalf("uniq must not be flagged, got %v", ambiguous)
+	}
+	if _, ok := ambiguous["transitive"]; ok {
+		t.Fatalf("transitive (Id 0) must not count, got %v", ambiguous)
+	}
+
+	cases := map[*model.Node]string{
+		nodes[0]: "node:1",
+		nodes[1]: "node:2",
+		nodes[2]: "uniq",
+		nodes[3]: "node:4",
+		nodes[4]: "node:5",
+		nodes[5]: "transitive",
+	}
+	for n, want := range cases {
+		if got := effectiveNodeGuid(n, ambiguous); got != want {
+			t.Errorf("effectiveNodeGuid(Id=%d, Guid=%q) = %q, want %q", n.Id, n.Guid, got, want)
+		}
+	}
+}
+
+// effectiveNodeKey (the no-preloaded-list variant used by the write paths) must
+// agree with the slice helper: fall back to the node-unique id when a GUID is
+// shared with another node or with the master, else keep the real GUID.
+func TestEffectiveNodeKey_FallsBackOnCollision(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+	if selfGuid == "" {
+		t.Fatal("expected a panel guid")
+	}
+
+	mk := func(id int, name, guid string) *model.Node {
+		n := &model.Node{Id: id, Name: name, Address: fmt.Sprintf("10.0.0.%d", id), Port: 2053, ApiToken: "t", Guid: guid, Status: "online"}
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create %s: %v", name, err)
+		}
+		return n
+	}
+	dupA := mk(1, "a", "shared")
+	mk(2, "b", "shared")
+	uniq := mk(3, "c", "solo")
+	masterClone := mk(4, "d", selfGuid)
+
+	if got := effectiveNodeKey(dupA); got != "node:1" {
+		t.Errorf("node-node collision: got %q, want node:1", got)
+	}
+	if got := effectiveNodeKey(uniq); got != "solo" {
+		t.Errorf("unique node: got %q, want solo", got)
+	}
+	if got := effectiveNodeKey(masterClone); got != "node:4" {
+		t.Errorf("master collision: got %q, want node:4", got)
+	}
+}
+
+// recountByGuid must split per-node counts even when two direct nodes share a
+// GUID and their inbounds still carry that shared GUID as origin (pre-backfill).
+func TestRecountByGuid_SplitsClonedNodesWithSharedGuid(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	svc := NodeService{}
+	selfGuid, _ := (&SettingService{}).GetPanelGuid()
+
+	n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	n3 := &model.Node{Id: 3, Name: "C", Address: "10.0.0.3", Port: 2053, ApiToken: "t", Guid: "uniq", Status: "online"}
+	for _, n := range []*model.Node{n1, n2, n3} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node %s: %v", n.Name, err)
+		}
+	}
+
+	id1, id2, id3 := 1, 2, 3
+	inbounds := []*model.Inbound{
+		{Tag: "a", Port: 1001, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id1, OriginNodeGuid: "dup"},
+		{Tag: "b", Port: 1002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id2, OriginNodeGuid: "dup"},
+		{Tag: "c", Port: 1003, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id3, OriginNodeGuid: "uniq"},
+	}
+	for _, ib := range inbounds {
+		if err := db.Create(ib).Error; err != nil {
+			t.Fatalf("create inbound %s: %v", ib.Tag, err)
+		}
+	}
+
+	nodes := []*model.Node{n1, n2, n3}
+	svc.recountByGuid(nodes, selfGuid)
+
+	if n1.InboundCount != 1 || n2.InboundCount != 1 {
+		t.Errorf("cloned nodes must not share inbound counts: n1=%d n2=%d, want 1,1", n1.InboundCount, n2.InboundCount)
+	}
+	if n3.InboundCount != 1 {
+		t.Errorf("unique node InboundCount = %d, want 1", n3.InboundCount)
+	}
+}
+
+// A cloned node's IP-attribution subtree must be stored under its node-unique
+// key, so a second clone sharing the GUID can't overwrite it in node_client_ips.
+func TestMergeClientIpsByGuid_RemapsClonedNodeSubtree(t *testing.T) {
+	setupClientIpTestDB(t)
+	db := database.GetDB()
+	svc := &InboundService{}
+	now := time.Now().Unix()
+
+	n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"}
+	for _, n := range []*model.Node{n1, n2} {
+		if err := db.Create(n).Error; err != nil {
+			t.Fatalf("create node: %v", err)
+		}
+	}
+
+	if err := svc.MergeClientIpsByGuid(n1, map[string]map[string][]model.ClientIpEntry{
+		"dup": {"u@x": {{IP: "1.1.1.1", Timestamp: now}}},
+	}); err != nil {
+		t.Fatalf("merge n1: %v", err)
+	}
+
+	var rows []model.NodeClientIp
+	if err := db.Find(&rows).Error; err != nil {
+		t.Fatalf("load rows: %v", err)
+	}
+	if len(rows) != 1 {
+		t.Fatalf("want 1 attribution row, got %d", len(rows))
+	}
+	if rows[0].NodeGuid != "node:1" {
+		t.Errorf("cloned node IPs must be stored under node-unique key, got %q", rows[0].NodeGuid)
+	}
+}

+ 39 - 28
internal/web/service/node_tree.go

@@ -3,7 +3,6 @@ package service
 import (
 	"context"
 	"sync"
-	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -174,9 +173,9 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
 	if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
 		return
 	}
+	ambiguous := ambiguousNodeGuids(nodes, selfGuid)
 	effByInbound := make(map[int]string, len(ibRows))
 	inboundCountByGuid := make(map[string]int)
-	ids := make([]int, 0, len(ibRows))
 	for _, r := range ibRows {
 		guid := r.OriginNodeGuid
 		if guid == "" {
@@ -185,46 +184,58 @@ func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
 			} else {
 				guid = selfGuid
 			}
+		} else if r.NodeID != nil {
+			// Origin still holds an ambiguous GUID (cloned server / master-shared,
+			// not yet re-attributed): bucket under the hosting node's unique id so
+			// the clones don't merge.
+			if _, bad := ambiguous[guid]; bad {
+				guid = synthNodeGuid(*r.NodeID)
+			}
 		}
 		effByInbound[r.Id] = guid
 		inboundCountByGuid[guid]++
-		ids = append(ids, r.Id)
 	}
 
-	now := time.Now().UnixMilli()
+	// Classify by EMAIL (not the stale client_traffics.inbound_id) and bucket
+	// each client under its inbound's effective attribution GUID, deduping a
+	// client attached to several inbounds under the same GUID.
 	depletedByGuid := make(map[string]int)
-	if len(ids) > 0 {
-		type tRow struct {
-			InboundID  int `gorm:"column:inbound_id"`
-			Enable     bool
-			Total      int64
-			Up         int64
-			Down       int64
-			ExpiryTime int64 `gorm:"column:expiry_time"`
-		}
-		var tRows []tRow
-		if err := db.Table("client_traffics").
-			Select("inbound_id, enable, total, up, down, expiry_time").
-			Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
-			for _, row := range tRows {
-				guid, ok := effByInbound[row.InboundID]
-				if !ok {
-					continue
-				}
-				expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
-				exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
-				if expired || exhausted || !row.Enable {
-					depletedByGuid[guid]++
-				}
+	disabledByGuid := make(map[string]int)
+	activeByGuid := make(map[string]int)
+	if statuses, err := s.nodeClientStatuses(); err == nil {
+		seen := make(map[string]map[int]struct{})
+		for _, st := range statuses {
+			guid, ok := effByInbound[st.InboundID]
+			if !ok {
+				continue
+			}
+			clientsSeen := seen[guid]
+			if clientsSeen == nil {
+				clientsSeen = make(map[int]struct{})
+				seen[guid] = clientsSeen
+			}
+			if _, dup := clientsSeen[st.ClientID]; dup {
+				continue
+			}
+			clientsSeen[st.ClientID] = struct{}{}
+			switch {
+			case st.Depleted:
+				depletedByGuid[guid]++
+			case st.Disabled:
+				disabledByGuid[guid]++
+			default:
+				activeByGuid[guid]++
 			}
 		}
 	}
 
 	onlineByGuid := s.onlineEmailsByGuid()
 	for _, n := range nodes {
-		guid := effectiveNodeGuid(n)
+		guid := effectiveNodeGuid(n, ambiguous)
 		n.InboundCount = inboundCountByGuid[guid]
 		n.OnlineCount = len(onlineByGuid[guid])
 		n.DepletedCount = depletedByGuid[guid]
+		n.DisabledCount = disabledByGuid[guid]
+		n.ActiveCount = activeByGuid[guid]
 	}
 }

+ 106 - 0
internal/web/service/server.go

@@ -142,6 +142,10 @@ type ServerService struct {
 
 	versionsCacheMu sync.Mutex
 	versionsCache   *cachedXrayVersions
+
+	fail2banMu        sync.Mutex
+	fail2banInstalled bool
+	fail2banCheckedAt time.Time
 }
 
 type cachedXrayVersions struct {
@@ -185,6 +189,53 @@ func (s *ServerService) LastStatus() *Status {
 	return s.lastStatus
 }
 
+// Fail2banStatus tells the frontend whether the per-client IP limit can
+// actually be enforced. Enforcement depends on fail2ban, so a limit set
+// without it would silently do nothing.
+type Fail2banStatus struct {
+	Enabled   bool `json:"enabled"`
+	Installed bool `json:"installed"`
+	Usable    bool `json:"usable"`
+	Windows   bool `json:"windows"`
+}
+
+const fail2banInstalledCacheTTL = 30 * time.Second
+
+func (s *ServerService) GetFail2banStatus() Fail2banStatus {
+	enabled := isFail2banEnabled()
+
+	installed := false
+	if enabled {
+		installed = s.isFail2banInstalled()
+	}
+
+	return Fail2banStatus{
+		Enabled:   enabled,
+		Installed: installed,
+		Usable:    enabled && installed,
+		Windows:   runtime.GOOS == "windows",
+	}
+}
+
+func isFail2banEnabled() bool {
+	value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
+	return !ok || value == "true"
+}
+
+func (s *ServerService) isFail2banInstalled() bool {
+	s.fail2banMu.Lock()
+	defer s.fail2banMu.Unlock()
+
+	if !s.fail2banCheckedAt.IsZero() && time.Since(s.fail2banCheckedAt) < fail2banInstalledCacheTTL {
+		return s.fail2banInstalled
+	}
+
+	err := exec.Command("fail2ban-client", "-h").Run()
+	s.fail2banInstalled = err == nil
+	s.fail2banCheckedAt = time.Now()
+	return s.fail2banInstalled
+}
+
 // RefreshStatus collects a new system snapshot, stores it as LastStatus, and
 // appends it to the system-metrics time series. Returns the new snapshot (may
 // be nil if collection failed). Called by the background ticker; the caller is
@@ -1229,6 +1280,61 @@ func (s *ServerService) GetDb() ([]byte, error) {
 	return fileContents, nil
 }
 
+// BackupFilename returns the filename for a database backup, named after the
+// panel's address — the configured web domain, or the server's public IP when
+// no domain is set — so a downloaded or Telegram-sent backup identifies the
+// server it came from. The extension is .dump on PostgreSQL and .db on SQLite;
+// the base falls back to "x-ui" when no address is known.
+func (s *ServerService) BackupFilename() string {
+	ext := ".db"
+	if database.IsPostgres() {
+		ext = ".dump"
+	}
+	return s.backupHost() + ext
+}
+
+// backupHost picks the address used to name backup files, preferring the
+// configured web domain and otherwise the cached public IP (IPv4 before IPv6),
+// reduced to safe filename characters.
+func (s *ServerService) backupHost() string {
+	host := ""
+	if domain, err := s.settingService.GetWebDomain(); err == nil {
+		host = strings.TrimSpace(domain)
+	}
+	if host == "" {
+		if st := s.LastStatus(); st != nil {
+			if ip := st.PublicIP.IPv4; ip != "" && ip != "N/A" {
+				host = ip
+			} else if ip := st.PublicIP.IPv6; ip != "" && ip != "N/A" {
+				host = ip
+			}
+		}
+	}
+	return sanitizeBackupHost(host)
+}
+
+// sanitizeBackupHost reduces a host to characters safe in a download filename
+// (the getDb handler enforces ^[a-zA-Z0-9_\-.]+$). IPv6 brackets are stripped
+// and any other character — such as the colons in an IPv6 address — becomes a
+// hyphen. Returns "x-ui" when nothing usable remains.
+func sanitizeBackupHost(host string) string {
+	host = strings.Trim(host, "[]")
+	var b strings.Builder
+	for _, r := range host {
+		switch {
+		case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '.', r == '-', r == '_':
+			b.WriteRune(r)
+		default:
+			b.WriteRune('-')
+		}
+	}
+	out := strings.Trim(b.String(), ".-")
+	if out == "" {
+		return "x-ui"
+	}
+	return out
+}
+
 // GetMigration produces a cross-engine migration file plus its filename: on a
 // SQLite panel it returns a portable .dump (SQL text), and on a PostgreSQL panel
 // it returns a .db SQLite database built from the live data. Either output can

+ 32 - 10
internal/web/service/setting.go

@@ -23,6 +23,8 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/reflect_util"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
 )
 
 //go:embed config.json
@@ -1049,17 +1051,37 @@ func (s *SettingService) UpdateAllSetting(allSetting *entity.AllSetting) error {
 	v := reflect.ValueOf(allSetting).Elem()
 	t := reflect.TypeFor[entity.AllSetting]()
 	fields := reflect_util.GetFields(t)
-	errs := make([]error, 0)
-	for _, field := range fields {
-		key := field.Tag.Get("json")
-		fieldV := v.FieldByName(field.Name)
-		value := fmt.Sprint(fieldV.Interface())
-		err := s.saveSetting(key, value)
-		if err != nil {
-			errs = append(errs, err)
+
+	db := database.GetDB()
+	return db.Transaction(func(tx *gorm.DB) error {
+		var existing []*model.Setting
+		if err := tx.Find(&existing).Error; err != nil {
+			return err
 		}
-	}
-	return common.Combine(errs...)
+		byKey := make(map[string]*model.Setting, len(existing))
+		for _, st := range existing {
+			byKey[st.Key] = st
+		}
+		for _, field := range fields {
+			key := field.Tag.Get("json")
+			fieldV := v.FieldByName(field.Name)
+			value := fmt.Sprint(fieldV.Interface())
+			if st, ok := byKey[key]; ok {
+				if st.Value == value {
+					continue
+				}
+				st.Value = value
+				if err := tx.Save(st).Error; err != nil {
+					return err
+				}
+				continue
+			}
+			if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
 }
 
 func (s *SettingService) preserveRedactedSecrets(allSetting *entity.AllSetting) error {

+ 1 - 5
internal/web/service/tgbot/tgbot_report.go

@@ -10,7 +10,6 @@ import (
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -403,10 +402,7 @@ func (t *Tgbot) sendBackup(chatId int64) {
 	// Send database backup (SQLite file, or a pg_dump archive on PostgreSQL)
 	dbData, err := t.serverService.GetDb()
 	if err == nil {
-		dbFilename := "x-ui.db"
-		if database.IsPostgres() {
-			dbFilename = "x-ui.dump"
-		}
+		dbFilename := t.serverService.BackupFilename()
 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
 		document := tu.Document(
 			tu.ID(chatId),

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

@@ -111,6 +111,8 @@
     "nodes": "النودز",
     "settings": "إعدادات اللوحة",
     "xray": "إعدادات Xray",
+    "routing": "التوجيه",
+    "outbounds": "الصادرات",
     "apiDocs": "توثيق API",
     "logout": "تسجيل خروج",
     "link": "إدارة",
@@ -745,6 +747,9 @@
       "addClients": "إضافة عملاء",
       "limitIp": "حد عناوين IP",
       "limitIpDesc": "الحد الأقصى لعناوين IP المتزامنة. 0 = غير محدود.",
+      "limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.",
+      "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
+      "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",
       "password": "كلمة المرور",
       "subId": "معرّف الاشتراك",
       "online": "متصل",

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

@@ -112,6 +112,8 @@
     "hosts": "Hosts",
     "settings": "Panel Settings",
     "xray": "Xray Configs",
+    "routing": "Routing",
+    "outbounds": "Outbounds",
     "apiDocs": "API Docs",
     "logout": "Log Out",
     "link": "Manage",
@@ -745,6 +747,9 @@
       "addClients": "Add Clients",
       "limitIp": "IP Limit",
       "limitIpDesc": "Maximum simultaneous IPs. 0 = unlimited.",
+      "limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.",
+      "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
+      "limitIpDisabled": "The IP limit feature is disabled on this server.",
       "password": "Password",
       "subId": "Subscription ID",
       "online": "Online",

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

@@ -111,6 +111,8 @@
     "nodes": "Nodos",
     "settings": "Ajustes del panel",
     "xray": "Configuración Xray",
+    "routing": "Enrutamiento",
+    "outbounds": "Salidas",
     "apiDocs": "Documentación de la API",
     "logout": "Cerrar Sesión",
     "link": "Gestionar",
@@ -745,6 +747,9 @@
       "addClients": "Añadir clientes",
       "limitIp": "Límite de IP",
       "limitIpDesc": "Máximo de IP simultáneas. 0 = ilimitado.",
+      "limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.",
+      "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
+      "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",
       "password": "Contraseña",
       "subId": "ID de suscripción",
       "online": "En línea",

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

@@ -111,6 +111,8 @@
     "nodes": "نودها",
     "settings": "تنظیمات پنل",
     "xray": "پیکربندی Xray",
+    "routing": "مسیریابی",
+    "outbounds": "خروجی‌ها",
     "apiDocs": "مستندات API",
     "logout": "خروج",
     "link": "مدیریت",
@@ -745,6 +747,9 @@
       "addClients": "افزودن کلاینت‌ها",
       "limitIp": "محدودیت IP",
       "limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
+      "limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
+      "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
+      "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
       "password": "رمز عبور",
       "subId": "شناسه اشتراک",
       "online": "آنلاین",

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

@@ -111,6 +111,8 @@
     "nodes": "Node",
     "settings": "Pengaturan Panel",
     "xray": "Konfigurasi Xray",
+    "routing": "Pengalihan",
+    "outbounds": "Outbound",
     "apiDocs": "Dokumentasi API",
     "logout": "Keluar",
     "link": "Kelola",
@@ -745,6 +747,9 @@
       "addClients": "Tambah klien",
       "limitIp": "Batas IP",
       "limitIpDesc": "Jumlah maksimum IP bersamaan. 0 = tidak terbatas.",
+      "limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.",
+      "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
+      "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",
       "password": "Kata sandi",
       "subId": "ID Langganan",
       "online": "Online",

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

@@ -111,6 +111,8 @@
     "nodes": "ノード",
     "settings": "パネル設定",
     "xray": "Xray 設定",
+    "routing": "ルーティング",
+    "outbounds": "アウトバウンド",
     "apiDocs": "API ドキュメント",
     "logout": "ログアウト",
     "link": "リンク管理",
@@ -745,6 +747,9 @@
       "addClients": "クライアントを追加",
       "limitIp": "IP 制限",
       "limitIpDesc": "同時接続 IP の最大数。0 = 無制限。",
+      "limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。",
+      "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
+      "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",
       "password": "パスワード",
       "subId": "サブスクリプション ID",
       "online": "オンライン",

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

@@ -111,6 +111,8 @@
     "nodes": "Nós",
     "settings": "Configurações do Painel",
     "xray": "Configurações Xray",
+    "routing": "Roteamento",
+    "outbounds": "Saídas",
     "apiDocs": "Documentação da API",
     "logout": "Sair",
     "link": "Gerenciar",
@@ -745,6 +747,9 @@
       "addClients": "Adicionar clientes",
       "limitIp": "Limite de IP",
       "limitIpDesc": "Máximo de IPs simultâneos. 0 = ilimitado.",
+      "limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.",
+      "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
+      "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",
       "password": "Senha",
       "subId": "ID da assinatura",
       "online": "Online",

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

@@ -111,6 +111,8 @@
     "nodes": "Узлы",
     "settings": "Настройки панели",
     "xray": "Конфигурации Xray",
+    "routing": "Маршрутизация",
+    "outbounds": "Исходящие",
     "apiDocs": "Документация API",
     "logout": "Выход",
     "link": "Управление",
@@ -745,6 +747,9 @@
       "addClients": "Добавить клиентов",
       "limitIp": "Лимит IP",
       "limitIpDesc": "Максимум одновременных IP-адресов. 0 = без ограничений.",
+      "limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.",
+      "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
+      "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
       "password": "Пароль",
       "subId": "ID подписки",
       "online": "В сети",

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

@@ -111,6 +111,8 @@
     "nodes": "Düğümler",
     "settings": "Panel Ayarları",
     "xray": "Xray Yapılandırmaları",
+    "routing": "Yönlendirme",
+    "outbounds": "Giden Bağlantılar",
     "apiDocs": "API Belgeleri",
     "logout": "Çıkış Yap",
     "link": "Yönet",
@@ -745,6 +747,9 @@
       "addClients": "Kullanıcı Ekle",
       "limitIp": "IP Limiti",
       "limitIpDesc": "Eş zamanlı en fazla IP sayısı. 0 = sınırsız.",
+      "limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.",
+      "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
+      "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",
       "password": "Şifre",
       "subId": "Abonelik ID'si",
       "online": "Çevrimiçi",

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

@@ -111,6 +111,8 @@
     "nodes": "Вузли",
     "settings": "Налаштування панелі",
     "xray": "Конфігурації Xray",
+    "routing": "Маршрутизація",
+    "outbounds": "Вихідні",
     "apiDocs": "Документація API",
     "logout": "Вийти",
     "link": "Керувати",
@@ -745,6 +747,9 @@
       "addClients": "Додати клієнтів",
       "limitIp": "Ліміт IP",
       "limitIpDesc": "Максимум одночасних IP-адрес. 0 = без обмежень.",
+      "limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.",
+      "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
+      "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",
       "password": "Пароль",
       "subId": "ID підписки",
       "online": "У мережі",

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

@@ -111,6 +111,8 @@
     "nodes": "Nút",
     "settings": "Cài đặt bảng điều khiển",
     "xray": "Cấu hình Xray",
+    "routing": "Định tuyến",
+    "outbounds": "Outbound",
     "apiDocs": "Tài liệu API",
     "logout": "Đăng xuất",
     "link": "Quản lý",
@@ -745,6 +747,9 @@
       "addClients": "Thêm khách hàng",
       "limitIp": "Giới hạn IP",
       "limitIpDesc": "Số IP đồng thời tối đa. 0 = không giới hạn.",
+      "limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.",
+      "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
+      "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",
       "password": "Mật khẩu",
       "subId": "ID đăng ký",
       "online": "Trực tuyến",

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

@@ -111,6 +111,8 @@
     "nodes": "节点",
     "settings": "面板设置",
     "xray": "Xray 配置",
+    "routing": "路由",
+    "outbounds": "出站",
     "apiDocs": "API 文档",
     "logout": "退出登录",
     "link": "管理",
@@ -745,6 +747,9 @@
       "addClients": "添加客户端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同时连接 IP 数。0 = 不限制。",
+      "limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。",
+      "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
+      "limitIpDisabled": "此服务器已禁用 IP 限制功能。",
       "password": "密码",
       "subId": "订阅 ID",
       "online": "在线",

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

@@ -111,6 +111,8 @@
     "nodes": "節點",
     "settings": "面板設定",
     "xray": "Xray 設定",
+    "routing": "路由",
+    "outbounds": "出站",
     "apiDocs": "API 文件",
     "logout": "退出登入",
     "link": "管理",
@@ -745,6 +747,9 @@
       "addClients": "新增客戶端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同時連線 IP 數。0 = 不限制。",
+      "limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。",
+      "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
+      "limitIpDisabled": "此伺服器已停用 IP 限制功能。",
       "password": "密碼",
       "subId": "訂閱 ID",
       "online": "上線",

+ 39 - 22
update.sh

@@ -854,6 +854,33 @@ config_after_update() {
     fi
 }
 
+# setup_fail2ban auto-installs and configures fail2ban for the IP Limit feature
+# by invoking the freshly downloaded x-ui CLI. IP Limit is load-bearing on
+# fail2ban (without it the panel disables the limitIp field and zeroes existing
+# limits), so updating an older install should make it work without a manual
+# trip through the IP Limit menu. Non-fatal: a fail2ban failure must never abort
+# the update. XUI_ENABLE_FAIL2BAN is honored (load_xui_env exports it from the
+# persisted env file, so a deliberate opt-out survives updates).
+setup_fail2ban() {
+    if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
+        echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
+    if [[ ! -x /usr/bin/x-ui ]]; then
+        echo -e "${yellow}x-ui CLI not found; skipping Fail2ban auto-setup.${plain}"
+        return 0
+    fi
+
+    echo -e "${green}Setting up Fail2ban for the IP Limit feature...${plain}"
+    if /usr/bin/x-ui setup-fail2ban; then
+        echo -e "${green}Fail2ban setup complete.${plain}"
+    else
+        echo -e "${yellow}Fail2ban setup did not finish; IP Limit stays disabled until you run 'x-ui' and open the IP Limit menu. Continuing.${plain}"
+    fi
+    return 0
+}
+
 update_x-ui() {
     cd ${xui_folder%/x-ui}/
 
@@ -870,20 +897,12 @@ update_x-ui() {
 
     tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
     if [[ ! -n "$tag_version" ]]; then
-        echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
-        tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
-        if [[ ! -n "$tag_version" ]]; then
-            _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
-        fi
+        _fail "ERROR: Failed to fetch x-ui version, it may be due to GitHub API restrictions, please try it later"
     fi
     echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
     ${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
     if [[ $? -ne 0 ]]; then
-        echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
-        ${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
-        if [[ $? -ne 0 ]]; then
-            _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
-        fi
+        _fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
     fi
 
     if [[ -e ${xui_folder}/ ]]; then
@@ -950,11 +969,7 @@ update_x-ui() {
     echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
     ${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
     if [[ $? -ne 0 ]]; then
-        echo -e "${yellow}Trying to fetch x-ui with IPv4...${plain}"
-        ${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
-        if [[ $? -ne 0 ]]; then
-            _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
-        fi
+        _fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
     fi
 
     chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
@@ -973,10 +988,7 @@ update_x-ui() {
         echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
         ${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
         if [[ $? -ne 0 ]]; then
-            ${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
-            if [[ $? -ne 0 ]]; then
-                _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
-            fi
+            _fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
         fi
         chmod +x /etc/init.d/x-ui > /dev/null 2>&1
         chown root:root /etc/init.d/x-ui > /dev/null 2>&1
@@ -1027,13 +1039,13 @@ update_x-ui() {
                 echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
                 case "${release}" in
                     ubuntu | debian | armbian)
-                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
+                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
                         ;;
                     arch | manjaro | parch)
-                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
+                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
                         ;;
                     *)
-                        ${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
+                        ${curl_bin} -fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
                         ;;
                 esac
 
@@ -1052,6 +1064,11 @@ update_x-ui() {
 
     config_after_update
 
+    # IP Limit relies on fail2ban; install + configure it now so the feature
+    # works out of the box on update too (no-op when XUI_ENABLE_FAIL2BAN=false).
+    # Never fatal.
+    setup_fail2ban
+
     echo -e "${green}x-ui ${tag_version}${plain} updating finished, it is running now..."
     echo -e ""
     echo -e "┌───────────────────────────────────────────────────────┐

+ 22 - 3
x-ui.sh

@@ -2166,7 +2166,15 @@ iplimit_main() {
     esac
 }
 
-install_iplimit() {
+setup_fail2ban_iplimit() {
+    # Honor the same toggle the panel uses (isFail2BanEnabled): enabled when the
+    # var is unset or exactly "true"; any other explicit value means the operator
+    # opted out, so do nothing rather than install a fail2ban the panel ignores.
+    if [[ -n "${XUI_ENABLE_FAIL2BAN+x}" && "${XUI_ENABLE_FAIL2BAN}" != "true" ]]; then
+        echo -e "${yellow}XUI_ENABLE_FAIL2BAN=${XUI_ENABLE_FAIL2BAN}, skipping Fail2ban setup.${plain}\n"
+        return 0
+    fi
+
     if ! command -v fail2ban-client &> /dev/null; then
         echo -e "${green}Fail2ban is not installed. Installing now...!${plain}\n"
 
@@ -2216,13 +2224,13 @@ install_iplimit() {
                 ;;
             *)
                 echo -e "${red}Unsupported operating system. Please check the script and install the necessary packages manually.${plain}\n"
-                exit 1
+                return 1
                 ;;
         esac
 
         if ! command -v fail2ban-client &> /dev/null; then
             echo -e "${red}Fail2ban installation failed.${plain}\n"
-            exit 1
+            return 1
         fi
 
         echo -e "${green}Fail2ban installed successfully!${plain}\n"
@@ -2267,6 +2275,14 @@ install_iplimit() {
     fi
 
     echo -e "${green}IP Limit installed and configured successfully!${plain}\n"
+    return 0
+}
+
+# install_iplimit is the interactive (menu) entry point: it runs the shared
+# setup and then returns to the menu. The non-interactive installer path uses
+# setup_fail2ban_iplimit directly via `x-ui setup-fail2ban`.
+install_iplimit() {
+    setup_fail2ban_iplimit
     before_show_menu
 }
 
@@ -3263,6 +3279,9 @@ if [[ $# > 0 ]]; then
         "banlog")
             check_install 0 && show_banlog 0
             ;;
+        "setup-fail2ban")
+            setup_fail2ban_iplimit
+            ;;
         "update")
             check_install 0 && update 0
             ;;

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików