1
0

12 Коммиты 573c43e445 ... 5c1d64b841

Автор SHA1 Сообщение Дата
  MHSanaei 5c1d64b841 v3.2.7 5 часов назад
  MHSanaei 4813a2fe00 fix(api-token): hash tokens at rest and show plaintext only once 5 часов назад
  MHSanaei 7a72aeda7a i18n: translate connection-limit strings for all languages 6 часов назад
  MHSanaei 72944daab7 chore(deps): bump xray-core to v1.260327.1 and add pion/wireguard deps 6 часов назад
  MHSanaei c78285402e fix(sidebar): set fixed sider width to 220 6 часов назад
  MHSanaei ceef413dc4 feat(xray): add connIdle and bufferSize policy controls 6 часов назад
  MHSanaei 1a64d7e9de feat(tls): add ocspStapling to certificate config 11 часов назад
  MHSanaei 55d6729955 fix(nodes): Set Cert from Panel uses the node's own web cert for node inbounds 12 часов назад
  MHSanaei 42d7f62d8b Revert "feat(sidebar): collapse to icon rail, expand on hover" 12 часов назад
  MHSanaei ef8882a5c0 fix(online): scope per-inbound online to inbounds that carried traffic 12 часов назад
  MHSanaei 5fb18b8819 fix(outbounds): preserve SNI/TLS settings on transport change 12 часов назад
  MHSanaei 039d05a743 fix(ci): bump Go to 1.26.4 and exempt /panel/groups SPA route from api-docs test 13 часов назад
50 измененных файлов с 854 добавлено и 178 удалено
  1. 1 1
      config/version
  2. 29 1
      database/db.go
  3. 1 1
      database/model/model.go
  4. 127 4
      frontend/public/openapi.json
  5. 1 0
      frontend/src/api/queryKeys.ts
  6. 0 23
      frontend/src/layouts/AppSidebar.css
  7. 28 14
      frontend/src/layouts/AppSidebar.tsx
  8. 25 4
      frontend/src/pages/api-docs/endpoints.ts
  9. 1 1
      frontend/src/pages/inbounds/form/InboundFormModal.tsx
  10. 8 1
      frontend/src/pages/inbounds/form/security/tls.tsx
  11. 27 16
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  12. 46 4
      frontend/src/pages/inbounds/useInbounds.ts
  13. 5 0
      frontend/src/pages/settings/SecurityTab.css
  14. 24 34
      frontend/src/pages/settings/SecurityTab.tsx
  15. 61 1
      frontend/src/pages/xray/basics/BasicsTab.tsx
  16. 3 14
      frontend/src/pages/xray/outbounds/OutboundFormModal.tsx
  17. 28 0
      frontend/src/pages/xray/outbounds/outbound-form-helpers.ts
  18. 5 0
      frontend/src/schemas/client.ts
  19. 2 0
      frontend/src/schemas/protocols/security/tls.ts
  20. 1 0
      frontend/src/schemas/xray.ts
  21. 5 0
      frontend/src/test/__snapshots__/inbound-full.test.ts.snap
  22. 1 0
      frontend/src/test/__snapshots__/security.test.ts.snap
  23. 13 4
      go.mod
  24. 18 6
      go.sum
  25. 25 0
      util/crypto/crypto.go
  26. 2 2
      web/controller/api_docs_test.go
  27. 5 0
      web/controller/client.go
  28. 17 0
      web/controller/node.go
  29. 19 0
      web/controller/server.go
  30. 8 7
      web/job/node_traffic_sync_job.go
  31. 13 1
      web/job/xray_traffic_job.go
  32. 22 0
      web/runtime/remote.go
  33. 15 8
      web/service/api_token.go
  34. 14 6
      web/service/inbound.go
  35. 24 0
      web/service/node.go
  36. 11 1
      web/translation/ar-EG.json
  37. 11 1
      web/translation/en-US.json
  38. 11 1
      web/translation/es-ES.json
  39. 11 1
      web/translation/fa-IR.json
  40. 11 1
      web/translation/id-ID.json
  41. 11 1
      web/translation/ja-JP.json
  42. 11 1
      web/translation/pt-BR.json
  43. 11 1
      web/translation/ru-RU.json
  44. 11 1
      web/translation/tr-TR.json
  45. 11 1
      web/translation/uk-UA.json
  46. 11 1
      web/translation/vi-VN.json
  47. 11 1
      web/translation/zh-CN.json
  48. 11 1
      web/translation/zh-TW.json
  49. 31 5
      xray/online_test.go
  50. 56 7
      xray/process.go

+ 1 - 1
config/version

@@ -1 +1 @@
-3.2.6
+3.2.7

+ 29 - 1
database/db.go

@@ -181,7 +181,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash"}
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
@@ -232,6 +232,12 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 	}
 
+	if !slices.Contains(seedersHistory, "ApiTokensHash") {
+		if err := hashExistingApiTokens(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "ClientsTable") {
 		if err := seedClientsFromInboundJSON(); err != nil {
 			return err
@@ -646,6 +652,28 @@ func seedApiTokens() error {
 	return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensTable"}).Error
 }
 
+// hashExistingApiTokens replaces any plaintext token stored before tokens were
+// hashed at rest with its SHA-256 digest. Callers keep their plaintext copy
+// (used on remote nodes), so existing tokens keep authenticating; the panel
+// just can no longer reveal them. Idempotent — already-hashed rows are skipped.
+func hashExistingApiTokens() error {
+	var rows []*model.ApiToken
+	if err := db.Find(&rows).Error; err != nil {
+		return err
+	}
+	for _, r := range rows {
+		if crypto.IsSHA256Hex(r.Token) {
+			continue
+		}
+		hashed := crypto.HashTokenSHA256(r.Token)
+		if err := db.Model(model.ApiToken{}).Where("id = ?", r.Id).Update("token", hashed).Error; err != nil {
+			log.Printf("Error hashing api token %d: %v", r.Id, err)
+			return err
+		}
+	}
+	return db.Create(&model.HistoryOfSeeders{SeederName: "ApiTokensHash"}).Error
+}
+
 // isTableEmpty returns true if the named table contains zero rows.
 func isTableEmpty(tableName string) (bool, error) {
 	var count int64

+ 1 - 1
database/model/model.go

@@ -138,7 +138,7 @@ type HistoryOfSeeders struct {
 type ApiToken struct {
 	Id        int    `json:"id" gorm:"primaryKey;autoIncrement"`
 	Name      string `json:"name" gorm:"uniqueIndex;not null"`
-	Token     string `json:"token" gorm:"not null"`
+	Token     string `json:"token" gorm:"not null"` // SHA-256 hash; the plaintext is shown only once at creation
 	Enabled   bool   `json:"enabled" gorm:"default:true"`
 	CreatedAt int64  `json:"createdAt" gorm:"autoCreateTime:milli"`
 }

+ 127 - 4
frontend/public/openapi.json

@@ -69,7 +69,7 @@
     },
     {
       "name": "API Tokens",
-      "description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request."
+      "description": "Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request."
     },
     {
       "name": "Xray Settings",
@@ -1529,6 +1529,43 @@
         }
       }
     },
+    "/panel/api/server/getWebCertFiles": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Return this panel's own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so \"Set Cert from Panel\" fills a node-assigned inbound with paths that exist on the node.",
+        "operationId": "get_panel_api_server_getWebCertFiles",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "webCertFile": "/root/cert/example.com/fullchain.pem",
+                    "webKeyFile": "/root/cert/example.com/privkey.pem"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/getNewX25519Cert": {
       "get": {
         "tags": [
@@ -3691,6 +3728,45 @@
         }
       }
     },
+    "/panel/api/clients/activeInbounds": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key \"0\"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.",
+        "operationId": "post_panel_api_clients_activeInbounds",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "0": [
+                      "inbound-443",
+                      "inbound-8443"
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/lastOnline": {
       "post": {
         "tags": [
@@ -3977,6 +4053,54 @@
         }
       }
     },
+    "/panel/api/nodes/webCert/{id}": {
+      "get": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Fetch a node's own web TLS certificate/key file paths (proxied to the node). Used by the inbound form's \"Set Cert from Panel\" so a node-assigned inbound gets paths that exist on the node, not the central panel.",
+        "operationId": "get_panel_api_nodes_webCert_id",
+        "parameters": [
+          {
+            "name": "id",
+            "in": "path",
+            "required": true,
+            "description": "Node ID.",
+            "schema": {
+              "type": "integer"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "webCertFile": "/root/cert/example.com/fullchain.pem",
+                    "webKeyFile": "/root/cert/example.com/privkey.pem"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/add": {
       "post": {
         "tags": [
@@ -4981,7 +5105,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "List every API token, enabled or not.",
+        "summary": "List every API token, enabled or not. The token value is never returned — only metadata.",
         "operationId": "get_panel_setting_apiTokens",
         "responses": {
           "200": {
@@ -5006,7 +5130,6 @@
                     {
                       "id": 1,
                       "name": "default",
-                      "token": "abcdef-12345-...",
                       "enabled": true,
                       "createdAt": 1736000000
                     }
@@ -5023,7 +5146,7 @@
         "tags": [
           "API Tokens"
         ],
-        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.",
+        "summary": "Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.",
         "operationId": "post_panel_setting_apiTokens_create",
         "requestBody": {
           "required": true,

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

@@ -22,6 +22,7 @@ export const keys = {
     all: () => ['clients', 'all'] as const,
     onlines: () => ['clients', 'onlines'] as const,
     onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
+    activeInbounds: () => ['clients', 'activeInbounds'] as const,
     lastOnline: () => ['clients', 'lastOnline'] as const,
     groups: () => ['clients', 'groups'] as const,
   },

+ 0 - 23
frontend/src/layouts/AppSidebar.css

@@ -5,20 +5,6 @@
   align-self: flex-start;
 }
 
-.ant-sidebar.is-rail {
-  flex: 0 0 80px;
-  width: 80px;
-  overflow: visible;
-}
-
-.ant-sidebar.is-rail > .ant-layout-sider {
-  z-index: 100;
-}
-
-.ant-sidebar.is-rail:hover > .ant-layout-sider {
-  box-shadow: 2px 0 16px rgba(0, 0, 0, 0.18);
-}
-
 .sider-brand,
 .drawer-brand {
   font-weight: 600;
@@ -259,15 +245,6 @@
     min-width: 0 !important;
     width: 0 !important;
   }
-
-  .ant-sidebar,
-  .ant-sidebar.is-rail {
-    flex: 0 0 0 !important;
-    width: 0 !important;
-    min-width: 0 !important;
-    max-width: 0 !important;
-    overflow: hidden !important;
-  }
 }
 
 body.dark .ant-drawer-content,

+ 28 - 14
frontend/src/layouts/AppSidebar.tsx

@@ -35,6 +35,7 @@ import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
 import { useAllSettings } from '@/api/queries/useAllSettings';
 import './AppSidebar.css';
 
+const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
 const DONATE_URL = 'https://donate.sanaei.dev/';
 const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
 const LOGOUT_KEY = '__logout__';
@@ -53,6 +54,14 @@ const iconByName: Record<IconName, ComponentType> = {
   apidocs: ApiOutlined,
 };
 
+function readCollapsed(): boolean {
+  try {
+    return JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false');
+  } catch {
+    return false;
+  }
+}
+
 function DonateButton({ ariaLabel }: { ariaLabel: string }) {
   return (
     <a
@@ -116,9 +125,8 @@ export default function AppSidebar() {
   const { allSetting } = useAllSettings();
   const showSubFormats = !!(allSetting.subJsonEnable || allSetting.subClashEnable);
 
-  const [hovered, setHovered] = useState(false);
+  const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
   const [drawerOpen, setDrawerOpen] = useState(false);
-  const collapsedView = !hovered;
 
   const currentTheme: 'light' | 'dark' = isDark ? 'dark' : 'light';
   const panelVersion = window.X_UI_CUR_VER || '';
@@ -202,6 +210,13 @@ export default function AppSidebar() {
     openLink(String(key));
   }, [openLink]);
 
+  const onSiderCollapse = useCallback((isCollapsed: boolean, type: 'clickTrigger' | 'responsive') => {
+    if (type === 'clickTrigger') {
+      localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(isCollapsed));
+      setCollapsed(isCollapsed);
+    }
+  }, []);
+
   const cycleTheme = useCallback((id: string) => {
     pauseAnimationsUntilLeave(id);
     if (!isDark) {
@@ -216,21 +231,20 @@ export default function AppSidebar() {
   }, [isDark, isUltra, toggleTheme, toggleUltra]);
 
   return (
-    <div
-      className="ant-sidebar is-rail"
-      onMouseEnter={() => setHovered(true)}
-      onMouseLeave={() => setHovered(false)}
-    >
+    <div className="ant-sidebar">
       <Layout.Sider
         theme={currentTheme}
-        collapsed={collapsedView}
-        trigger={null}
+        width={220}
+        collapsible
+        collapsed={collapsed}
+        breakpoint="md"
+        onCollapse={onSiderCollapse}
       >
-        <div className={`sider-brand${collapsedView ? ' sider-brand-collapsed' : ''}`}>
+        <div className={`sider-brand${collapsed ? ' sider-brand-collapsed' : ''}`}>
           <div className="brand-block">
-            <span className="brand-text">{collapsedView ? '3X' : '3X-UI'}</span>
+            <span className="brand-text">{collapsed ? '3X' : '3X-UI'}</span>
           </div>
-          {!collapsedView && (
+          {!collapsed && (
             <div className="brand-actions">
               <DonateButton ariaLabel={t('menu.donate') || 'Donate'} />
               <ThemeCycleButton
@@ -247,7 +261,7 @@ export default function AppSidebar() {
           theme={currentTheme}
           mode="inline"
           selectedKeys={[selectedKey]}
-          openKeys={collapsedView ? undefined : openKeys}
+          openKeys={collapsed ? undefined : openKeys}
           onOpenChange={(keys) => setOpenKeys(keys as string[])}
           className="sider-nav"
           items={toMenuItems(navItems)}
@@ -262,7 +276,7 @@ export default function AppSidebar() {
           onClick={onMenuClick}
         />
         <div className="sider-footer">
-          <VersionBadge version={panelVersion} collapsed={collapsedView} />
+          <VersionBadge version={panelVersion} collapsed={collapsed} />
         </div>
       </Layout.Sider>
 

+ 25 - 4
frontend/src/pages/api-docs/endpoints.ts

@@ -313,6 +313,12 @@ export const sections: readonly Section[] = [
         summary: 'Generate a fresh UUID v4. Convenience helper for client IDs.',
         response: '{\n  "success": true,\n  "obj": "550e8400-e29b-41d4-a716-446655440000"\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/getWebCertFiles',
+        summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
+        response: '{\n  "success": true,\n  "obj": {\n    "webCertFile": "/root/cert/example.com/fullchain.pem",\n    "webKeyFile": "/root/cert/example.com/privkey.pem"\n  }\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/getNewX25519Cert',
@@ -675,6 +681,12 @@ export const sections: readonly Section[] = [
         summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
         response: '{\n  "success": true,\n  "obj": {\n    "0": ["user1"],\n    "3": ["user1", "user2"]\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/activeInbounds',
+        summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
+        response: '{\n  "success": true,\n  "obj": {\n    "0": ["inbound-443", "inbound-8443"]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/lastOnline',
@@ -735,6 +747,15 @@ export const sections: readonly Section[] = [
           { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
         ],
       },
+      {
+        method: 'GET',
+        path: '/panel/api/nodes/webCert/:id',
+        summary: 'Fetch a node\'s own web TLS certificate/key file paths (proxied to the node). Used by the inbound form\'s "Set Cert from Panel" so a node-assigned inbound gets paths that exist on the node, not the central panel.',
+        params: [
+          { name: 'id', in: 'path', type: 'number', desc: 'Node ID.' },
+        ],
+        response: '{\n  "success": true,\n  "obj": {\n    "webCertFile": "/root/cert/example.com/fullchain.pem",\n    "webKeyFile": "/root/cert/example.com/privkey.pem"\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/nodes/add',
@@ -930,18 +951,18 @@ export const sections: readonly Section[] = [
     id: 'api-tokens',
     title: 'API Tokens',
     description:
-      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored plaintext so the SPA can show them on demand. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
+      'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
     endpoints: [
       {
         method: 'GET',
         path: '/panel/setting/apiTokens',
-        summary: 'List every API token, enabled or not.',
-        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\n      "token": "abcdef-12345-...",\n      "enabled": true,\n      "createdAt": 1736000000\n    }\n  ]\n}',
+        summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "id": 1,\n      "name": "default",\n      "enabled": true,\n      "createdAt": 1736000000\n    }\n  ]\n}',
       },
       {
         method: 'POST',
         path: '/panel/setting/apiTokens/create',
-        summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated.',
+        summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
         params: [
           { name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
         ],

+ 1 - 1
frontend/src/pages/inbounds/form/InboundFormModal.tsx

@@ -194,7 +194,7 @@ export default function InboundFormModal({
     setCertFromPanel,
     clearCertFiles,
     onSecurityChange,
-  } = useSecurityActions({ form, setSaving, messageApi });
+  } = useSecurityActions({ form, setSaving, messageApi, nodeId: typeof wNodeId === 'number' ? wNodeId : null });
 
   const toggleExternalProxy = (on: boolean) => {
     if (on) {

+ 8 - 1
frontend/src/pages/inbounds/form/security/tls.tsx

@@ -1,5 +1,5 @@
 import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Radio, Select, Space, Switch } from 'antd';
+import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
 import { MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
 
 import {
@@ -113,6 +113,7 @@ export default function TlsForm({
                   keyFile: '',
                   certificate: [],
                   key: [],
+                  ocspStapling: 3600,
                   oneTimeLoading: false,
                   usage: 'encipherment',
                   buildChain: false,
@@ -218,6 +219,12 @@ export default function TlsForm({
                     );
                   }}
                 </Form.Item>
+                <Form.Item
+                  name={[certField.name, 'ocspStapling']}
+                  label="OCSP Stapling"
+                >
+                  <InputNumber min={0} addonAfter="s" style={{ width: '50%' }} />
+                </Form.Item>
                 <Form.Item
                   name={[certField.name, 'oneTimeLoading']}
                   label={t('pages.inbounds.form.oneTimeLoading')}

+ 27 - 16
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -13,13 +13,17 @@ interface UseSecurityActionsArgs {
   form: FormInstance<InboundFormValues>;
   setSaving: Dispatch<SetStateAction<boolean>>;
   messageApi: MessageInstance;
+  // Node the inbound is deployed to (null = central panel). "Set Cert from
+  // Panel" must read the node's own cert paths for a node-assigned inbound —
+  // the central panel's paths don't exist on the node. See issue #4854.
+  nodeId: number | null;
 }
 
 // Server-side TLS / Reality key + certificate generation handlers for the
 // inbound modal's security tab. Each talks to a /panel server endpoint and
 // writes the result back into the form. Lifted out of InboundFormModal so
 // the modal body stays focused on orchestration.
-export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityActionsArgs) {
+export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseSecurityActionsArgs) {
   const { t } = useTranslation();
 
   const genRealityKeypair = async () => {
@@ -112,22 +116,28 @@ export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityA
   const setCertFromPanel = async (certName: number) => {
     setSaving(true);
     try {
-      const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
-      if (msg?.success) {
-        const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
-        if (!obj.webCertFile && !obj.webKeyFile) {
-          messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
-          return;
-        }
-        form.setFieldValue(
-          ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
-          obj.webCertFile ?? '',
-        );
-        form.setFieldValue(
-          ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
-          obj.webKeyFile ?? '',
-        );
+      // Node-assigned inbounds run on the node, so their cert files must be the
+      // node's own paths (fetched through the central panel), not this panel's.
+      const msg = typeof nodeId === 'number'
+        ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
+        : await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
+      if (!msg?.success) {
+        messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
+        return;
+      }
+      const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
+      if (!obj?.webCertFile && !obj?.webKeyFile) {
+        messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
+        return;
       }
+      form.setFieldValue(
+        ['streamSettings', 'tlsSettings', 'certificates', certName, 'certificateFile'],
+        obj.webCertFile ?? '',
+      );
+      form.setFieldValue(
+        ['streamSettings', 'tlsSettings', 'certificates', certName, 'keyFile'],
+        obj.webKeyFile ?? '',
+      );
     } finally {
       setSaving(false);
     }
@@ -157,6 +167,7 @@ export function useSecurityActions({ form, setSaving, messageApi }: UseSecurityA
         keyFile: '',
         certificate: [],
         key: [],
+        ocspStapling: 3600,
         oneTimeLoading: false,
         usage: 'encipherment',
         buildChain: false,

+ 46 - 4
frontend/src/pages/inbounds/useInbounds.ts

@@ -9,7 +9,7 @@ import { isSSMultiUser } from '@/lib/xray/protocol-capabilities';
 import { setDatepicker } from '@/hooks/useDatepicker';
 import { keys } from '@/api/queryKeys';
 import { SlimInboundListSchema, LastOnlineMapSchema, InboundDetailSchema } from '@/schemas/inbound';
-import { OnlinesSchema, OnlineByNodeSchema } from '@/schemas/client';
+import { OnlinesSchema, OnlineByNodeSchema, ActiveInboundsByNodeSchema } from '@/schemas/client';
 import { DefaultsPayloadSchema, type DefaultsPayload } from '@/schemas/defaults';
 
 export interface SubSettings {
@@ -68,6 +68,17 @@ async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
   return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
 }
 
+// Inbound tags that carried traffic recently, grouped by node (local = key 0).
+// Pairs with the per-node online map so a client attached to several inbounds
+// is only marked online on the ones that actually moved bytes — Xray's
+// user-level stat can't attribute traffic to a single inbound on its own.
+async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
+  const msg = await HttpUtil.post('/panel/api/clients/activeInbounds', undefined, { silent: true });
+  if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch activeInbounds');
+  const validated = parseMsg(msg, ActiveInboundsByNodeSchema, 'clients/activeInbounds');
+  return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
+}
+
 function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
   const map = new Map<number, Set<string>>();
   for (const [key, emails] of Object.entries(data)) {
@@ -112,6 +123,12 @@ export function useInbounds() {
     staleTime: Infinity,
   });
 
+  const activeInboundsQuery = useQuery({
+    queryKey: keys.clients.activeInbounds(),
+    queryFn: fetchActiveInboundsByNode,
+    staleTime: Infinity,
+  });
+
   const lastOnlineQuery = useQuery({
     queryKey: keys.clients.lastOnline(),
     queryFn: fetchLastOnlineMap,
@@ -169,6 +186,13 @@ export function useInbounds() {
   // reads this so each inbound only counts clients online on its own node.
   const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
 
+  // Recently-active inbound tags keyed by node id. A node missing from this
+  // map means "no per-inbound activity reported" (e.g. remote nodes), so the
+  // rollup leaves that node's inbounds ungated and falls back to the email
+  // signal. A present node gates: a client only counts online on an inbound
+  // whose tag carried traffic this window.
+  const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
+
   const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
 
   const rollupClients = useCallback(
@@ -185,14 +209,21 @@ export function useInbounds() {
       const comments = new Map<string, string>();
       const now = Date.now();
 
-      const nodeOnline = onlineByNodeRef.current.get(dbInbound.nodeId ?? 0);
+      const nodeId = dbInbound.nodeId ?? 0;
+      const nodeOnline = onlineByNodeRef.current.get(nodeId);
+      // A node absent from the active map reports no per-inbound activity, so
+      // leave its inbounds ungated. When present, only mark a client online on
+      // this inbound if its tag actually carried traffic — that's what stops a
+      // multi-inbound client lighting up every inbound it's attached to.
+      const activeForNode = activeByNodeRef.current.get(nodeId);
+      const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
 
       if (dbInbound.enable) {
         for (const client of clients) {
           if (client.comment && client.email) comments.set(client.email, client.comment);
           if (client.enable) {
             if (client.email) active.push(client.email);
-            if (client.email && nodeOnline?.has(client.email)) online.push(client.email);
+            if (client.email && inboundActive && nodeOnline?.has(client.email)) online.push(client.email);
           } else if (client.email) {
             deactive.push(client.email);
           }
@@ -280,6 +311,13 @@ export function useInbounds() {
     }
   }, [onlinesByNodeQuery.data, rebuildClientCount]);
 
+  useEffect(() => {
+    if (activeInboundsQuery.data) {
+      activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
+      rebuildClientCount();
+    }
+  }, [activeInboundsQuery.data, rebuildClientCount]);
+
   useEffect(() => {
     if (lastOnlineQuery.data) setLastOnlineMap(lastOnlineQuery.data);
   }, [lastOnlineQuery.data]);
@@ -299,6 +337,7 @@ export function useInbounds() {
       queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
+      queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
       queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
       queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
     ]);
@@ -328,7 +367,7 @@ export function useInbounds() {
   const applyTrafficEvent = useCallback(
     (payload: unknown) => {
       if (!payload || typeof payload !== 'object') return;
-      const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
+      const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
       if (Array.isArray(p.onlineClients)) {
         onlineClientsRef.current = p.onlineClients;
         setOnlineClients(p.onlineClients);
@@ -336,6 +375,9 @@ export function useInbounds() {
       if (p.onlineByNode && typeof p.onlineByNode === 'object') {
         onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
       }
+      if (p.activeInbounds && typeof p.activeInbounds === 'object') {
+        activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
+      }
       if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
         setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
       }

+ 5 - 0
frontend/src/pages/settings/SecurityTab.css

@@ -83,6 +83,11 @@
   word-break: break-all;
 }
 
+.api-token-created-notice {
+  margin: 0 0 12px;
+  font-size: 13px;
+}
+
 .security-actions {
   padding: 12px 0;
   display: flex;

+ 24 - 34
frontend/src/pages/settings/SecurityTab.tsx

@@ -30,7 +30,6 @@ interface ApiMsg<T = unknown> {
 interface ApiTokenRow {
   id: number;
   name: string;
-  token: string;
   enabled: boolean;
   createdAt: number;
 }
@@ -77,10 +76,10 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
 
   const [apiTokens, setApiTokens] = useState<ApiTokenRow[]>([]);
   const [apiTokensLoading, setApiTokensLoading] = useState(false);
-  const [visibleTokenIds, setVisibleTokenIds] = useState<Set<number>>(() => new Set());
   const [createOpen, setCreateOpen] = useState(false);
   const [createName, setCreateName] = useState('');
   const [creating, setCreating] = useState(false);
+  const [createdToken, setCreatedToken] = useState<{ name: string; token: string } | null>(null);
 
   const openTfa = useCallback((opts: Omit<TfaState, 'open'>) => {
     setTfa({ ...opts, open: true });
@@ -137,14 +136,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     loadApiTokens();
   }, [loadApiTokens]);
 
-  function toggleTokenVisibility(id: number) {
-    setVisibleTokenIds((prev) => {
-      const next = new Set(prev);
-      if (next.has(id)) next.delete(id); else next.add(id);
-      return next;
-    });
-  }
-
   async function copyToken(token: string) {
     if (!token) return;
     const ok = await ClipboardManager.copyText(token);
@@ -165,17 +156,12 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     }
     setCreating(true);
     try {
-      const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ id?: number }>;
+      const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
       if (msg?.success) {
         setCreateOpen(false);
         await loadApiTokens();
-        if (msg.obj?.id != null) {
-          const id = msg.obj.id;
-          setVisibleTokenIds((prev) => {
-            const next = new Set(prev);
-            next.add(id);
-            return next;
-          });
+        if (msg.obj?.token) {
+          setCreatedToken({ name, token: msg.obj.token });
         }
       }
     } finally {
@@ -206,11 +192,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
     }
   }
 
-  function maskToken(token: string): string {
-    if (!token) return '';
-    return '•'.repeat(Math.min(token.length, 24));
-  }
-
   function formatTokenDate(ts: number): string {
     if (!ts) return '';
     return new Date(ts * 1000).toLocaleString();
@@ -326,17 +307,6 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
                         </Button>
                       </div>
                     </div>
-                    <div className="api-token-value-wrap">
-                      <code className="api-token-value">
-                        {visibleTokenIds.has(row.id) ? row.token : maskToken(row.token)}
-                      </code>
-                      <Button size="small" onClick={() => toggleTokenVisibility(row.id)}>
-                        {visibleTokenIds.has(row.id)
-                          ? (t('pages.settings.security.hide') || 'Hide')
-                          : (t('pages.settings.security.show') || 'Show')}
-                      </Button>
-                      <Button size="small" onClick={() => copyToken(row.token)}>{t('copy')}</Button>
-                    </div>
                   </div>
                 ))}
               </Spin>
@@ -367,6 +337,26 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
         </Form>
       </Modal>
 
+      <Modal
+        open={!!createdToken}
+        title={t('pages.settings.security.apiTokenCreatedTitle') || 'Token created'}
+        okText={t('done')}
+        onOk={() => setCreatedToken(null)}
+        onCancel={() => setCreatedToken(null)}
+        cancelButtonProps={{ style: { display: 'none' } }}
+      >
+        <p className="api-token-created-notice">
+          {t('pages.settings.security.apiTokenCreatedNotice')
+            || 'Copy this token now. For security it is not stored in readable form and will not be shown again.'}
+        </p>
+        <div className="api-token-value-wrap">
+          <code className="api-token-value">{createdToken?.token}</code>
+          <Button size="small" type="primary" onClick={() => createdToken && copyToken(createdToken.token)}>
+            {t('copy')}
+          </Button>
+        </div>
+      </Modal>
+
       <TwoFactorModal
         open={tfa.open}
         title={tfa.title}

+ 61 - 1
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -1,8 +1,9 @@
 import { useCallback } from 'react';
 import { useTranslation } from 'react-i18next';
-import { Alert, Button, Input, Modal, Select, Space, Switch, Tabs } from 'antd';
+import { Alert, Button, Input, InputNumber, Modal, Select, Space, Switch, Tabs } from 'antd';
 import {
   BarChartOutlined,
+  ClockCircleOutlined,
   FileTextOutlined,
   ReloadOutlined,
   SettingOutlined,
@@ -54,6 +55,20 @@ export default function BasicsTab({
     [setTemplateSettings],
   );
 
+  const setLevel0 = useCallback(
+    (field: string, value: number | null) => mutate((tt) => {
+      if (!tt.policy) tt.policy = {};
+      if (!tt.policy.levels) tt.policy.levels = {};
+      if (!tt.policy.levels['0']) tt.policy.levels['0'] = {};
+      if (value === null || value === undefined) {
+        delete tt.policy.levels['0'][field];
+      } else {
+        tt.policy.levels['0'][field] = value;
+      }
+    }),
+    [mutate],
+  );
+
   function confirmResetDefault() {
     modal.confirm({
       title: t('pages.settings.resetDefaultConfig'),
@@ -72,6 +87,7 @@ export default function BasicsTab({
   const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
   const log = (templateSettings?.log || {}) as Record<string, unknown>;
   const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
+  const level0 = (templateSettings?.policy?.levels?.['0'] || {}) as Record<string, unknown>;
 
   const items = [
     {
@@ -168,6 +184,50 @@ export default function BasicsTab({
         </>
       ),
     },
+    {
+      key: 'connection',
+      label: catTabLabel(<ClockCircleOutlined />, t('pages.xray.connectionLimits'), isMobile),
+      children: (
+        <>
+          <Alert
+            type="warning"
+            showIcon
+            className="mb-12 hint-alert"
+            title={t('pages.xray.connectionLimitsDesc')}
+          />
+          <SettingListItem
+            title={t('pages.xray.connIdle')}
+            description={t('pages.xray.connIdleDesc')}
+            paddings="small"
+            control={
+              <InputNumber
+                value={typeof level0.connIdle === 'number' ? level0.connIdle : undefined}
+                min={0}
+                style={{ width: '100%' }}
+                placeholder="300"
+                addonAfter={t('pages.xray.seconds')}
+                onChange={(v) => setLevel0('connIdle', v as number | null)}
+              />
+            }
+          />
+          <SettingListItem
+            title={t('pages.xray.bufferSize')}
+            description={t('pages.xray.bufferSizeDesc')}
+            paddings="small"
+            control={
+              <InputNumber
+                value={typeof level0.bufferSize === 'number' ? level0.bufferSize : undefined}
+                min={0}
+                style={{ width: '100%' }}
+                placeholder={t('pages.xray.bufferSizePlaceholder')}
+                addonAfter="KB"
+                onChange={(v) => setLevel0('bufferSize', v as number | null)}
+              />
+            }
+          />
+        </>
+      ),
+    },
     {
       key: '3',
       label: catTabLabel(<FileTextOutlined />, t('pages.xray.logConfigs'), isMobile),

+ 3 - 14
frontend/src/pages/xray/outbounds/OutboundFormModal.tsx

@@ -42,6 +42,7 @@ import {
   SERVER_PROTOCOLS,
 } from './outbound-form-constants';
 import {
+  applyNetworkChange,
   buildAddModeValues,
   hysteriaStreamSlice,
   newStreamSlice,
@@ -231,20 +232,8 @@ export default function OutboundFormModal({
   // wsSettings, etc.) so the DU branch matches. Preserve security if
   // the new network supports it, otherwise force back to 'none'.
   function onNetworkChange(next: string) {
-    if (next === 'hysteria') {
-      form.setFieldValue('streamSettings', hysteriaStreamSlice());
-      return;
-    }
-    const currentSecurity = form.getFieldValue(['streamSettings', 'security']) ?? 'none';
-    const stillAllowed = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
-    const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
-    const newSecurity =
-      currentSecurity === 'tls' && !stillAllowed
-        ? 'none'
-        : currentSecurity === 'reality' && !stillReality
-          ? 'none'
-          : currentSecurity;
-    form.setFieldValue('streamSettings', { ...newStreamSlice(next), security: newSecurity });
+    const stream = (form.getFieldValue('streamSettings') ?? {}) as Record<string, unknown>;
+    form.setFieldValue('streamSettings', applyNetworkChange(protocol, stream, next));
   }
 
   function onXmuxToggle(checked: boolean) {

+ 28 - 0
frontend/src/pages/xray/outbounds/outbound-form-helpers.ts

@@ -1,4 +1,5 @@
 import { rawOutboundToFormValues } from '@/lib/xray/outbound-form-adapter';
+import { canEnableReality, canEnableTls } from '@/lib/xray/protocol-capabilities';
 import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
 
 import { MUX_PROTOCOLS } from './outbound-form-constants';
@@ -74,6 +75,33 @@ export function hysteriaStreamSlice(): Record<string, unknown> {
   };
 }
 
+// Network change cascade: swap the per-network sub-key (tcpSettings,
+// wsSettings, etc.) so the DU branch matches. Carry over the security mode
+// and its settings (tlsSettings/realitySettings, including SNI serverName)
+// when the new network still supports it; otherwise fall back to 'none'.
+// Dropping tlsSettings here silently wiped the spoofed SNI on save (#4791).
+export function applyNetworkChange(
+  protocol: string,
+  prevStream: Record<string, unknown> | undefined,
+  next: string,
+): Record<string, unknown> {
+  if (next === 'hysteria') return hysteriaStreamSlice();
+  const stream = prevStream ?? {};
+  const currentSecurity = (stream.security as string) ?? 'none';
+  const stillTls = canEnableTls({ protocol, streamSettings: { network: next, security: currentSecurity } });
+  const stillReality = canEnableReality({ protocol, streamSettings: { network: next, security: currentSecurity } });
+  const newSecurity =
+    currentSecurity === 'tls' && !stillTls
+      ? 'none'
+      : currentSecurity === 'reality' && !stillReality
+        ? 'none'
+        : currentSecurity;
+  const newStream: Record<string, unknown> = { ...newStreamSlice(next), security: newSecurity };
+  if (newSecurity === 'tls' && stream.tlsSettings) newStream.tlsSettings = stream.tlsSettings;
+  else if (newSecurity === 'reality' && stream.realitySettings) newStream.realitySettings = stream.realitySettings;
+  return newStream;
+}
+
 export function buildAddModeValues(): OutboundFormValues {
   return rawOutboundToFormValues({});
 }

+ 5 - 0
frontend/src/schemas/client.ts

@@ -117,6 +117,11 @@ export const OnlineByNodeSchema = z
   .nullable()
   .transform((v) => v ?? {});
 
+export const ActiveInboundsByNodeSchema = z
+  .record(z.string(), nullableStringArray)
+  .nullable()
+  .transform((v) => v ?? {});
+
 export const GroupSummarySchema = z.object({
   name: z.string(),
   clientCount: z.number(),

+ 2 - 0
frontend/src/schemas/protocols/security/tls.ts

@@ -34,6 +34,7 @@ export type TlsCertUsage = z.infer<typeof TlsCertUsageSchema>;
 export const TlsCertFileSchema = z.object({
   certificateFile: z.string().min(1),
   keyFile: z.string().min(1),
+  ocspStapling: z.number().default(3600),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),
@@ -41,6 +42,7 @@ export const TlsCertFileSchema = z.object({
 export const TlsCertInlineSchema = z.object({
   certificate: z.array(z.string()),
   key: z.array(z.string()),
+  ocspStapling: z.number().default(3600),
   oneTimeLoading: z.boolean().default(false),
   usage: TlsCertUsageSchema.default('encipherment'),
   buildChain: z.boolean().default(false),

+ 1 - 0
frontend/src/schemas/xray.ts

@@ -28,6 +28,7 @@ export const XraySettingsValueSchema = z.object({
   log: z.record(z.string(), z.unknown()).optional(),
   policy: z.object({
     system: z.record(z.string(), z.boolean()).optional(),
+    levels: z.record(z.string(), z.record(z.string(), z.unknown())).optional(),
   }).loose().optional(),
   observatory: z.unknown().optional(),
   burstObservatory: z.unknown().optional(),

+ 5 - 0
frontend/src/test/__snapshots__/inbound-full.test.ts.snap

@@ -55,6 +55,7 @@ exports[`InboundSchema (full) fixtures > parses hysteria-v1-tls byte-stably 1`]
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/hysteria.crt",
           "keyFile": "/etc/ssl/private/hysteria.key",
+          "ocspStapling": 3600,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -193,6 +194,7 @@ exports[`InboundSchema (full) fixtures > parses trojan-ws-tls byte-stably 1`] =
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/trojan.crt",
           "keyFile": "/etc/ssl/private/trojan.key",
+          "ocspStapling": 3600,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -365,6 +367,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls byte-stably 1`] = `
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "ocspStapling": 3600,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -453,6 +456,7 @@ exports[`InboundSchema (full) fixtures > parses vless-ws-tls-pinned byte-stably
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
           "keyFile": "/etc/ssl/private/cdn.example.test.key",
+          "ocspStapling": 3600,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },
@@ -547,6 +551,7 @@ exports[`InboundSchema (full) fixtures > parses vmess-tcp-tls byte-stably 1`] =
           "buildChain": false,
           "certificateFile": "/etc/ssl/certs/vmess.crt",
           "keyFile": "/etc/ssl/private/vmess.key",
+          "ocspStapling": 3600,
           "oneTimeLoading": false,
           "usage": "encipherment",
         },

+ 1 - 0
frontend/src/test/__snapshots__/security.test.ts.snap

@@ -51,6 +51,7 @@ exports[`SecuritySettingsSchema fixtures > parses tls-cert-file byte-stably 1`]
         "buildChain": false,
         "certificateFile": "/etc/ssl/certs/cdn.example.test.crt",
         "keyFile": "/etc/ssl/private/cdn.example.test.key",
+        "ocspStapling": 3600,
         "oneTimeLoading": false,
         "usage": "encipherment",
       },

+ 13 - 4
go.mod

@@ -1,6 +1,6 @@
 module github.com/mhsanaei/3x-ui/v3
 
-go 1.26.3
+go 1.26.4
 
 require (
 	github.com/gin-contrib/gzip v1.2.6
@@ -21,7 +21,7 @@ require (
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
 	github.com/valyala/fasthttp v1.71.0
 	github.com/xlzd/gotp v0.1.0
-	github.com/xtls/xray-core v1.260327.0
+	github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
 	go.uber.org/atomic v1.11.0
 	golang.org/x/crypto v0.52.0
 	golang.org/x/sys v0.45.0
@@ -33,10 +33,19 @@ require (
 	gorm.io/gorm v1.31.1
 )
 
+require (
+	github.com/pion/dtls/v3 v3.1.2 // indirect
+	github.com/pion/logging v0.2.4 // indirect
+	github.com/pion/stun/v3 v3.1.2 // indirect
+	github.com/pion/transport/v4 v4.0.1 // indirect
+	github.com/wlynxg/anet v0.0.5 // indirect
+	golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
+)
+
 require (
 	github.com/Azure/go-ntlmssp v0.1.1 // indirect
 	github.com/andybalholm/brotli v1.2.1 // indirect
-	github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
+	github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
 	github.com/bytedance/gopkg v0.1.4 // indirect
 	github.com/bytedance/sonic v1.15.1 // indirect
 	github.com/bytedance/sonic/loader v0.5.1 // indirect
@@ -56,7 +65,7 @@ require (
 	github.com/grbit/go-json v0.11.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
-	github.com/jackc/pgx/v5 v5.9.2 // indirect
+	github.com/jackc/pgx/v5 v5.10.0 // indirect
 	github.com/jackc/puddle/v2 v2.2.2 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
 	github.com/jinzhu/now v1.1.5 // indirect

+ 18 - 6
go.sum

@@ -6,8 +6,8 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
 github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
 github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
-github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
-github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
+github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I=
+github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
 github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
 github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
 github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
@@ -89,8 +89,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
 github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
 github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
-github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
-github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
 github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
 github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
 github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -148,6 +148,14 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
 github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
 github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
+github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
+github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
+github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
+github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
+github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
 github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
 github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -202,12 +210,14 @@ github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW
 github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
 github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
 github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
 github.com/xlzd/gotp v0.1.0 h1:37blvlKCh38s+fkem+fFh7sMnceltoIEBYTVXyoa5Po=
 github.com/xlzd/gotp v0.1.0/go.mod h1:ndLJ3JKzi3xLmUProq4LLxCuECL93dG9WASNLpHz8qg=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
-github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
-github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
+github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1 h1:RAxvdTekSZCn1OO5P9d0ioDrdiiqdOsdqllxLvC+IGQ=
+github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1/go.mod h1:klRI+zA2uG6qrelDRoUaEur3gasszRE9W8e2zTgqXNU=
 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
 github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -263,6 +273,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 h1:cqHQ3AycTHvM2R7ikgyX57D+XvtcSnGylsLkOVhta/w=
 golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH8ysFIbw8=
+golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=

+ 25 - 0
util/crypto/crypto.go

@@ -2,6 +2,9 @@
 package crypto
 
 import (
+	"crypto/sha256"
+	"encoding/hex"
+
 	"golang.org/x/crypto/bcrypt"
 )
 
@@ -20,3 +23,25 @@ func IsHashed(s string) bool {
 	_, err := bcrypt.Cost([]byte(s))
 	return err == nil
 }
+
+// HashTokenSHA256 returns the hex-encoded SHA-256 digest of token. API tokens
+// are high-entropy random strings, so a fast unsalted digest is sufficient to
+// keep them irrecoverable at rest while allowing constant-time verification.
+func HashTokenSHA256(token string) string {
+	sum := sha256.Sum256([]byte(token))
+	return hex.EncodeToString(sum[:])
+}
+
+// IsSHA256Hex reports whether s looks like a hex-encoded SHA-256 digest
+// (64 lowercase hex characters), used to skip already-hashed token rows.
+func IsSHA256Hex(s string) bool {
+	if len(s) != 64 {
+		return false
+	}
+	for _, c := range s {
+		if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
+			return false
+		}
+	}
+	return true
+}

+ 2 - 2
web/controller/api_docs_test.go

@@ -131,8 +131,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 		// Skip SPA page routes (these are UI pages, not API endpoints)
 		spaPages := map[string]bool{
 			"/": true, "/panel/": true, "/panel/inbounds": true,
-			"/panel/clients": true,
-			"/panel/nodes":   true, "/panel/settings": true,
+			"/panel/clients": true, "/panel/groups": true,
+			"/panel/nodes": true, "/panel/settings": true,
 			"/panel/xray": true, "/panel/api-docs": true,
 		}
 		if spaPages[r.Path] {

+ 5 - 0
web/controller/client.go

@@ -56,6 +56,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/onlines", a.onlines)
 	g.POST("/onlinesByNode", a.onlinesByNode)
+	g.POST("/activeInbounds", a.activeInbounds)
 	g.POST("/lastOnline", a.lastOnline)
 }
 
@@ -402,6 +403,10 @@ func (a *ClientController) onlinesByNode(c *gin.Context) {
 	jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
 }
 
+func (a *ClientController) activeInbounds(c *gin.Context) {
+	jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
+}
+
 func (a *ClientController) lastOnline(c *gin.Context) {
 	data, err := a.inboundService.GetClientsLastOnline()
 	jsonObj(c, data, err)

+ 17 - 0
web/controller/node.go

@@ -28,6 +28,7 @@ func NewNodeController(g *gin.RouterGroup) *NodeController {
 func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.GET("/list", a.list)
 	g.GET("/get/:id", a.get)
+	g.GET("/webCert/:id", a.webCert)
 
 	g.POST("/add", a.add)
 	g.POST("/update/:id", a.update)
@@ -64,6 +65,22 @@ func (a *NodeController) get(c *gin.Context) {
 	jsonObj(c, n, nil)
 }
 
+// webCert returns the node's own web TLS certificate/key file paths so the
+// inbound form's "Set Cert from Panel" can fill paths that exist on the node.
+func (a *NodeController) webCert(c *gin.Context) {
+	id, err := strconv.Atoi(c.Param("id"))
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "get"), err)
+		return
+	}
+	files, err := a.nodeService.GetWebCertFiles(id)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, files, nil)
+}
+
 func (a *NodeController) ensureReachable(c *gin.Context, n *model.Node) error {
 	ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second)
 	defer cancel()

+ 19 - 0
web/controller/server.go

@@ -54,6 +54,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getConfigJson", a.getConfigJson)
 	g.GET("/getDb", a.getDb)
 	g.GET("/getNewUUID", a.getNewUUID)
+	g.GET("/getWebCertFiles", a.getWebCertFiles)
 	g.GET("/getNewX25519Cert", a.getNewX25519Cert)
 	g.GET("/getNewmldsa65", a.getNewmldsa65)
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -314,6 +315,24 @@ func (a *ServerController) importDB(c *gin.Context) {
 	jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
 }
 
+// getWebCertFiles returns this panel's own web TLS certificate and key file
+// paths. The central panel calls it on a node (via the node's API token) so
+// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
+// on the node's filesystem instead of the central panel's — see issue #4854.
+func (a *ServerController) getWebCertFiles(c *gin.Context) {
+	certFile, err := a.settingService.GetCertFile()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	keyFile, err := a.settingService.GetKeyFile()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil)
+}
+
 // getNewX25519Cert generates a new X25519 certificate.
 func (a *ServerController) getNewX25519Cert(c *gin.Context) {
 	cert, err := a.serverService.GetNewX25519Cert()

+ 8 - 7
web/job/node_traffic_sync_job.go

@@ -109,10 +109,10 @@ func (j *NodeTrafficSyncJob) Run() {
 		lastOnline = map[string]int64{}
 	}
 
-	// Prune stale local-online entries (no local active emails to add here —
-	// only the local xray poll feeds those) so a stopped local xray's clients
-	// still age out between traffic polls.
-	j.inboundService.RefreshLocalOnlineClients(nil)
+	// Prune stale local-online entries (no local active emails or inbound tags
+	// to add here — only the local xray poll feeds those) so a stopped local
+	// xray's clients and inbounds still age out between traffic polls.
+	j.inboundService.RefreshLocalOnlineClients(nil, nil)
 
 	if !websocket.HasClients() {
 		return
@@ -123,9 +123,10 @@ func (j *NodeTrafficSyncJob) Run() {
 		online = []string{}
 	}
 	websocket.BroadcastTraffic(map[string]any{
-		"onlineClients": online,
-		"onlineByNode":  j.inboundService.GetOnlineClientsByNode(),
-		"lastOnlineMap": lastOnline,
+		"onlineClients":  online,
+		"onlineByNode":   j.inboundService.GetOnlineClientsByNode(),
+		"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
+		"lastOnlineMap":  lastOnline,
 	})
 
 	clientStats := map[string]any{}

+ 13 - 1
web/job/xray_traffic_job.go

@@ -82,7 +82,18 @@ func (j *XrayTrafficJob) Run() {
 			activeEmails = append(activeEmails, ct.Email)
 		}
 	}
-	j.inboundService.RefreshLocalOnlineClients(activeEmails)
+	// Pair the email signal with the inbound tags that moved bytes this poll.
+	// Xray's user>>>email counter aggregates across every inbound a client is
+	// attached to, so an online email alone can't say which inbound it used —
+	// gating the per-inbound view on these tags keeps a multi-inbound client
+	// off inbounds that saw no traffic. See issue #4859.
+	activeInboundTags := make([]string, 0, len(traffics))
+	for _, tr := range traffics {
+		if tr != nil && tr.IsInbound && tr.Up+tr.Down > 0 {
+			activeInboundTags = append(activeInboundTags, tr.Tag)
+		}
+	}
+	j.inboundService.RefreshLocalOnlineClients(activeEmails, activeInboundTags)
 
 	if !websocket.HasClients() {
 		return
@@ -97,6 +108,7 @@ func (j *XrayTrafficJob) Run() {
 		"clientTraffics": clientTraffics,
 		"onlineClients":  onlineClients,
 		"onlineByNode":   j.inboundService.GetOnlineClientsByNode(),
+		"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
 		"lastOnlineMap":  lastOnlineMap,
 	})
 

+ 22 - 0
web/runtime/remote.go

@@ -328,6 +328,28 @@ func (r *Remote) UpdatePanel(ctx context.Context) error {
 	return err
 }
 
+// WebCertFiles holds a node's own web TLS certificate and key file paths.
+type WebCertFiles struct {
+	WebCertFile string `json:"webCertFile"`
+	WebKeyFile  string `json:"webKeyFile"`
+}
+
+// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so
+// the central panel can offer them as the "Set Cert from Panel" default for a
+// node-assigned inbound — those paths exist on the node, the central panel's
+// don't. See issue #4854.
+func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
+	env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil)
+	if err != nil {
+		return nil, err
+	}
+	var files WebCertFiles
+	if err := json.Unmarshal(env.Obj, &files); err != nil {
+		return nil, fmt.Errorf("decode web cert files: %w", err)
+	}
+	return &files, nil
+}
+
 func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
 	_, err := r.do(ctx, http.MethodPost,
 		"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)

+ 15 - 8
web/service/api_token.go

@@ -8,6 +8,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/database"
 	"github.com/mhsanaei/3x-ui/v3/database/model"
 	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/util/random"
 )
 
@@ -18,16 +19,18 @@ const apiTokenLength = 48
 type ApiTokenView struct {
 	Id        int    `json:"id"`
 	Name      string `json:"name"`
-	Token     string `json:"token"`
+	Token     string `json:"token,omitempty"`
 	Enabled   bool   `json:"enabled"`
 	CreatedAt int64  `json:"createdAt"`
 }
 
+// toView builds the metadata view returned by List. It never carries the
+// token value: only a SHA-256 hash is stored, and the plaintext is shown
+// exactly once at creation time.
 func toView(t *model.ApiToken) *ApiTokenView {
 	return &ApiTokenView{
 		Id:        t.Id,
 		Name:      t.Name,
-		Token:     t.Token,
 		Enabled:   t.Enabled,
 		CreatedAt: t.CreatedAt,
 	}
@@ -62,15 +65,18 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	if count > 0 {
 		return nil, common.NewError("a token with that name already exists")
 	}
+	plaintext := random.Seq(apiTokenLength)
 	row := &model.ApiToken{
 		Name:    name,
-		Token:   random.Seq(apiTokenLength),
+		Token:   crypto.HashTokenSHA256(plaintext),
 		Enabled: true,
 	}
 	if err := db.Create(row).Error; err != nil {
 		return nil, err
 	}
-	return toView(row), nil
+	view := toView(row)
+	view.Token = plaintext
+	return view, nil
 }
 
 func (s *ApiTokenService) Delete(id int) error {
@@ -97,8 +103,9 @@ func (s *ApiTokenService) SetEnabled(id int, enabled bool) error {
 }
 
 // Match returns true when the presented bearer token matches any enabled
-// row in api_tokens. Uses constant-time compare per row so a remote
-// attacker can't time-attack tokens byte-by-byte.
+// row in api_tokens. Tokens are stored as SHA-256 hashes, so the presented
+// value is hashed before a constant-time compare per row keeps a remote
+// attacker from timing the comparison byte-by-byte.
 func (s *ApiTokenService) Match(presented string) bool {
 	if presented == "" {
 		return false
@@ -108,10 +115,10 @@ func (s *ApiTokenService) Match(presented string) bool {
 	if err := db.Model(model.ApiToken{}).Where("enabled = ?", true).Find(&rows).Error; err != nil {
 		return false
 	}
-	presentedBytes := []byte(presented)
+	presentedHash := []byte(crypto.HashTokenSHA256(presented))
 	matched := false
 	for _, r := range rows {
-		if subtle.ConstantTimeCompare([]byte(r.Token), presentedBytes) == 1 {
+		if subtle.ConstantTimeCompare([]byte(r.Token), presentedHash) == 1 {
 			matched = true
 		}
 	}

+ 14 - 6
web/service/inbound.go

@@ -3339,6 +3339,13 @@ func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
 	return p.GetOnlineClientsByNode()
 }
 
+func (s *InboundService) GetActiveInboundsByNode() map[int][]string {
+	if p == nil {
+		return map[int][]string{}
+	}
+	return p.GetActiveInboundsByNode()
+}
+
 func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
 	if p != nil {
 		p.SetNodeOnlineClients(nodeID, emails)
@@ -3365,13 +3372,14 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
 	return result, nil
 }
 
-// RefreshLocalOnlineClients folds the emails active on this panel's own
-// xray this poll into the local online set, applying the grace window and
-// pruning stale entries. Pass nil to only prune. See xray.Process for why
-// the local set is kept separate from the shared last_online column.
-func (s *InboundService) RefreshLocalOnlineClients(activeEmails []string) {
+// RefreshLocalOnlineClients folds the emails and inbound tags active on this
+// panel's own xray this poll into the local online/active sets, applying the
+// grace window and pruning stale entries. Pass nil to only prune. See
+// xray.Process for why the local sets are kept separate from the shared
+// last_online column.
+func (s *InboundService) RefreshLocalOnlineClients(activeEmails, activeInboundTags []string) {
 	if p != nil {
-		p.RefreshLocalOnline(activeEmails, time.Now().UnixMilli(), onlineGracePeriodMs)
+		p.RefreshLocalOnline(activeEmails, activeInboundTags, time.Now().UnixMilli(), onlineGracePeriodMs)
 	}
 }
 

+ 24 - 0
web/service/node.go

@@ -382,6 +382,30 @@ func (s *NodeService) SetEnable(id int, enable bool) error {
 	return db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error
 }
 
+// GetWebCertFiles asks a node for its own web TLS certificate/key file paths,
+// used by "Set Cert from Panel" so a node-assigned inbound gets paths that
+// exist on the node rather than the central panel. See issue #4854.
+func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) {
+	n, err := s.GetById(id)
+	if err != nil || n == nil {
+		return nil, fmt.Errorf("node not found")
+	}
+	if !n.Enable {
+		return nil, fmt.Errorf("node is disabled")
+	}
+	mgr := runtime.GetManager()
+	if mgr == nil {
+		return nil, fmt.Errorf("runtime manager unavailable")
+	}
+	remote, err := mgr.RemoteFor(n)
+	if err != nil {
+		return nil, err
+	}
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+	defer cancel()
+	return remote.GetWebCertFiles(ctx)
+}
+
 // NodeUpdateResult reports the outcome of triggering a panel self-update on one
 // node so the UI can show per-node success/failure for a bulk request.
 type NodeUpdateResult struct {

+ 11 - 1
web/translation/ar-EG.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "مثل central-panel-a",
         "apiTokenNameRequired": "الاسم مطلوب",
         "apiTokenEmpty": "لا توجد رموز بعد — أنشئ واحدًا لمصادقة الروبوتات أو اللوحات البعيدة.",
-        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا."
+        "apiTokenDeleteWarning": "أي عميل يستخدم هذا الرمز سيفقد المصادقة فورًا.",
+        "apiTokenCreatedTitle": "تم إنشاء الرمز",
+        "apiTokenCreatedNotice": "انسخ هذا الرمز الآن. لأسباب أمنية لا يتم تخزينه بصيغة قابلة للقراءة ولن يتم عرضه مرة أخرى."
       },
       "toasts": {
         "modifySettings": "تم تغيير المعلمات.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "تفعيل جمع الإحصائيات لترافيك الرفع لكل بروكسي من المخرجات.",
       "statsOutboundDownlink": "إحصائيات تنزيل المخرجات",
       "statsOutboundDownlinkDesc": "تفعيل جمع الإحصائيات لترافيك التنزيل لكل بروكسي من المخرجات.",
+      "connectionLimits": "حدود الاتصال",
+      "connectionLimitsDesc": "سياسات على مستوى الاتصال لمستوى المستخدم 0. اترك الحقل فارغًا لاستخدام القيمة الافتراضية لـ Xray.",
+      "connIdle": "مهلة الخمول",
+      "connIdleDesc": "يغلق الاتصال بعد بقائه خاملًا لهذا العدد من الثواني. خفضه يحرر الذاكرة وواصفات الملفات بشكل أسرع على الخوادم المزدحمة (الافتراضي في Xray: 300).",
+      "bufferSize": "حجم المخزن المؤقت",
+      "bufferSizeDesc": "حجم المخزن المؤقت الداخلي لكل اتصال بالكيلوبايت. اضبطه على 0 لتقليل استهلاك الذاكرة على الخوادم منخفضة الذاكرة (الافتراضي في Xray يعتمد على المنصة).",
+      "bufferSizePlaceholder": "تلقائي",
+      "seconds": "ثانية",
       "rules": {
         "first": "أول",
         "last": "آخر",

+ 11 - 1
web/translation/en-US.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "e.g. central-panel-a",
         "apiTokenNameRequired": "Name is required",
         "apiTokenEmpty": "No tokens yet — create one to authenticate bots or remote panels.",
-        "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately."
+        "apiTokenDeleteWarning": "Any caller using this token will stop authenticating immediately.",
+        "apiTokenCreatedTitle": "Token created",
+        "apiTokenCreatedNotice": "Copy this token now. For security it is not stored in readable form and will not be shown again."
       },
       "toasts": {
         "modifySettings": "The parameters have been changed.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Enables the statistics collection for upstream traffic of all outbound proxies.",
       "statsOutboundDownlink": "Outbound Download Statistics",
       "statsOutboundDownlinkDesc": "Enables the statistics collection for downstream traffic of all outbound proxies.",
+      "connectionLimits": "Connection Limits",
+      "connectionLimitsDesc": "Connection-level policies for user level 0. Leave a field empty to use Xray's default.",
+      "connIdle": "Idle Timeout",
+      "connIdleDesc": "Closes a connection after it stays idle for this many seconds. Lowering it frees memory and file descriptors faster on busy servers (Xray default: 300).",
+      "bufferSize": "Buffer Size",
+      "bufferSizeDesc": "Per-connection internal buffer size in KB. Set to 0 to minimize memory usage on low-RAM servers (Xray default depends on the platform).",
+      "bufferSizePlaceholder": "auto",
+      "seconds": "seconds",
       "rules": {
         "first": "First",
         "last": "Last",

+ 11 - 1
web/translation/es-ES.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "por ejemplo central-panel-a",
         "apiTokenNameRequired": "El nombre es obligatorio",
         "apiTokenEmpty": "Aún no hay tokens — crea uno para autenticar bots o paneles remotos.",
-        "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente."
+        "apiTokenDeleteWarning": "Cualquier cliente que use este token dejará de autenticarse inmediatamente.",
+        "apiTokenCreatedTitle": "Token creado",
+        "apiTokenCreatedNotice": "Copia este token ahora. Por seguridad, no se almacena de forma legible y no se volverá a mostrar."
       },
       "toasts": {
         "modifySettings": "Los parámetros han sido modificados.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Habilita la recopilación de estadísticas para el tráfico ascendente de todos los proxies de salida.",
       "statsOutboundDownlink": "Estadísticas de Bajada de Salida",
       "statsOutboundDownlinkDesc": "Habilita la recopilación de estadísticas para el tráfico descendente de todos los proxies de salida.",
+      "connectionLimits": "Límites de conexión",
+      "connectionLimitsDesc": "Políticas a nivel de conexión para el nivel de usuario 0. Deja un campo vacío para usar el valor predeterminado de Xray.",
+      "connIdle": "Tiempo de inactividad",
+      "connIdleDesc": "Cierra una conexión después de que permanezca inactiva durante esta cantidad de segundos. Reducirlo libera memoria y descriptores de archivo más rápido en servidores con mucha carga (predeterminado de Xray: 300).",
+      "bufferSize": "Tamaño del búfer",
+      "bufferSizeDesc": "Tamaño del búfer interno por conexión en KB. Ponlo en 0 para minimizar el uso de memoria en servidores con poca RAM (el valor predeterminado de Xray depende de la plataforma).",
+      "bufferSizePlaceholder": "automático",
+      "seconds": "segundos",
       "rules": {
         "first": "Primero",
         "last": "Último",

+ 11 - 1
web/translation/fa-IR.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "مثلاً central-panel-a",
         "apiTokenNameRequired": "نام الزامی است",
         "apiTokenEmpty": "هنوز توکنی وجود ندارد — برای احراز هویت ربات‌ها یا پنل‌های راه دور یکی بسازید.",
-        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود."
+        "apiTokenDeleteWarning": "هر کلاینتی که از این توکن استفاده می‌کند بلافاصله احراز هویتش قطع می‌شود.",
+        "apiTokenCreatedTitle": "توکن ساخته شد",
+        "apiTokenCreatedNotice": "اکنون این توکن را کپی کنید. به‌دلیل امنیتی به‌صورت قابل‌خواندن ذخیره نمی‌شود و دوباره نمایش داده نخواهد شد."
       },
       "toasts": {
         "modifySettings": "پارامترها تغییر کرده‌اند.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "جمع‌آوری آمار برای ترافیک بالارو (آپلود) تمام پروکسی‌های خروجی را فعال می‌کند.",
       "statsOutboundDownlink": "آمار دانلود خروجی",
       "statsOutboundDownlinkDesc": "جمع‌آوری آمار برای ترافیک پایین‌رو (دانلود) تمام پروکسی‌های خروجی را فعال می‌کند.",
+      "connectionLimits": "محدودیت اتصال",
+      "connectionLimitsDesc": "سیاست‌های سطح اتصال برای کاربرانِ سطح ۰. هر فیلد را خالی بگذارید تا مقدار پیش‌فرض Xray استفاده شود.",
+      "connIdle": "مهلت بی‌کاری",
+      "connIdleDesc": "اتصال را پس از این تعداد ثانیه بی‌کار ماندن می‌بندد. کم‌کردن آن، روی سرورهای شلوغ حافظه و file descriptor را زودتر آزاد می‌کند (پیش‌فرض Xray: ۳۰۰).",
+      "bufferSize": "اندازهٔ بافر",
+      "bufferSizeDesc": "اندازهٔ بافر داخلی هر اتصال بر حسب کیلوبایت. برای کم‌کردن مصرف حافظه روی سرورهای کم‌رم روی ۰ بگذارید (پیش‌فرض Xray به پلتفرم بستگی دارد).",
+      "bufferSizePlaceholder": "خودکار",
+      "seconds": "ثانیه",
       "rules": {
         "first": "اولین",
         "last": "آخرین",

+ 11 - 1
web/translation/id-ID.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "misalnya central-panel-a",
         "apiTokenNameRequired": "Nama wajib diisi",
         "apiTokenEmpty": "Belum ada token — buat satu untuk mengautentikasi bot atau panel jarak jauh.",
-        "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera."
+        "apiTokenDeleteWarning": "Setiap pemanggil yang menggunakan token ini akan berhenti terautentikasi segera.",
+        "apiTokenCreatedTitle": "Token dibuat",
+        "apiTokenCreatedNotice": "Salin token ini sekarang. Demi keamanan, token tidak disimpan dalam bentuk yang dapat dibaca dan tidak akan ditampilkan lagi."
       },
       "toasts": {
         "modifySettings": "Parameter telah diubah.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unggah dari semua proxy keluar.",
       "statsOutboundDownlink": "Statistik Unduh Keluar",
       "statsOutboundDownlinkDesc": "Mengaktifkan pengumpulan statistik untuk lalu lintas unduh dari semua proxy keluar.",
+      "connectionLimits": "Batas Koneksi",
+      "connectionLimitsDesc": "Kebijakan tingkat koneksi untuk level pengguna 0. Biarkan kolom kosong untuk menggunakan nilai bawaan Xray.",
+      "connIdle": "Batas Waktu Idle",
+      "connIdleDesc": "Menutup koneksi setelah idle selama sekian detik. Menurunkannya membebaskan memori dan file descriptor lebih cepat pada server yang sibuk (bawaan Xray: 300).",
+      "bufferSize": "Ukuran Buffer",
+      "bufferSizeDesc": "Ukuran buffer internal per koneksi dalam KB. Setel ke 0 untuk meminimalkan penggunaan memori pada server ber-RAM rendah (nilai bawaan Xray bergantung pada platform).",
+      "bufferSizePlaceholder": "otomatis",
+      "seconds": "detik",
       "rules": {
         "first": "Pertama",
         "last": "Terakhir",

+ 11 - 1
web/translation/ja-JP.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例: central-panel-a",
         "apiTokenNameRequired": "名前は必須です",
         "apiTokenEmpty": "トークンがまだありません — ボットやリモートパネルを認証するために作成してください。",
-        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。"
+        "apiTokenDeleteWarning": "このトークンを使用しているクライアントは直ちに認証できなくなります。",
+        "apiTokenCreatedTitle": "トークンを作成しました",
+        "apiTokenCreatedNotice": "このトークンを今すぐコピーしてください。セキュリティ上、読み取り可能な形式では保存されず、再表示されません。"
       },
       "toasts": {
         "modifySettings": "パラメーターが変更されました。",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "すべてのアウトバウンドプロキシのアップストリームトラフィックの統計収集を有効にします。",
       "statsOutboundDownlink": "アウトバウンドダウンロード統計",
       "statsOutboundDownlinkDesc": "すべてのアウトバウンドプロキシのダウンストリームトラフィックの統計収集を有効にします。",
+      "connectionLimits": "接続制限",
+      "connectionLimitsDesc": "ユーザーレベル0の接続レベルのポリシーです。フィールドを空のままにすると Xray のデフォルト値が使用されます。",
+      "connIdle": "アイドルタイムアウト",
+      "connIdleDesc": "接続がこの秒数アイドル状態のままになると接続を閉じます。値を下げると、混雑したサーバーでメモリとファイルディスクリプタをより早く解放できます(Xray のデフォルト: 300)。",
+      "bufferSize": "バッファサイズ",
+      "bufferSizeDesc": "接続ごとの内部バッファサイズ(KB単位)。低メモリのサーバーでメモリ使用量を最小限にするには 0 に設定します(Xray のデフォルトはプラットフォームに依存します)。",
+      "bufferSizePlaceholder": "自動",
+      "seconds": "秒",
       "rules": {
         "first": "最初",
         "last": "最後",

+ 11 - 1
web/translation/pt-BR.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "ex.: central-panel-a",
         "apiTokenNameRequired": "O nome é obrigatório",
         "apiTokenEmpty": "Nenhum token ainda — crie um para autenticar bots ou painéis remotos.",
-        "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente."
+        "apiTokenDeleteWarning": "Qualquer cliente usando este token deixará de se autenticar imediatamente.",
+        "apiTokenCreatedTitle": "Token criado",
+        "apiTokenCreatedNotice": "Copie este token agora. Por segurança, ele não é armazenado de forma legível e não será exibido novamente."
       },
       "toasts": {
         "modifySettings": "Os parâmetros foram alterados.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Habilita a coleta de estatísticas para o tráfego de upload de todos os proxies de saída.",
       "statsOutboundDownlink": "Estatísticas de Download de Saída",
       "statsOutboundDownlinkDesc": "Habilita a coleta de estatísticas para o tráfego de download de todos os proxies de saída.",
+      "connectionLimits": "Limites de conexão",
+      "connectionLimitsDesc": "Políticas em nível de conexão para o nível de usuário 0. Deixe um campo vazio para usar o padrão do Xray.",
+      "connIdle": "Tempo limite de inatividade",
+      "connIdleDesc": "Fecha uma conexão depois que ela fica inativa por esta quantidade de segundos. Reduzi-lo libera memória e descritores de arquivo mais rápido em servidores ocupados (padrão do Xray: 300).",
+      "bufferSize": "Tamanho do buffer",
+      "bufferSizeDesc": "Tamanho do buffer interno por conexão em KB. Defina como 0 para minimizar o uso de memória em servidores com pouca RAM (o padrão do Xray depende da plataforma).",
+      "bufferSizePlaceholder": "automático",
+      "seconds": "segundos",
       "rules": {
         "first": "Primeiro",
         "last": "Último",

+ 11 - 1
web/translation/ru-RU.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "например, central-panel-a",
         "apiTokenNameRequired": "Имя обязательно",
         "apiTokenEmpty": "Токенов пока нет — создайте один для аутентификации ботов или удалённых панелей.",
-        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию."
+        "apiTokenDeleteWarning": "Любой клиент, использующий этот токен, немедленно потеряет аутентификацию.",
+        "apiTokenCreatedTitle": "Токен создан",
+        "apiTokenCreatedNotice": "Скопируйте этот токен сейчас. В целях безопасности он не хранится в читаемом виде и больше не будет показан."
       },
       "toasts": {
         "modifySettings": "Настройки изменены",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Включает сбор статистики для исходящего трафика всех исходящих прокси.",
       "statsOutboundDownlink": "Статистика исходящего даунлинка",
       "statsOutboundDownlinkDesc": "Включает сбор статистики для входящего трафика всех исходящих прокси.",
+      "connectionLimits": "Ограничения соединения",
+      "connectionLimitsDesc": "Политики уровня соединения для пользователей уровня 0. Оставьте поле пустым, чтобы использовать значение Xray по умолчанию.",
+      "connIdle": "Тайм-аут простоя",
+      "connIdleDesc": "Закрывает соединение после простоя в течение указанного числа секунд. Уменьшение значения быстрее освобождает память и файловые дескрипторы на нагруженных серверах (по умолчанию в Xray: 300).",
+      "bufferSize": "Размер буфера",
+      "bufferSizeDesc": "Размер внутреннего буфера на соединение в КБ. Установите 0, чтобы минимизировать использование памяти на серверах с малым объёмом ОЗУ (значение Xray по умолчанию зависит от платформы).",
+      "bufferSizePlaceholder": "авто",
+      "seconds": "секунд",
       "rules": {
         "first": "Первый",
         "last": "Последний",

+ 11 - 1
web/translation/tr-TR.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "örn. central-panel-a",
         "apiTokenNameRequired": "Ad zorunludur",
         "apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
-        "apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder."
+        "apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder.",
+        "apiTokenCreatedTitle": "Belirteç oluşturuldu",
+        "apiTokenCreatedNotice": "Bu belirteci şimdi kopyalayın. Güvenlik nedeniyle okunabilir biçimde saklanmaz ve tekrar gösterilmez."
       },
       "toasts": {
         "modifySettings": "Parametreler değiştirildi.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Tüm giden proxy'lerin yükleme trafiği için istatistik toplamayı etkinleştirir.",
       "statsOutboundDownlink": "Giden İndirme İstatistikleri",
       "statsOutboundDownlinkDesc": "Tüm giden proxy'lerin indirme trafiği için istatistik toplamayı etkinleştirir.",
+      "connectionLimits": "Bağlantı Sınırları",
+      "connectionLimitsDesc": "Kullanıcı seviyesi 0 için bağlantı düzeyi politikaları. Xray'in varsayılanını kullanmak için alanı boş bırakın.",
+      "connIdle": "Boşta Kalma Zaman Aşımı",
+      "connIdleDesc": "Bağlantı bu kadar saniye boşta kaldıktan sonra kapatılır. Değerin düşürülmesi, yoğun sunucularda belleği ve dosya tanımlayıcılarını daha hızlı serbest bırakır (Xray varsayılanı: 300).",
+      "bufferSize": "Arabellek Boyutu",
+      "bufferSizeDesc": "Bağlantı başına dahili arabellek boyutu (KB). Düşük RAM'li sunucularda bellek kullanımını en aza indirmek için 0 olarak ayarlayın (Xray varsayılanı platforma bağlıdır).",
+      "bufferSizePlaceholder": "otomatik",
+      "seconds": "saniye",
       "rules": {
         "first": "İlk",
         "last": "Son",

+ 11 - 1
web/translation/uk-UA.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "наприклад, central-panel-a",
         "apiTokenNameRequired": "Назва обов'язкова",
         "apiTokenEmpty": "Поки немає токенів — створіть один для автентифікації ботів або віддалених панелей.",
-        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію."
+        "apiTokenDeleteWarning": "Будь-який клієнт, що використовує цей токен, негайно втратить автентифікацію.",
+        "apiTokenCreatedTitle": "Токен створено",
+        "apiTokenCreatedNotice": "Скопіюйте цей токен зараз. З міркувань безпеки він не зберігається у читабельному вигляді й більше не відображатиметься."
       },
       "toasts": {
         "modifySettings": "Параметри було змінено.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Увімкнення збору статистики для вхідного трафіку всіх вихідних проксі.",
       "statsOutboundDownlink": "Статистика вихідного даунлінку",
       "statsOutboundDownlinkDesc": "Увімкнення збору статистики для вихідного трафіку всіх вихідних проксі.",
+      "connectionLimits": "Обмеження з'єднання",
+      "connectionLimitsDesc": "Політики рівня з'єднання для користувачів рівня 0. Залиште поле порожнім, щоб використовувати значення Xray за замовчуванням.",
+      "connIdle": "Тайм-аут простою",
+      "connIdleDesc": "Закриває з'єднання після простою протягом вказаної кількості секунд. Зменшення значення швидше звільняє пам'ять і файлові дескриптори на завантажених серверах (за замовчуванням у Xray: 300).",
+      "bufferSize": "Розмір буфера",
+      "bufferSizeDesc": "Розмір внутрішнього буфера на з'єднання в КБ. Встановіть 0, щоб мінімізувати використання пам'яті на серверах з малим обсягом ОЗП (значення Xray за замовчуванням залежить від платформи).",
+      "bufferSizePlaceholder": "авто",
+      "seconds": "секунд",
       "rules": {
         "first": "Перший",
         "last": "Останній",

+ 11 - 1
web/translation/vi-VN.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "ví dụ: central-panel-a",
         "apiTokenNameRequired": "Tên là bắt buộc",
         "apiTokenEmpty": "Chưa có token nào — tạo một token để xác thực bot hoặc panel từ xa.",
-        "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức."
+        "apiTokenDeleteWarning": "Mọi client đang dùng token này sẽ ngừng xác thực ngay lập tức.",
+        "apiTokenCreatedTitle": "Đã tạo token",
+        "apiTokenCreatedNotice": "Hãy sao chép token này ngay bây giờ. Vì lý do bảo mật, token không được lưu ở dạng đọc được và sẽ không hiển thị lại."
       },
       "toasts": {
         "modifySettings": "Các tham số đã được thay đổi.",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải lên của tất cả các proxy đầu ra.",
       "statsOutboundDownlink": "Thống kê tải xuống đầu ra",
       "statsOutboundDownlinkDesc": "Kích hoạt thu thập thống kê cho lưu lượng tải xuống của tất cả các proxy đầu ra.",
+      "connectionLimits": "Giới hạn kết nối",
+      "connectionLimitsDesc": "Chính sách cấp kết nối cho người dùng cấp 0. Để trống một trường để sử dụng giá trị mặc định của Xray.",
+      "connIdle": "Thời gian chờ nhàn rỗi",
+      "connIdleDesc": "Đóng kết nối sau khi nó ở trạng thái nhàn rỗi trong số giây này. Giảm giá trị này giúp giải phóng bộ nhớ và file descriptor nhanh hơn trên các máy chủ bận (mặc định của Xray: 300).",
+      "bufferSize": "Kích thước bộ đệm",
+      "bufferSizeDesc": "Kích thước bộ đệm nội bộ trên mỗi kết nối tính bằng KB. Đặt thành 0 để giảm thiểu mức sử dụng bộ nhớ trên các máy chủ ít RAM (giá trị mặc định của Xray tùy thuộc vào nền tảng).",
+      "bufferSizePlaceholder": "tự động",
+      "seconds": "giây",
       "rules": {
         "first": "Đầu tiên",
         "last": "Cuối cùng",

+ 11 - 1
web/translation/zh-CN.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名称必填",
         "apiTokenEmpty": "暂无令牌 — 创建一个用于认证机器人或远程面板。",
-        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。"
+        "apiTokenDeleteWarning": "使用此令牌的任何调用方将立即无法认证。",
+        "apiTokenCreatedTitle": "令牌已创建",
+        "apiTokenCreatedNotice": "请立即复制此令牌。出于安全考虑,它不会以可读形式存储,也不会再次显示。"
       },
       "toasts": {
         "modifySettings": "参数已更改。",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "启用所有出站代理的上行流量统计收集。",
       "statsOutboundDownlink": "出站下载统计",
       "statsOutboundDownlinkDesc": "启用所有出站代理的下行流量统计收集。",
+      "connectionLimits": "连接限制",
+      "connectionLimitsDesc": "用户等级 0 的连接级策略。留空则使用 Xray 的默认值。",
+      "connIdle": "空闲超时",
+      "connIdleDesc": "连接空闲达到该秒数后将被关闭。在繁忙的服务器上调低此值可更快释放内存和文件描述符(Xray 默认值:300)。",
+      "bufferSize": "缓冲区大小",
+      "bufferSizeDesc": "每个连接的内部缓冲区大小(KB)。在低内存服务器上设为 0 可最大限度减少内存占用(Xray 默认值取决于平台)。",
+      "bufferSizePlaceholder": "自动",
+      "seconds": "秒",
       "rules": {
         "first": "置顶",
         "last": "置底",

+ 11 - 1
web/translation/zh-TW.json

@@ -1119,7 +1119,9 @@
         "apiTokenNamePlaceholder": "例如 central-panel-a",
         "apiTokenNameRequired": "名稱必填",
         "apiTokenEmpty": "尚無令牌 — 建立一個以認證機器人或遠端面板。",
-        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。"
+        "apiTokenDeleteWarning": "使用此令牌的任何呼叫方將立即無法認證。",
+        "apiTokenCreatedTitle": "權杖已建立",
+        "apiTokenCreatedNotice": "請立即複製此權杖。基於安全考量,它不會以可讀形式儲存,也不會再次顯示。"
       },
       "toasts": {
         "modifySettings": "參數已更改。",
@@ -1201,6 +1203,14 @@
       "statsOutboundUplinkDesc": "啟用所有出站代理的上行流量統計收集。",
       "statsOutboundDownlink": "出站下載統計",
       "statsOutboundDownlinkDesc": "啟用所有出站代理的下行流量統計收集。",
+      "connectionLimits": "連線限制",
+      "connectionLimitsDesc": "使用者等級 0 的連線層級原則。留空則使用 Xray 的預設值。",
+      "connIdle": "閒置逾時",
+      "connIdleDesc": "連線閒置達到該秒數後將被關閉。在繁忙的伺服器上調低此值可更快釋放記憶體和檔案描述符(Xray 預設值:300)。",
+      "bufferSize": "緩衝區大小",
+      "bufferSizeDesc": "每個連線的內部緩衝區大小(KB)。在低記憶體伺服器上設為 0 可最大限度減少記憶體佔用(Xray 預設值取決於平台)。",
+      "bufferSizePlaceholder": "自動",
+      "seconds": "秒",
       "rules": {
         "first": "置頂",
         "last": "置底",

+ 31 - 5
xray/online_test.go

@@ -24,7 +24,7 @@ func assertSameSet(t *testing.T, label string, got, want []string) {
 // client online on one node must not be reported online on any other node.
 func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
 	p := newOnlineTestProcess()
-	p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
+	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
 	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
 	p.SetNodeOnlineClients(5, []string{"user3"})
 
@@ -63,7 +63,7 @@ func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
 // client-centric / total-count views) still merges every node and dedupes.
 func TestGetOnlineClientsUnionDedupes(t *testing.T) {
 	p := newOnlineTestProcess()
-	p.RefreshLocalOnline([]string{"user1"}, 1000, 20000)
+	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
 	p.SetNodeOnlineClients(3, []string{"user1", "user2"})
 
 	assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
@@ -76,18 +76,18 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	p := newOnlineTestProcess()
 	const grace = 20000
 
-	p.RefreshLocalOnline([]string{"user1"}, 1000, grace)
+	p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
 	if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
 		t.Fatalf("user1 should be online right after activity, got %v", got)
 	}
 
-	p.RefreshLocalOnline([]string{"user2"}, 11000, grace)
+	p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
 	got := p.GetOnlineClientsByNode()[localNodeKey]
 	if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
 		t.Fatalf("both within grace window, got %v", got)
 	}
 
-	p.RefreshLocalOnline(nil, 22000, grace)
+	p.RefreshLocalOnline(nil, nil, 22000, grace)
 	got = p.GetOnlineClientsByNode()[localNodeKey]
 	if slices.Contains(got, "user1") {
 		t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
@@ -97,6 +97,32 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
 	}
 }
 
+// TestGetActiveInboundsByNodeTracksGraceWindow pins the fix for issue #4859: a
+// multi-inbound client must only count as online on inbounds that actually
+// carried traffic. The active-inbound signal honours the same grace window as
+// the online-email signal, and only this panel's tags report under key 0.
+func TestGetActiveInboundsByNodeTracksGraceWindow(t *testing.T) {
+	p := newOnlineTestProcess()
+	const grace = 20000
+
+	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
+	got := p.GetActiveInboundsByNode()[localNodeKey]
+	assertSameSet(t, "active after first poll", got, []string{"inbound-a"})
+
+	p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
+	got = p.GetActiveInboundsByNode()[localNodeKey]
+	assertSameSet(t, "both within grace", got, []string{"inbound-a", "inbound-b"})
+
+	p.RefreshLocalOnline(nil, nil, 22000, grace)
+	got = p.GetActiveInboundsByNode()[localNodeKey]
+	assertSameSet(t, "inbound-a (idle 21s, past grace) aged out, inbound-b kept", got, []string{"inbound-b"})
+
+	p.RefreshLocalOnline(nil, nil, 40000, grace)
+	if _, ok := p.GetActiveInboundsByNode()[localNodeKey]; ok {
+		t.Errorf("all inbounds idle past grace, key 0 should be absent: %v", p.GetActiveInboundsByNode())
+	}
+}
+
 // TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
 // clients must disappear from the per-node map immediately.
 func TestClearNodeOnlineClientsDropsNode(t *testing.T) {

+ 56 - 7
xray/process.go

@@ -135,6 +135,13 @@ type process struct {
 	// snapshots — so a client connected solely to a remote node is not
 	// reported online on local inbounds.
 	onlineClients []string
+	// localActiveInbounds is the set of THIS panel's inbound tags that
+	// carried traffic within the same grace window. Xray's user>>>email
+	// stat aggregates across every inbound a client is attached to, so an
+	// online email alone can't say which inbound it actually used. Pairing
+	// it with the inbound>>>tag stat lets the per-inbound view drop a
+	// multi-inbound client from inbounds that saw no traffic this window.
+	localActiveInbounds []string
 	// localLastOnline records, per email, the last time this panel's own
 	// xray reported traffic for it. RefreshLocalOnline rebuilds
 	// onlineClients from this map each tick, keeping the local online set
@@ -142,6 +149,12 @@ type process struct {
 	// column is bumped by remote-node syncs too and would otherwise leak
 	// remote-only clients into the local set.
 	localLastOnline map[string]int64
+	// localInboundLastActive mirrors localLastOnline for inbound tags: the
+	// last tick this panel's xray reported traffic through each tag.
+	// Rebuilt into localActiveInbounds under the same grace window so the
+	// two signals stay aligned — an email within grace always has the
+	// inbound it used within grace too.
+	localInboundLastActive map[string]int64
 	// nodeOnlineClients holds the online-emails list reported by each
 	// remote node, keyed by node id. NodeTrafficSyncJob populates entries
 	// per cron tick and clears them when a node's probe fails. The mutex
@@ -296,13 +309,33 @@ func (p *Process) GetOnlineClientsByNode() map[int][]string {
 	return out
 }
 
-// RefreshLocalOnline records that each email in activeEmails had local xray
-// traffic at now, then rebuilds onlineClients from every email seen within
-// graceMs and prunes entries older than that. Called by the local
-// XrayTrafficJob after each xray gRPC stats poll. Pass a nil/empty
-// activeEmails to only prune — NodeTrafficSyncJob does this so a stopped
-// local xray's clients still age out between local traffic polls.
-func (p *Process) RefreshLocalOnline(activeEmails []string, now, graceMs int64) {
+// GetActiveInboundsByNode returns the inbound tags that carried traffic within
+// the grace window, grouped by node. Only this panel's own xray reports
+// per-inbound activity (under localNodeKey); remote-node snapshots don't carry
+// it, so their nodes are simply absent — the per-inbound view reads "node
+// missing" as "don't gate" and falls back to the email-only signal there.
+// Empty groups are omitted, mirroring GetOnlineClientsByNode.
+func (p *Process) GetActiveInboundsByNode() map[int][]string {
+	p.onlineMu.RLock()
+	defer p.onlineMu.RUnlock()
+
+	if len(p.localActiveInbounds) == 0 {
+		return map[int][]string{}
+	}
+	out := make(map[int][]string, 1)
+	local := make([]string, len(p.localActiveInbounds))
+	copy(local, p.localActiveInbounds)
+	out[localNodeKey] = local
+	return out
+}
+
+// RefreshLocalOnline records that each email in activeEmails and each tag in
+// activeInboundTags had local xray traffic at now, then rebuilds onlineClients
+// and localActiveInbounds from every entry seen within graceMs, pruning older
+// ones. Called by the local XrayTrafficJob after each xray gRPC stats poll.
+// Pass nil/empty slices to only prune — NodeTrafficSyncJob does this so a
+// stopped local xray's clients and inbounds still age out between local polls.
+func (p *Process) RefreshLocalOnline(activeEmails, activeInboundTags []string, now, graceMs int64) {
 	p.onlineMu.Lock()
 	defer p.onlineMu.Unlock()
 	if p.localLastOnline == nil {
@@ -320,6 +353,22 @@ func (p *Process) RefreshLocalOnline(activeEmails []string, now, graceMs int64)
 		}
 	}
 	p.onlineClients = online
+
+	if p.localInboundLastActive == nil {
+		p.localInboundLastActive = make(map[string]int64, len(activeInboundTags))
+	}
+	for _, tag := range activeInboundTags {
+		p.localInboundLastActive[tag] = now
+	}
+	activeInbounds := make([]string, 0, len(p.localInboundLastActive))
+	for tag, ts := range p.localInboundLastActive {
+		if now-ts < graceMs {
+			activeInbounds = append(activeInbounds, tag)
+		} else {
+			delete(p.localInboundLastActive, tag)
+		}
+	}
+	p.localActiveInbounds = activeInbounds
 }
 
 // SetNodeOnlineClients records the online-emails set for one remote