Просмотр исходного кода

feat(node): node hardening — mTLS, hashed+zstd reconcile transport, per-node net metrics (#5382)

* fix(api-docs): document clientIpsByGuid route

Restores a green `go test ./...` baseline: TestAPIRoutesDocumented
flagged POST /panel/api/clients/clientIpsByGuid (added in 9385b6c6)
as undocumented in endpoints.ts.

* test(node): characterize current node TLS + API auth behavior

Phase 0 regression net for the mTLS work. These pass on unchanged
production code and lock the pre-mTLS contracts so later phases can be
proven additive:

- tlsConfigForNode: skip -> InsecureSkipVerify (no VerifyConnection);
  pin -> VerifyConnection installed.
- checkAPIAuth: bearer match -> Next + api_authed; unauthenticated ->
  401 (XHR) / 404; valid session -> Next.
- panel HTTPS listener with no ClientAuth accepts a client that presents
  no client certificate (the browsers-keep-working invariant).

* feat(crypto): node-auth CA + client-cert minting (TDD)

Stdlib-only ECDSA P-256 helpers for the node mTLS work:
- GenerateNodeCA: self-signed CA (IsCA, CertSign, path len 0)
- IssueClientCert: client-auth leaf (ExtKeyUsageClientAuth) signed by CA
- LoadCAFromPEM: parse a CA cert+key for issuing / trust-pool building

Tests assert the contract (leaf verifies against the issuing CA with
ExtKeyUsageClientAuth), seen failing on the assertion before impl.

* feat(node): lazy node mTLS CA + client cert in settings (TDD)

SettingService gains opt-in mTLS material, all stored as Setting rows
with empty defaults and kept out of entity.AllSetting (so private keys
never reach the settings UI/export):
- EnsureNodeMtlsCA: mint+persist the node-auth CA once, reuse thereafter
- EnsureMasterClientCert: issue the master client cert from the CA, idempotent
- NodeMtlsClientCAPool: ClientCAs trust pool for the listener; nil when
  unconfigured so the no-mTLS path is unchanged

Tests assert idempotency and that the client cert verifies against the CA
for client auth; seen failing on the assertion before impl.

* feat(node): mtls client TLS config + master-cert provider (TDD)

tlsConfigForNode gains an 'mtls' branch that presents the master client
certificate and verifies the node server against system roots (no
InsecureSkipVerify, no custom RootCAs). The cert is supplied via an
injected MasterClientCertProvider so runtime need not import service;
it fails closed when unconfigured. skip/pin contracts unchanged.

* feat(node): allow tokenless mtls nodes in remote do() (TDD)

mtls nodes authenticate with a client certificate, so the bearer token
becomes optional for them: do() no longer rejects an empty ApiToken when
TlsVerifyMode is mtls, and the Authorization header is omitted when no
token is set. Every other mode still requires a token (regression kept).

* feat(node): authenticate verified client certs in checkAPIAuth (TDD)

A completed mTLS handshake (non-empty r.TLS.VerifiedChains) now
authenticates an API request, equivalent to a valid bearer token, and
sets api_authed so the CSRF middleware lets cert-authed mutations
through. Bearer/session/reject paths unchanged. The accept-path assert
was mutation-checked (guard flipped -> test red -> reverted).

* feat(node): opt-in mTLS on the panel listener (TDD; mutation-checked)

web.go now applies VerifyClientCertIfGiven + ClientCAs to the HTTPS
listener when a node trust CA is configured, and wires the master client
cert provider for outbound mtls calls. With no CA the listener is
byte-identical to before (browsers unaffected).

applyNodeMtls is covered end-to-end: no-cert client handshakes (browsers
keep working), a CA-signed client cert verifies, a foreign-CA cert is
rejected at the handshake. Mutation-checked:
- RequireAndVerifyClientCert -> no-cert client rejected (red) -> reverted
- drop ClientCAs -> master cert no longer trusted (red) -> reverted

* feat(node): accept mtls verify-mode + CA reveal endpoint (TDD)

- model.Node.TlsVerifyMode validator now accepts 'mtls'
- normalize() preserves mtls and requires the node scheme to be https
  (fail closed), instead of clamping mtls back to verify
- NodeService.NodeMtlsCaCert + POST /panel/api/nodes/mtls/ca return this
  panel's node-auth CA cert (public) to paste into a node, minting the CA
  + master client cert on first call
- endpoints.ts documents the new route (doc-sync test)

No model column added (enum is a string), so no migration/codegen.

* feat(node): node mTLS UI + trust-CA setter (TDD)

Backend:
- NodeService.SetNodeMtlsTrustCA + POST /panel/api/nodes/mtls/trustCA
  store the CA this panel trusts for incoming node-API client certs
  (validates PEM, empty clears); applied on next restart
- endpoints.ts + regenerated openapi.json document both mtls routes

Frontend:
- node form: 'mtls' TLS-verify option + setup hint (zod enum updated)
- Nodes page 'Node mTLS' card: copy this panel's CA, and paste/save the
  trusted parent CA
- en-US i18n keys (other locales fall back to en-US)

Gates green: go build (native+windows), vet, go test ./...; frontend
typecheck, lint, vitest (541).

* style(node): gofmt web_mtls_test doc comment

* feat(node): hashed+zstd reconcile transport (TDD, negotiated, mixed-version safe)

Adds an integrity + compression envelope to node config pushes:
- internal/util/wirecodec: shared zstd codec (bomb-capped decode) +
  SHA-256 hashing + the header/capability constants
- Remote.do(): always attaches X-Config-Sha256 of the uncompressed body;
  zstd-compresses only when the node advertised support (learned from its
  X-3x-Node-Caps response header) and the body is >=1KiB
- ConfigEnvelopeMiddleware on /panel/api: advertises the cap, decompresses
  and verifies the hash (handler not invoked on mismatch) before binding

Mixed-version safe: old nodes never advertise the cap -> plain bodies;
the hash header is verify-if-present so any panel/node mix interoperates
(existing reconcile tests stay green). klauspost/compress promoted to a
direct dep. Hash-mismatch reject was mutation-checked (compare defeated
-> test red -> reverted).

* feat(node): per-node network throughput metrics (TDD)

The node status response already carries gopsutil netIO.up/down (summed
non-virtual interfaces), so no node-side change is needed:
- probe() parses netIO.up/down into HeartbeatPatch.NetUp/NetDown
- Node gains net_up/net_down columns (AutoMigrate); UpdateHeartbeat
  persists them and appends netUp/netDown to the per-node metric history
- NodeMetricKeys whitelists netUp/netDown so the history endpoint serves them
- NodeHistoryPanel renders Net Up/Down sparklines (KB/s, no 0-100 clamp)
- regenerated frontend types + openapi.json for the new Node fields

* feat(node): move node mTLS controls into a toolbar button + modal

The Node mTLS panel was an always-visible card cluttering the nodes
page. Replace it with a 'Node mTLS' button beside 'Add node' that opens
a modal with the same copy-CA + trusted-parent-CA controls; the modal
closes on a successful save. No backend/i18n changes.

* i18n(node): translate mTLS + net-metrics keys for all locales

Adds the node mTLS strings (tlsMtls, mtlsFormHint, mtls.* dialog + the
saveMtls toast) and the netUp/netDown chart labels to all 12 non-English
catalogs (ar, es, fa, id, ja, pt, ru, tr, uk, vi, zh-CN, zh-TW), matching
each catalog's existing terminology. Technical tokens (mTLS/TLS/CA/API/
KB/s) kept verbatim.

* fix(node): address Copilot review on node-hardening PR

- setting_mtls: fail closed on a half-present CA/master-cert pair instead of
  silently regenerating (which would rotate the CA and break fleet trust).
- config_envelope: reject non-zstd Content-Encoding on the envelope path
  rather than hashing/forwarding a still-encoded body to the handler.
- node mTLS: support tokenless mTLS end-to-end — apiToken is now
  required_unless tlsVerifyMode=mtls (model) with matching conditional
  validation in NodeFormSchema, so the runtime allowance is actually reachable.
- NodesPage: add a catch block to onSaveTrustCa so save failures surface.
Sanaei 8 часов назад
Родитель
Сommit
37c5e0bfd2
51 измененных файлов с 3040 добавлено и 981 удалено
  1. 136 1
      frontend/public/openapi.json
  2. 2 0
      frontend/src/generated/examples.ts
  3. 12 1
      frontend/src/generated/schemas.ts
  4. 2 0
      frontend/src/generated/types.ts
  5. 3 1
      frontend/src/generated/zod.ts
  6. 18 0
      frontend/src/pages/api-docs/endpoints.ts
  7. 10 0
      frontend/src/pages/nodes/NodeFormModal.tsx
  8. 51 3
      frontend/src/pages/nodes/NodeHistoryPanel.tsx
  9. 6 0
      frontend/src/pages/nodes/NodeList.tsx
  10. 73 1
      frontend/src/pages/nodes/NodesPage.tsx
  11. 13 3
      frontend/src/schemas/node.ts
  12. 1 1
      go.mod
  13. 4 2
      internal/database/model/model.go
  14. 122 0
      internal/util/crypto/certs.go
  15. 69 0
      internal/util/crypto/certs_test.go
  16. 57 0
      internal/util/wirecodec/wirecodec.go
  17. 51 0
      internal/util/wirecodec/wirecodec_test.go
  18. 14 0
      internal/web/controller/api.go
  19. 203 0
      internal/web/controller/api_auth_test.go
  20. 32 0
      internal/web/controller/node.go
  21. 71 0
      internal/web/middleware/config_envelope.go
  22. 82 0
      internal/web/middleware/config_envelope_test.go
  23. 59 5
      internal/web/runtime/remote.go
  24. 92 0
      internal/web/runtime/remote_envelope_test.go
  25. 71 0
      internal/web/runtime/remote_mtls_test.go
  26. 42 0
      internal/web/runtime/tls_client.go
  27. 80 0
      internal/web/runtime/tls_client_test.go
  28. 1 1
      internal/web/service/metric_history.go
  29. 19 2
      internal/web/service/node.go
  30. 43 0
      internal/web/service/node_mtls.go
  31. 111 0
      internal/web/service/node_mtls_test.go
  32. 72 0
      internal/web/service/node_netmetrics_test.go
  33. 18 9
      internal/web/service/setting.go
  34. 106 0
      internal/web/service/setting_mtls.go
  35. 125 0
      internal/web/service/setting_mtls_test.go
  36. 84 85
      internal/web/translation/ar-EG.json
  37. 19 1
      internal/web/translation/en-US.json
  38. 84 85
      internal/web/translation/es-ES.json
  39. 84 85
      internal/web/translation/fa-IR.json
  40. 84 85
      internal/web/translation/id-ID.json
  41. 84 85
      internal/web/translation/ja-JP.json
  42. 84 85
      internal/web/translation/pt-BR.json
  43. 35 16
      internal/web/translation/ru-RU.json
  44. 84 84
      internal/web/translation/tr-TR.json
  45. 84 85
      internal/web/translation/uk-UA.json
  46. 84 85
      internal/web/translation/vi-VN.json
  47. 84 85
      internal/web/translation/zh-CN.json
  48. 84 85
      internal/web/translation/zh-TW.json
  49. 18 0
      internal/web/web.go
  50. 19 0
      internal/web/web_mtls.go
  51. 154 0
      internal/web/web_mtls_test.go

+ 136 - 1
frontend/public/openapi.json

@@ -1719,6 +1719,14 @@
             "example": "de-fra-1",
             "type": "string"
           },
+          "netDown": {
+            "example": 2097152,
+            "type": "integer"
+          },
+          "netUp": {
+            "example": 1048576,
+            "type": "integer"
+          },
           "onlineCount": {
             "example": 3,
             "type": "integer"
@@ -1763,7 +1771,8 @@
             "enum": [
               "verify",
               "skip",
-              "pin"
+              "pin",
+              "mtls"
             ],
             "type": "string"
           },
@@ -1812,6 +1821,8 @@
           "latencyMs",
           "memPct",
           "name",
+          "netDown",
+          "netUp",
           "onlineCount",
           "outboundTag",
           "panelVersion",
@@ -5936,6 +5947,49 @@
         }
       }
     },
+    "/panel/api/clients/clientIpsByGuid": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.",
+        "operationId": "post_panel_api_clients_clientIpsByGuid",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "a1b2-...": {
+                      "user1": [
+                        {
+                          "ip": "1.2.3.4",
+                          "timestamp": 1700000000
+                        }
+                      ]
+                    }
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/activeInbounds": {
       "post": {
         "tags": [
@@ -6223,6 +6277,8 @@
                       "latencyMs": 42,
                       "memPct": 45.1,
                       "name": "de-fra-1",
+                      "netDown": 2097152,
+                      "netUp": 1048576,
                       "onlineCount": 3,
                       "outboundTag": "",
                       "panelVersion": "v3.x.x",
@@ -6248,6 +6304,85 @@
         }
       }
     },
+    "/panel/api/nodes/mtls/ca": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
+        "operationId": "post_panel_api_nodes_mtls_ca",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/nodes/mtls/trustCA": {
+      "post": {
+        "tags": [
+          "Nodes"
+        ],
+        "summary": "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
+        "operationId": "post_panel_api_nodes_mtls_trustCA",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "caCert": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/nodes/get/{id}": {
       "get": {
         "tags": [

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

@@ -372,6 +372,8 @@ export const EXAMPLES: Record<string, unknown> = {
     "latencyMs": 42,
     "memPct": 45.1,
     "name": "de-fra-1",
+    "netDown": 2097152,
+    "netUp": 1048576,
     "onlineCount": 3,
     "outboundTag": "",
     "panelVersion": "v3.x.x",

+ 12 - 1
frontend/src/generated/schemas.ts

@@ -1693,6 +1693,14 @@ export const SCHEMAS: Record<string, unknown> = {
         "example": "de-fra-1",
         "type": "string"
       },
+      "netDown": {
+        "example": 2097152,
+        "type": "integer"
+      },
+      "netUp": {
+        "example": 1048576,
+        "type": "integer"
+      },
       "onlineCount": {
         "example": 3,
         "type": "integer"
@@ -1737,7 +1745,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "enum": [
           "verify",
           "skip",
-          "pin"
+          "pin",
+          "mtls"
         ],
         "type": "string"
       },
@@ -1786,6 +1795,8 @@ export const SCHEMAS: Record<string, unknown> = {
       "latencyMs",
       "memPct",
       "name",
+      "netDown",
+      "netUp",
       "onlineCount",
       "outboundTag",
       "panelVersion",

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

@@ -377,6 +377,8 @@ export interface Node {
   latencyMs: number;
   memPct: number;
   name: string;
+  netDown: number;
+  netUp: number;
   onlineCount: number;
   outboundTag: string;
   panelVersion: string;

+ 3 - 1
frontend/src/generated/zod.ts

@@ -405,6 +405,8 @@ export const NodeSchema = z.object({
   latencyMs: z.number().int(),
   memPct: z.number(),
   name: z.string(),
+  netDown: z.number().int(),
+  netUp: z.number().int(),
   onlineCount: z.number().int(),
   outboundTag: z.string(),
   panelVersion: z.string(),
@@ -414,7 +416,7 @@ export const NodeSchema = z.object({
   remark: z.string(),
   scheme: z.enum(['http', 'https']),
   status: z.string(),
-  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
   transitive: z.boolean().optional(),
   updatedAt: z.number().int(),
   uptimeSecs: z.number().int(),

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

@@ -731,6 +731,12 @@ export const sections: readonly Section[] = [
         summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
         response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": ["user1"],\n    "c3d4-...": ["user1", "user2"]\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/clientIpsByGuid',
+        summary: 'Per-client source IPs grouped by the panelGuid of the node that observed them. Lets the central panel attribute and enforce per-client IP limits using the real visitor IPs each node sees, instead of the address of the intermediate panel it syncs through.',
+        response: '{\n  "success": true,\n  "obj": {\n    "a1b2-...": {\n      "user1": [\n        { "ip": "1.2.3.4", "timestamp": 1700000000 }\n      ]\n    }\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/activeInbounds',
@@ -790,6 +796,18 @@ export const sections: readonly Section[] = [
         responseSchema: 'Node',
         responseSchemaArray: true,
       },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/mtls/ca',
+        summary: "This panel's node-auth CA certificate (public, PEM) to paste into a node's mTLS trust setting. Lazily mints the CA and the master client cert on first call. Pair with setting tlsVerifyMode=mtls on the node.",
+        response: '{\n  "success": true,\n  "obj": {\n    "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n  }\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/nodes/mtls/trustCA',
+        summary: "Set the CA certificate this panel trusts for incoming node-API client certificates (this panel acting as a node). Paste the managing panel's CA (from nodes/mtls/ca). An empty caCert disables it. A non-empty value must be a PEM certificate. Applied on the next panel restart.",
+        body: '{\n  "caCert": "-----BEGIN CERTIFICATE-----\\n...\\n-----END CERTIFICATE-----\\n"\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/nodes/get/:id',

+ 10 - 0
frontend/src/pages/nodes/NodeFormModal.tsx

@@ -339,6 +339,7 @@ export default function NodeFormModal({
                 { value: 'verify', label: t('pages.nodes.tlsVerify') },
                 { value: 'pin', label: t('pages.nodes.tlsPin') },
                 { value: 'skip', label: t('pages.nodes.tlsSkip') },
+                { value: 'mtls', label: t('pages.nodes.tlsMtls') },
               ]}
             />
           </Form.Item>
@@ -352,6 +353,15 @@ export default function NodeFormModal({
             />
           )}
 
+          {tlsVerifyMode === 'mtls' && (
+            <Alert
+              type="info"
+              showIcon
+              style={{ marginBottom: 16 }}
+              title={t('pages.nodes.mtlsFormHint')}
+            />
+          )}
+
           {tlsVerifyMode === 'pin' && (
             <Form.Item
               label={t('pages.nodes.pinnedCert')}

+ 51 - 3
frontend/src/pages/nodes/NodeHistoryPanel.tsx

@@ -31,6 +31,10 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
   const [cpuLabels, setCpuLabels] = useState<string[]>([]);
   const [memPoints, setMemPoints] = useState<number[]>([]);
   const [memLabels, setMemLabels] = useState<string[]>([]);
+  const [netUpPoints, setNetUpPoints] = useState<number[]>([]);
+  const [netUpLabels, setNetUpLabels] = useState<string[]>([]);
+  const [netDownPoints, setNetDownPoints] = useState<number[]>([]);
+  const [netDownLabels, setNetDownLabels] = useState<string[]>([]);
 
   const lastNodeId = useRef<number>(node.id);
 
@@ -46,7 +50,9 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
       return `${hh}:${mm}:${ss}`;
     };
 
-    const fetchSeries = async (metric: 'cpu' | 'mem') => {
+    // cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
+    // as KB/s (no upper clamp, the sparkline auto-scales).
+    const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
       try {
         const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
         const msg = await HttpUtil.get(url) as ApiMsg<SeriesPoint[]>;
@@ -55,7 +61,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
           const labs: string[] = [];
           for (const p of msg.obj) {
             labs.push(bucketLabel(p.t));
-            vals.push(Math.max(0, Math.min(100, Number(p.v) || 0)));
+            const n = Number(p.v) || 0;
+            vals.push(kind === 'pct' ? Math.max(0, Math.min(100, n)) : Math.max(0, n / 1024));
           }
           return { vals, labs };
         }
@@ -66,12 +73,21 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
     };
 
     const refresh = async () => {
-      const [cpu, mem] = await Promise.all([fetchSeries('cpu'), fetchSeries('mem')]);
+      const [cpu, mem, netUp, netDown] = await Promise.all([
+        fetchSeries('cpu', 'pct'),
+        fetchSeries('mem', 'pct'),
+        fetchSeries('netUp', 'rate'),
+        fetchSeries('netDown', 'rate'),
+      ]);
       if (cancelled) return;
       setCpuPoints(cpu.vals);
       setCpuLabels(cpu.labs);
       setMemPoints(mem.vals);
       setMemLabels(mem.labs);
+      setNetUpPoints(netUp.vals);
+      setNetUpLabels(netUp.labs);
+      setNetDownPoints(netDown.vals);
+      setNetDownLabels(netDown.labs);
     };
 
     refresh();
@@ -118,6 +134,38 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
           showTooltip
         />
       </div>
+      <div className="series">
+        <div className="series-title">{t('pages.nodes.netUp')}</div>
+        <Sparkline
+          data={netUpPoints}
+          labels={netUpLabels}
+          height={120}
+          stroke="#1677ff"
+          showGrid
+          showAxes
+          tickCountX={4}
+          maxPoints={netUpPoints.length || 1}
+          fillOpacity={0.18}
+          markerRadius={2.6}
+          showTooltip
+        />
+      </div>
+      <div className="series">
+        <div className="series-title">{t('pages.nodes.netDown')}</div>
+        <Sparkline
+          data={netDownPoints}
+          labels={netDownLabels}
+          height={120}
+          stroke="#fa8c16"
+          showGrid
+          showAxes
+          tickCountX={4}
+          maxPoints={netDownPoints.length || 1}
+          fillOpacity={0.18}
+          markerRadius={2.6}
+          showTooltip
+        />
+      </div>
     </div>
   );
 }

+ 6 - 0
frontend/src/pages/nodes/NodeList.tsx

@@ -27,6 +27,7 @@ import {
   MoreOutlined,
   PlusOutlined,
   RightOutlined,
+  SafetyCertificateOutlined,
   ThunderboltOutlined,
 } from '@ant-design/icons';
 
@@ -43,6 +44,7 @@ interface NodeListProps {
   selectedIds: number[];
   onSelectionChange: (ids: number[]) => void;
   onAdd: () => void;
+  onMtls: () => void;
   onEdit: (node: NodeRecord) => void;
   onDelete: (node: NodeRecord) => void;
   onProbe: (node: NodeRecord) => void;
@@ -163,6 +165,7 @@ export default function NodeList({
   selectedIds,
   onSelectionChange,
   onAdd,
+  onMtls,
   onEdit,
   onDelete,
   onProbe,
@@ -417,6 +420,9 @@ export default function NodeList({
         <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
           {t('pages.nodes.addNode')}
         </Button>
+        <Button icon={<SafetyCertificateOutlined />} onClick={onMtls}>
+          {t('pages.nodes.mtls.title')}
+        </Button>
         {selectedIds.length > 0 && (
           <Button icon={<CloudDownloadOutlined />} onClick={onUpdateSelected}>
             {t('pages.nodes.updateSelected', { count: selectedIds.length })}

+ 73 - 1
frontend/src/pages/nodes/NodesPage.tsx

@@ -1,7 +1,7 @@
 import { useCallback, useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { Button, Card, Col, ConfigProvider, Layout, Modal, Result, Row, Spin, Statistic, message } from 'antd';
+import { Button, Card, Col, ConfigProvider, Input, Layout, Modal, Result, Row, Spin, Statistic, Typography, message } from 'antd';
 import {
   CheckCircleOutlined,
   CloseCircleOutlined,
@@ -45,6 +45,45 @@ export default function NodesPage() {
   const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
   const [formNode, setFormNode] = useState<NodeRecord | null>(null);
   const [selectedIds, setSelectedIds] = useState<number[]>([]);
+  const [mtlsOpen, setMtlsOpen] = useState(false);
+  const [trustCa, setTrustCa] = useState('');
+  const [copyingCa, setCopyingCa] = useState(false);
+  const [savingTrustCa, setSavingTrustCa] = useState(false);
+
+  const onCopyNodeCa = useCallback(async () => {
+    setCopyingCa(true);
+    try {
+      const msg = await HttpUtil.post<{ caCert: string }>('/panel/api/nodes/mtls/ca');
+      const ca = msg?.obj?.caCert;
+      if (msg?.success && ca) {
+        await navigator.clipboard.writeText(ca);
+        messageApi.success(t('pages.nodes.mtls.caCopied'));
+      } else {
+        messageApi.error(msg?.msg || t('pages.nodes.mtls.caFailed'));
+      }
+    } catch {
+      messageApi.error(t('pages.nodes.mtls.caFailed'));
+    } finally {
+      setCopyingCa(false);
+    }
+  }, [messageApi, t]);
+
+  const onSaveTrustCa = useCallback(async () => {
+    setSavingTrustCa(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/nodes/mtls/trustCA', { caCert: trustCa });
+      if (msg?.success) {
+        messageApi.success(t('pages.nodes.mtls.saved'));
+        setMtlsOpen(false);
+      } else {
+        messageApi.error(msg?.msg || t('somethingWentWrong'));
+      }
+    } catch {
+      messageApi.error(t('somethingWentWrong'));
+    } finally {
+      setSavingTrustCa(false);
+    }
+  }, [trustCa, messageApi, t]);
 
   const onAdd = useCallback(() => {
     setFormMode('add');
@@ -215,6 +254,7 @@ export default function NodesPage() {
                       selectedIds={selectedIds}
                       onSelectionChange={setSelectedIds}
                       onAdd={onAdd}
+                      onMtls={() => setMtlsOpen(true)}
                       onEdit={onEdit}
                       onDelete={onDelete}
                       onProbe={onProbe}
@@ -239,6 +279,38 @@ export default function NodesPage() {
           save={onSave}
           onOpenChange={setFormOpen}
         />
+
+        <Modal
+          open={mtlsOpen}
+          title={t('pages.nodes.mtls.title')}
+          footer={null}
+          onCancel={() => setMtlsOpen(false)}
+          destroyOnHidden
+        >
+          <Typography.Paragraph type="secondary" style={{ marginTop: 0 }}>
+            {t('pages.nodes.mtls.intro')}
+          </Typography.Paragraph>
+          <Button onClick={onCopyNodeCa} loading={copyingCa} style={{ marginBottom: 4 }}>
+            {t('pages.nodes.mtls.copyCa')}
+          </Button>
+          <Typography.Paragraph type="secondary">
+            {t('pages.nodes.mtls.copyCaHint')}
+          </Typography.Paragraph>
+          <Typography.Text strong>{t('pages.nodes.mtls.trustLabel')}</Typography.Text>
+          <Input.TextArea
+            rows={5}
+            value={trustCa}
+            onChange={(e) => setTrustCa(e.target.value)}
+            placeholder={t('pages.nodes.mtls.trustPlaceholder')}
+            style={{ marginTop: 4, fontFamily: 'monospace' }}
+          />
+          <Typography.Paragraph type="secondary" style={{ marginTop: 4 }}>
+            {t('pages.nodes.mtls.trustHint')}
+          </Typography.Paragraph>
+          <Button type="primary" onClick={onSaveTrustCa} loading={savingTrustCa} block>
+            {t('pages.nodes.mtls.save')}
+          </Button>
+        </Modal>
       </Layout>
     </ConfigProvider>
   );

+ 13 - 3
frontend/src/schemas/node.ts

@@ -29,7 +29,7 @@ export const NodeRecordSchema = z.object({
   xrayState: z.string().optional(),
   xrayError: z.string().optional(),
   allowPrivateAddress: z.boolean().optional(),
-  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']).optional(),
   pinnedCertSha256: z.string().optional(),
   inboundSyncMode: z.enum(['all', 'selected']).optional(),
   // Backend serializes a nil []string as null for nodes saved before #5178.
@@ -62,16 +62,26 @@ export const NodeFormSchema = z.object({
   address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
   port: z.number().int().min(1).max(65535),
   basePath: z.string(),
-  apiToken: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
+  // mTLS nodes authenticate via the client certificate, so the token is optional
+  // there; every other verify mode still requires one (matches remote.do()).
+  apiToken: z.string().trim(),
   enable: z.boolean(),
   allowPrivateAddress: z.boolean(),
-  tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
+  tlsVerifyMode: z.enum(['verify', 'skip', 'pin', 'mtls']),
   pinnedCertSha256: z.string().optional().default(''),
   inboundSyncMode: z.enum(['all', 'selected']).optional().default('all'),
   // Unmounted when sync mode is "all" (absent from antd onFinish values) and
   // serialized as null by the backend for a nil slice — tolerate both.
   inboundTags: z.array(z.string()).nullish().transform((tags) => tags ?? []),
   outboundTag: z.string().optional(),
+}).superRefine((val, ctx) => {
+  if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0) {
+    ctx.addIssue({
+      code: 'custom',
+      path: ['apiToken'],
+      message: 'pages.nodes.toasts.fillRequired',
+    });
+  }
 });
 
 export type NodeRecord = z.infer<typeof NodeRecordSchema>;

+ 1 - 1
go.mod

@@ -72,7 +72,7 @@ require (
 	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/juju/ratelimit v1.0.2 // indirect
-	github.com/klauspost/compress v1.18.6 // indirect
+	github.com/klauspost/compress v1.18.6
 	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
 	github.com/leodido/go-urn v1.4.0 // indirect
 	github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect

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

@@ -502,10 +502,10 @@ type Node struct {
 	Address             string   `json:"address" form:"address" validate:"required" example:"node1.example.com"`
 	Port                int      `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
 	BasePath            string   `json:"basePath" form:"basePath" example:"/"`
-	ApiToken            string   `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
+	ApiToken            string   `json:"apiToken" form:"apiToken" validate:"required_unless=TlsVerifyMode mtls" example:"abcdef0123456789"`
 	Enable              bool     `json:"enable" form:"enable" gorm:"default:true" example:"true"`
 	AllowPrivateAddress bool     `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
-	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
+	TlsVerifyMode       string   `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin mtls"`
 	PinnedCertSha256    string   `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
 	InboundSyncMode     string   `json:"inboundSyncMode" form:"inboundSyncMode" gorm:"column:inbound_sync_mode;default:all" validate:"omitempty,oneof=all selected"`
 	InboundTags         []string `json:"inboundTags" form:"inboundTags" gorm:"serializer:json;column:inbound_tags"`
@@ -529,6 +529,8 @@ type Node struct {
 	CpuPct        float64 `json:"cpuPct" example:"23.5"`
 	MemPct        float64 `json:"memPct" example:"45.1"`
 	UptimeSecs    uint64  `json:"uptimeSecs" example:"86400"`
+	NetUp         uint64  `json:"netUp" gorm:"column:net_up" example:"1048576"`
+	NetDown       uint64  `json:"netDown" gorm:"column:net_down" example:"2097152"`
 	LastError     string  `json:"lastError"`
 
 	// XrayState and XrayError are captured from the remote node's /panel/api/server/status

+ 122 - 0
internal/util/crypto/certs.go

@@ -0,0 +1,122 @@
+package crypto
+
+import (
+	"crypto"
+	"crypto/ecdsa"
+	"crypto/elliptic"
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/pem"
+	"errors"
+	"math/big"
+	"time"
+)
+
+// nodeCertValidity is how long the node-auth CA and the leaves it issues stay
+// valid. It is deliberately long: v1 has no rotation flow, so expiry would mean
+// a fleet-wide outage. Rotation is tracked as follow-up work.
+const nodeCertValidity = 10 * 365 * 24 * time.Hour
+
+// CertKeyPEM is a PEM-encoded certificate together with its private key.
+type CertKeyPEM struct {
+	CertPEM []byte
+	KeyPEM  []byte
+}
+
+func randomSerial() (*big.Int, error) {
+	return rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+}
+
+func marshalCertKey(certDER []byte, key *ecdsa.PrivateKey) (CertKeyPEM, error) {
+	keyDER, err := x509.MarshalECPrivateKey(key)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	return CertKeyPEM{
+		CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}),
+		KeyPEM:  pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}),
+	}, nil
+}
+
+// GenerateNodeCA mints a self-signed ECDSA P-256 CA used to authenticate node
+// API traffic. It can sign leaf certificates only (path length 0).
+func GenerateNodeCA(commonName string) (CertKeyPEM, error) {
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	serial, err := randomSerial()
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	now := time.Now()
+	tmpl := &x509.Certificate{
+		SerialNumber:          serial,
+		Subject:               pkix.Name{CommonName: commonName},
+		NotBefore:             now.Add(-time.Hour),
+		NotAfter:              now.Add(nodeCertValidity),
+		KeyUsage:              x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
+		BasicConstraintsValid: true,
+		IsCA:                  true,
+		MaxPathLenZero:        true,
+	}
+	der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	return marshalCertKey(der, key)
+}
+
+// IssueClientCert signs a client-auth leaf (ExtKeyUsageClientAuth) with the
+// given CA. The leaf authenticates the managing panel to a node.
+func IssueClientCert(ca CertKeyPEM, commonName string) (CertKeyPEM, error) {
+	caCert, caKey, err := LoadCAFromPEM(ca)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	serial, err := randomSerial()
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	now := time.Now()
+	tmpl := &x509.Certificate{
+		SerialNumber: serial,
+		Subject:      pkix.Name{CommonName: commonName},
+		NotBefore:    now.Add(-time.Hour),
+		NotAfter:     now.Add(nodeCertValidity),
+		KeyUsage:     x509.KeyUsageDigitalSignature,
+		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}
+	der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, key.Public(), caKey)
+	if err != nil {
+		return CertKeyPEM{}, err
+	}
+	return marshalCertKey(der, key)
+}
+
+// LoadCAFromPEM parses a CA cert+key pair into a certificate and its signer,
+// ready to issue or to populate a trust pool.
+func LoadCAFromPEM(ca CertKeyPEM) (*x509.Certificate, crypto.Signer, error) {
+	certBlock, _ := pem.Decode(ca.CertPEM)
+	if certBlock == nil || certBlock.Type != "CERTIFICATE" {
+		return nil, nil, errors.New("invalid CA certificate PEM")
+	}
+	cert, err := x509.ParseCertificate(certBlock.Bytes)
+	if err != nil {
+		return nil, nil, err
+	}
+	keyBlock, _ := pem.Decode(ca.KeyPEM)
+	if keyBlock == nil {
+		return nil, nil, errors.New("invalid CA key PEM")
+	}
+	key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
+	if err != nil {
+		return nil, nil, err
+	}
+	return cert, key, nil
+}

+ 69 - 0
internal/util/crypto/certs_test.go

@@ -0,0 +1,69 @@
+package crypto
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+)
+
+// parseOneCert decodes a single CERTIFICATE PEM block.
+func parseOneCert(t *testing.T, pemBytes []byte) *x509.Certificate {
+	t.Helper()
+	block, _ := pem.Decode(pemBytes)
+	if block == nil || block.Type != "CERTIFICATE" {
+		t.Fatalf("expected a CERTIFICATE PEM block, got %+v", block)
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		t.Fatalf("ParseCertificate: %v", err)
+	}
+	return cert
+}
+
+func TestGenerateNodeCA(t *testing.T) {
+	ca, err := GenerateNodeCA("3x-ui node CA")
+	if err != nil {
+		t.Fatalf("GenerateNodeCA: %v", err)
+	}
+	cert := parseOneCert(t, ca.CertPEM)
+	if !cert.IsCA {
+		t.Fatal("CA certificate must have IsCA=true")
+	}
+	if cert.KeyUsage&x509.KeyUsageCertSign == 0 {
+		t.Fatal("CA certificate must allow KeyUsageCertSign")
+	}
+	if _, _, err := LoadCAFromPEM(ca); err != nil {
+		t.Fatalf("LoadCAFromPEM on a freshly generated CA: %v", err)
+	}
+}
+
+func TestIssueClientCert_VerifiesAgainstCA(t *testing.T) {
+	ca, err := GenerateNodeCA("3x-ui node CA")
+	if err != nil {
+		t.Fatalf("GenerateNodeCA: %v", err)
+	}
+	leaf, err := IssueClientCert(ca, "central-panel")
+	if err != nil {
+		t.Fatalf("IssueClientCert: %v", err)
+	}
+	cert := parseOneCert(t, leaf.CertPEM)
+
+	hasClientAuth := false
+	for _, u := range cert.ExtKeyUsage {
+		if u == x509.ExtKeyUsageClientAuth {
+			hasClientAuth = true
+		}
+	}
+	if !hasClientAuth {
+		t.Fatal("client leaf must carry ExtKeyUsageClientAuth")
+	}
+
+	roots := x509.NewCertPool()
+	roots.AddCert(parseOneCert(t, ca.CertPEM))
+	if _, err := cert.Verify(x509.VerifyOptions{
+		Roots:     roots,
+		KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}); err != nil {
+		t.Fatalf("client leaf must verify against the issuing CA: %v", err)
+	}
+}

+ 57 - 0
internal/util/wirecodec/wirecodec.go

@@ -0,0 +1,57 @@
+// Package wirecodec holds the shared envelope codec for node-to-node config
+// transport: zstd (de)compression, SHA-256 integrity hashing, and the header /
+// capability constants both the panel (sender) and node (receiver) agree on.
+package wirecodec
+
+import (
+	"crypto/sha256"
+	"encoding/hex"
+	"errors"
+
+	"github.com/klauspost/compress/zstd"
+)
+
+const (
+	// HashHeader carries the lowercase-hex SHA-256 of the (uncompressed) body.
+	HashHeader = "X-Config-Sha256"
+	// CapsHeader is set by a node on its API responses to advertise support.
+	CapsHeader = "X-3x-Node-Caps"
+	// EncodingZstd is the Content-Encoding value for a zstd-compressed body.
+	EncodingZstd = "zstd"
+	// CapZstd is the capability token advertised in CapsHeader.
+	CapZstd = "zstd"
+
+	// maxDecodeBytes bounds in-memory decompression to defuse a zstd bomb from
+	// an (authenticated) node-API caller.
+	maxDecodeBytes = 16 << 20
+)
+
+// EncodeAll/DecodeAll on these shared instances are safe for concurrent use.
+var (
+	zstdEncoder, _ = zstd.NewWriter(nil)
+	zstdDecoder, _ = zstd.NewReader(nil, zstd.WithDecoderMaxMemory(maxDecodeBytes))
+)
+
+// Compress zstd-compresses b.
+func Compress(b []byte) []byte {
+	return zstdEncoder.EncodeAll(b, nil)
+}
+
+// Decompress zstd-decompresses src, rejecting output larger than maxOut (and any
+// input that would blow the in-memory bomb ceiling).
+func Decompress(src []byte, maxOut int) ([]byte, error) {
+	out, err := zstdDecoder.DecodeAll(src, nil)
+	if err != nil {
+		return nil, err
+	}
+	if maxOut > 0 && len(out) > maxOut {
+		return nil, errors.New("wirecodec: decompressed body exceeds limit")
+	}
+	return out, nil
+}
+
+// Sha256Hex returns the lowercase-hex SHA-256 of b.
+func Sha256Hex(b []byte) string {
+	sum := sha256.Sum256(b)
+	return hex.EncodeToString(sum[:])
+}

+ 51 - 0
internal/util/wirecodec/wirecodec_test.go

@@ -0,0 +1,51 @@
+package wirecodec
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+)
+
+func TestCompressRoundTrip(t *testing.T) {
+	orig := []byte(strings.Repeat("inbound config payload ", 200))
+	packed := Compress(orig)
+	if len(packed) == 0 {
+		t.Fatal("Compress returned empty output")
+	}
+	got, err := Decompress(packed, 1<<20)
+	if err != nil {
+		t.Fatalf("Decompress: %v", err)
+	}
+	if !bytes.Equal(got, orig) {
+		t.Fatal("round trip mismatch")
+	}
+}
+
+func TestDecompressRejectsOversize(t *testing.T) {
+	orig := bytes.Repeat([]byte("A"), 1<<16) // 64 KiB, highly compressible
+	packed := Compress(orig)
+	if _, err := Decompress(packed, 1024); err == nil {
+		t.Fatal("Decompress must reject output that exceeds the cap (bomb guard)")
+	}
+}
+
+func TestDecompressRejectsGarbage(t *testing.T) {
+	if _, err := Decompress([]byte("not a zstd frame"), 1<<20); err == nil {
+		t.Fatal("Decompress must reject non-zstd input")
+	}
+}
+
+func TestSha256HexStableAndSensitive(t *testing.T) {
+	a := Sha256Hex([]byte("config-A"))
+	b := Sha256Hex([]byte("config-A"))
+	c := Sha256Hex([]byte("config-B"))
+	if a != b {
+		t.Fatal("hash must be stable for identical input")
+	}
+	if a == c {
+		t.Fatal("hash must differ when the body changes")
+	}
+	if len(a) != 64 {
+		t.Fatalf("expected 64 hex chars, got %d", len(a))
+	}
+}

+ 14 - 0
internal/web/controller/api.go

@@ -35,6 +35,17 @@ func NewAPIController(g *gin.RouterGroup) *APIController {
 }
 
 func (a *APIController) checkAPIAuth(c *gin.Context) {
+	// A verified client certificate (a completed mTLS handshake) authenticates
+	// the caller, equivalent to a valid bearer token. api_authed must be set so
+	// the CSRF middleware lets cert-authed mutations through.
+	if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 {
+		if u, err := a.userService.GetFirstUser(); err == nil {
+			session.SetAPIAuthUser(c, u)
+		}
+		c.Set("api_authed", true)
+		c.Next()
+		return
+	}
 	auth := c.GetHeader("Authorization")
 	if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
 		tok := after
@@ -63,6 +74,9 @@ func (a *APIController) initRouter(g *gin.RouterGroup) {
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
+	// Decode + verify the node config envelope (zstd + X-Config-Sha256) and
+	// advertise support, before CSRF/handlers read the body.
+	api.Use(middleware.ConfigEnvelopeMiddleware())
 	api.Use(middleware.CSRFMiddleware())
 
 	// Inbounds API

+ 203 - 0
internal/web/controller/api_auth_test.go

@@ -0,0 +1,203 @@
+package controller
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"net/http"
+	"net/http/cookiejar"
+	"net/http/httptest"
+	"path/filepath"
+	"testing"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-contrib/sessions/cookie"
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
+)
+
+// newAPIAuthTestEngine builds a gin engine that mirrors the production auth
+// wiring: the sessions middleware, then checkAPIAuth guarding a sentinel
+// handler that reports whether c.Next() was reached and whether api_authed was
+// set. The APIController is the zero value, exactly as NewAPIController leaves
+// its service fields (they query the global DB), so this exercises the real
+// auth path. A fresh temp DB is initialised per test.
+func newAPIAuthTestEngine(t *testing.T) (*gin.Engine, *APIController) {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	engine := gin.New()
+	store := cookie.NewStore([]byte("api-auth-test-secret"))
+	engine.Use(sessions.Sessions("3x-ui", store))
+
+	a := &APIController{}
+
+	// Logs in as the first user so the session path can be exercised over a
+	// cookie round-trip without reaching into checkAPIAuth's internals.
+	engine.GET("/test-login", func(c *gin.Context) {
+		u, err := a.userService.GetFirstUser()
+		if err != nil {
+			c.Status(http.StatusInternalServerError)
+			return
+		}
+		if err := session.SetLoginUser(c, u); err != nil {
+			c.Status(http.StatusInternalServerError)
+			return
+		}
+		c.Status(http.StatusOK)
+	})
+
+	api := engine.Group("/panel/api")
+	api.Use(a.checkAPIAuth)
+	api.GET("/ping", func(c *gin.Context) {
+		c.JSON(http.StatusOK, gin.H{"api_authed": c.GetBool("api_authed")})
+	})
+	return engine, a
+}
+
+// TestCheckAPIAuth_BearerSuccess characterizes the bearer-token path: a valid
+// token reaches the handler and sets api_authed (the contract the later
+// client-cert branch must match).
+func TestCheckAPIAuth_BearerSuccess(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	const plaintext = "characterization-token-value"
+	if err := database.GetDB().Create(&model.ApiToken{
+		Name:    "t1",
+		Token:   crypto.HashTokenSHA256(plaintext),
+		Enabled: true,
+	}).Error; err != nil {
+		t.Fatalf("seed token: %v", err)
+	}
+
+	req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
+	req.Header.Set("Authorization", "Bearer "+plaintext)
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if got := w.Body.String(); got != `{"api_authed":true}` {
+		t.Fatalf("body = %s, want api_authed true", got)
+	}
+}
+
+// TestCheckAPIAuth_AcceptsVerifiedClientCert asserts that a completed mTLS
+// handshake (a non-empty verified client chain) authenticates the request even
+// with no bearer token and no session — the equivalent of a valid token — and
+// sets api_authed so the CSRF middleware lets mutations through.
+func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
+	req.TLS = &tls.ConnectionState{
+		VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}},
+	}
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String())
+	}
+	if got := w.Body.String(); got != `{"api_authed":true}` {
+		t.Fatalf("body = %s, want api_authed true", got)
+	}
+}
+
+// TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no
+// verified client chain is NOT treated as authenticated (it falls through to the
+// bearer/session paths) — so the cert branch can't accidentally authorize plain
+// browser HTTPS.
+func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
+	req.TLS = &tls.ConnectionState{} // handshake done, but no client cert verified
+	req.Header.Set("X-Requested-With", "XMLHttpRequest")
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusUnauthorized {
+		t.Fatalf("status = %d, want 401 (unauthenticated, no verified chain)", w.Code)
+	}
+}
+
+// TestCheckAPIAuth_RejectsUnauthenticated characterizes the reject paths: no
+// bearer token and no session yields 401 for XHR callers and 404 otherwise.
+func TestCheckAPIAuth_RejectsUnauthenticated(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	cases := []struct {
+		name string
+		xhr  bool
+		want int
+	}{
+		{"xhr gets 401", true, http.StatusUnauthorized},
+		{"non-xhr gets 404", false, http.StatusNotFound},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			req := httptest.NewRequest(http.MethodGet, "/panel/api/ping", nil)
+			if c.xhr {
+				req.Header.Set("X-Requested-With", "XMLHttpRequest")
+			}
+			w := httptest.NewRecorder()
+			engine.ServeHTTP(w, req)
+			if w.Code != c.want {
+				t.Fatalf("status = %d, want %d", w.Code, c.want)
+			}
+		})
+	}
+}
+
+// TestCheckAPIAuth_SessionLoginPasses characterizes the session path: a
+// logged-in browser session (no bearer token) reaches the handler.
+func TestCheckAPIAuth_SessionLoginPasses(t *testing.T) {
+	engine, _ := newAPIAuthTestEngine(t)
+
+	db := database.GetDB()
+	var n int64
+	if err := db.Model(&model.User{}).Count(&n).Error; err != nil {
+		t.Fatalf("count users: %v", err)
+	}
+	if n == 0 {
+		if err := db.Create(&model.User{Username: "sess", Password: "x"}).Error; err != nil {
+			t.Fatalf("seed user: %v", err)
+		}
+	}
+
+	ts := httptest.NewServer(engine)
+	defer ts.Close()
+	jar, err := cookiejar.New(nil)
+	if err != nil {
+		t.Fatalf("cookiejar: %v", err)
+	}
+	client := &http.Client{Jar: jar}
+
+	loginResp, err := client.Get(ts.URL + "/test-login")
+	if err != nil {
+		t.Fatalf("login: %v", err)
+	}
+	loginResp.Body.Close()
+	if loginResp.StatusCode != http.StatusOK {
+		t.Fatalf("login status = %d, want 200", loginResp.StatusCode)
+	}
+
+	pingResp, err := client.Get(ts.URL + "/panel/api/ping")
+	if err != nil {
+		t.Fatalf("ping: %v", err)
+	}
+	pingResp.Body.Close()
+	if pingResp.StatusCode != http.StatusOK {
+		t.Fatalf("session ping status = %d, want 200", pingResp.StatusCode)
+	}
+}

+ 32 - 0
internal/web/controller/node.go

@@ -43,6 +43,38 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
 	g.POST("/probe/:id", a.probe)
 	g.POST("/updatePanel", a.updatePanel)
 	g.GET("/history/:id/:metric/:bucket", a.history)
+	g.POST("/mtls/ca", a.mtlsCa)
+	g.POST("/mtls/trustCA", a.setMtlsTrustCA)
+}
+
+// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a
+// node's mTLS trust setting. It lazily mints the CA + master client cert on
+// first call.
+func (a *NodeController) mtlsCa(c *gin.Context) {
+	caCert, err := a.nodeService.NodeMtlsCaCert()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
+		return
+	}
+	jsonObj(c, gin.H{"caCert": caCert}, nil)
+}
+
+// setMtlsTrustCA stores the CA this panel trusts for incoming node-API client
+// certificates (this panel acting as a node). An empty value disables it.
+// Applied on the next panel restart.
+func (a *NodeController) setMtlsTrustCA(c *gin.Context) {
+	var req struct {
+		CaCert string `json:"caCert" form:"caCert"`
+	}
+	if err := c.ShouldBind(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
+		return
+	}
+	if err := a.nodeService.SetNodeMtlsTrustCA(req.CaCert); err != nil {
+		jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err)
+		return
+	}
+	jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), nil)
 }
 
 func (a *NodeController) list(c *gin.Context) {

+ 71 - 0
internal/web/middleware/config_envelope.go

@@ -0,0 +1,71 @@
+package middleware
+
+import (
+	"bytes"
+	"crypto/subtle"
+	"io"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
+)
+
+// maxDecodedConfigBytes caps a decompressed request body (defense in depth on
+// top of wirecodec's own ceiling).
+const maxDecodedConfigBytes = 8 << 20
+
+// ConfigEnvelopeMiddleware advertises node envelope support on every response
+// and, for requests that opt into the envelope, decompresses (zstd) and verifies
+// the X-Config-Sha256 integrity hash before the body reaches the handler. A
+// request carrying neither envelope header passes through untouched, so old
+// panels and plain calls keep working (mixed-version safe).
+func ConfigEnvelopeMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		c.Header(wirecodec.CapsHeader, wirecodec.CapZstd)
+
+		enc := c.GetHeader("Content-Encoding")
+		sum := c.GetHeader(wirecodec.HashHeader)
+		if enc != wirecodec.EncodingZstd && sum == "" {
+			c.Next()
+			return
+		}
+
+		// On the envelope path, zstd is the only encoding we understand. Reject any
+		// other Content-Encoding rather than hashing/forwarding a still-encoded body
+		// the downstream handler can't read.
+		if enc != "" && enc != wirecodec.EncodingZstd {
+			c.AbortWithStatus(http.StatusUnsupportedMediaType)
+			return
+		}
+
+		raw, err := io.ReadAll(c.Request.Body)
+		if err != nil {
+			c.AbortWithStatus(http.StatusBadRequest)
+			return
+		}
+		_ = c.Request.Body.Close()
+
+		if enc == wirecodec.EncodingZstd {
+			decoded, derr := wirecodec.Decompress(raw, maxDecodedConfigBytes)
+			if derr != nil {
+				c.AbortWithStatus(http.StatusBadRequest)
+				return
+			}
+			raw = decoded
+			c.Request.Header.Del("Content-Encoding")
+		}
+
+		if sum != "" {
+			got := wirecodec.Sha256Hex(raw)
+			if subtle.ConstantTimeCompare([]byte(got), []byte(sum)) != 1 {
+				c.AbortWithStatus(http.StatusBadRequest)
+				return
+			}
+		}
+
+		c.Request.Body = io.NopCloser(bytes.NewReader(raw))
+		c.Request.ContentLength = int64(len(raw))
+		c.Next()
+	}
+}

+ 82 - 0
internal/web/middleware/config_envelope_test.go

@@ -0,0 +1,82 @@
+package middleware
+
+import (
+	"bytes"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/gin-gonic/gin"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
+)
+
+func envelopeTestEngine(t *testing.T, onHandler func()) *gin.Engine {
+	t.Helper()
+	gin.SetMode(gin.TestMode)
+	engine := gin.New()
+	engine.Use(ConfigEnvelopeMiddleware())
+	engine.POST("/echo", func(c *gin.Context) {
+		if onHandler != nil {
+			onHandler()
+		}
+		b, _ := io.ReadAll(c.Request.Body)
+		c.String(http.StatusOK, "%s", string(b))
+	})
+	return engine
+}
+
+func TestConfigEnvelope_DecompressesAndVerifies(t *testing.T) {
+	engine := envelopeTestEngine(t, nil)
+
+	orig := []byte(strings.Repeat("payload-", 200))
+	packed := wirecodec.Compress(orig)
+	req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(packed))
+	req.Header.Set("Content-Encoding", wirecodec.EncodingZstd)
+	req.Header.Set(wirecodec.HashHeader, wirecodec.Sha256Hex(orig))
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK {
+		t.Fatalf("status = %d, want 200", w.Code)
+	}
+	if w.Body.String() != string(orig) {
+		t.Fatal("handler did not receive the decompressed body")
+	}
+	if !strings.Contains(w.Header().Get(wirecodec.CapsHeader), wirecodec.CapZstd) {
+		t.Fatal("response must advertise the zstd capability")
+	}
+}
+
+func TestConfigEnvelope_RejectsHashMismatch(t *testing.T) {
+	called := false
+	engine := envelopeTestEngine(t, func() { called = true })
+
+	body := []byte("the-real-config")
+	req := httptest.NewRequest(http.MethodPost, "/echo", bytes.NewReader(body))
+	// Header claims a hash of a *different* body — a tampered/corrupted push.
+	req.Header.Set(wirecodec.HashHeader, wirecodec.Sha256Hex([]byte("a-different-config")))
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusBadRequest {
+		t.Fatalf("a hash mismatch must be rejected 4xx, got %d", w.Code)
+	}
+	if called {
+		t.Fatal("the handler must NOT be invoked on a hash mismatch")
+	}
+}
+
+func TestConfigEnvelope_PlainPassesThrough(t *testing.T) {
+	engine := envelopeTestEngine(t, nil)
+
+	req := httptest.NewRequest(http.MethodPost, "/echo", strings.NewReader("plain=body"))
+	w := httptest.NewRecorder()
+	engine.ServeHTTP(w, req)
+
+	if w.Code != http.StatusOK || w.Body.String() != "plain=body" {
+		t.Fatalf("a request with no envelope headers must pass through unchanged: code=%d body=%q", w.Code, w.Body.String())
+	}
+}

+ 59 - 5
internal/web/runtime/remote.go

@@ -18,11 +18,16 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
 const remoteHTTPTimeout = 10 * time.Second
 
+// zstdMinBodyBytes is the smallest body worth compressing; below it the framing
+// overhead can outweigh the savings.
+const zstdMinBodyBytes = 1024
+
 type envelope struct {
 	Success bool            `json:"success"`
 	Msg     string          `json:"msg"`
@@ -34,6 +39,10 @@ type Remote struct {
 
 	mu            sync.RWMutex
 	remoteIDByTag map[string]int
+	// supportsZstd is learned from the node's X-3x-Node-Caps response header; once
+	// seen, config pushes to this node are zstd-compressed. Old nodes never set
+	// it, so they keep receiving plain bodies (mixed-version safe).
+	supportsZstd bool
 
 	// Per-node client honoring the TLS verify mode, built once and reused; a
 	// node config change drops the cached Remote so the next one rebuilds it.
@@ -61,6 +70,23 @@ func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 
 func (r *Remote) Name() string { return "node:" + r.node.Name }
 
+func (r *Remote) nodeSupportsZstd() bool {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	return r.supportsZstd
+}
+
+// recordCaps learns the node's capabilities from a response header so later
+// pushes can use the negotiated envelope.
+func (r *Remote) recordCaps(h http.Header) {
+	if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) {
+		return
+	}
+	r.mu.Lock()
+	r.supportsZstd = true
+	r.mu.Unlock()
+}
+
 // httpClient lazily builds and caches the per-node client honoring the TLS
 // verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264).
 func (r *Remote) httpClient() (*http.Client, error) {
@@ -99,7 +125,9 @@ func (r *Remote) baseURL() (string, error) {
 }
 
 func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) {
-	if r.node.ApiToken == "" {
+	// mtls nodes authenticate via the client certificate, so a bearer token is
+	// optional for them; every other mode still requires one.
+	if r.node.ApiToken == "" && r.node.TlsVerifyMode != "mtls" {
 		return nil, errors.New("node has no API token configured")
 	}
 
@@ -110,34 +138,59 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 	target := base + strings.TrimPrefix(path, "/")
 
 	var (
-		reqBody     io.Reader
+		bodyBytes   []byte
 		contentType string
 	)
 	switch b := body.(type) {
 	case nil:
 	case url.Values:
-		reqBody = strings.NewReader(b.Encode())
+		bodyBytes = []byte(b.Encode())
 		contentType = "application/x-www-form-urlencoded"
 	default:
 		buf, jerr := json.Marshal(b)
 		if jerr != nil {
 			return nil, fmt.Errorf("marshal body: %w", jerr)
 		}
-		reqBody = bytes.NewReader(buf)
+		bodyBytes = buf
 		contentType = "application/json"
 	}
 
+	// Attach the integrity hash of the uncompressed body unconditionally (a new
+	// node verifies it, an old one ignores it), and zstd-compress only when the
+	// node advertised support and the body is worth it.
+	var (
+		reqBody     io.Reader
+		hashHex     string
+		zstdEncoded bool
+	)
+	if bodyBytes != nil {
+		hashHex = wirecodec.Sha256Hex(bodyBytes)
+		if len(bodyBytes) >= zstdMinBodyBytes && r.nodeSupportsZstd() {
+			bodyBytes = wirecodec.Compress(bodyBytes)
+			zstdEncoded = true
+		}
+		reqBody = bytes.NewReader(bodyBytes)
+	}
+
 	cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout)
 	defer cancel()
 	req, err := http.NewRequestWithContext(cctx, method, target, reqBody)
 	if err != nil {
 		return nil, err
 	}
-	req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
+	if r.node.ApiToken != "" {
+		req.Header.Set("Authorization", "Bearer "+r.node.ApiToken)
+	}
 	req.Header.Set("Accept", "application/json")
 	if contentType != "" {
 		req.Header.Set("Content-Type", contentType)
 	}
+	if hashHex != "" {
+		req.Header.Set(wirecodec.HashHeader, hashHex)
+	}
+	if zstdEncoded {
+		req.Header.Set("Content-Encoding", wirecodec.EncodingZstd)
+	}
 
 	client, err := r.httpClient()
 	if err != nil {
@@ -148,6 +201,7 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo
 		return nil, fmt.Errorf("%s %s: %w", method, path, err)
 	}
 	defer resp.Body.Close()
+	r.recordCaps(resp.Header)
 
 	raw, err := io.ReadAll(resp.Body)
 	if err != nil {

+ 92 - 0
internal/web/runtime/remote_envelope_test.go

@@ -0,0 +1,92 @@
+package runtime
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec"
+)
+
+// TestRemoteSendsEnvelopeWhenNodeAdvertisesCap: once a node has advertised the
+// zstd capability (via a response header on any prior call), a large push is
+// sent zstd-compressed with an X-Config-Sha256 of the *uncompressed* body.
+func TestRemoteSendsEnvelopeWhenNodeAdvertisesCap(t *testing.T) {
+	var capturedEnc, capturedHash string
+	var capturedBody []byte
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set(wirecodec.CapsHeader, wirecodec.CapZstd) // advertise on every response
+		if r.Method == http.MethodPost {
+			capturedEnc = r.Header.Get("Content-Encoding")
+			capturedHash = r.Header.Get(wirecodec.HashHeader)
+			capturedBody, _ = io.ReadAll(r.Body)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+
+	// Prime: a prior call learns the cap from the response header.
+	if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
+		t.Fatalf("prime call: %v", err)
+	}
+
+	body := url.Values{}
+	body.Set("settings", strings.Repeat("x", 4096))
+	if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
+		t.Fatalf("push: %v", err)
+	}
+
+	if capturedEnc != wirecodec.EncodingZstd {
+		t.Fatalf("Content-Encoding = %q, want %q", capturedEnc, wirecodec.EncodingZstd)
+	}
+	if len(capturedHash) != 64 {
+		t.Fatalf("missing/short X-Config-Sha256: %q", capturedHash)
+	}
+	raw, err := wirecodec.Decompress(capturedBody, 1<<20)
+	if err != nil {
+		t.Fatalf("server could not decompress the body: %v", err)
+	}
+	if string(raw) != body.Encode() {
+		t.Fatalf("decompressed body mismatch: %q != %q", string(raw), body.Encode())
+	}
+	if wirecodec.Sha256Hex(raw) != capturedHash {
+		t.Fatal("X-Config-Sha256 does not match the decompressed body")
+	}
+}
+
+// TestRemoteSendsPlainWhenNoCap: a node that never advertises the cap (old
+// build) receives a plain body — but the integrity hash is still attached
+// (harmless to old nodes, verified by new ones).
+func TestRemoteSendsPlainWhenNoCap(t *testing.T) {
+	var capturedEnc, capturedHash string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == http.MethodPost {
+			capturedEnc = r.Header.Get("Content-Encoding")
+			capturedHash = r.Header.Get(wirecodec.HashHeader)
+		}
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer srv.Close()
+
+	r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil)
+	body := url.Values{}
+	body.Set("settings", strings.Repeat("x", 4096))
+	if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil {
+		t.Fatalf("push: %v", err)
+	}
+
+	if capturedEnc != "" {
+		t.Fatalf("a no-cap node must receive a plain body, got Content-Encoding=%q", capturedEnc)
+	}
+	if len(capturedHash) != 64 {
+		t.Fatalf("integrity hash should always be sent, got %q", capturedHash)
+	}
+}

+ 71 - 0
internal/web/runtime/remote_mtls_test.go

@@ -0,0 +1,71 @@
+package runtime
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"strconv"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// nodeForPlainServer builds an http (non-TLS) node so do()'s token handling can
+// be exercised without TLS scaffolding.
+func nodeForPlainServer(t *testing.T, srv *httptest.Server, mode, token string) *model.Node {
+	t.Helper()
+	u, err := url.Parse(srv.URL)
+	if err != nil {
+		t.Fatalf("parse url: %v", err)
+	}
+	port, err := strconv.Atoi(u.Port())
+	if err != nil {
+		t.Fatalf("parse port: %v", err)
+	}
+	return &model.Node{
+		Id: 1, Name: "n1", Scheme: "http", Address: u.Hostname(), Port: port,
+		BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
+		TlsVerifyMode: mode,
+	}
+}
+
+// TestRemoteDo_MTLSNodeNoBearer asserts that an mtls node with no API token
+// sends its request with NO Authorization header and does not trip the
+// empty-token precondition; while a non-mtls node with no token still errors.
+func TestRemoteDo_MTLSNodeNoBearer(t *testing.T) {
+	var reached bool
+	var gotAuth string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		reached = true
+		gotAuth = r.Header.Get("Authorization")
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true}`))
+	}))
+	defer srv.Close()
+
+	t.Run("mtls without token sends no Authorization header", func(t *testing.T) {
+		reached, gotAuth = false, "sentinel"
+		r := NewRemote(nodeForPlainServer(t, srv, "mtls", ""), nil)
+		if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil {
+			t.Fatalf("mtls node with no token must not error on the token precondition: %v", err)
+		}
+		if !reached {
+			t.Fatal("request did not reach the server")
+		}
+		if gotAuth != "" {
+			t.Fatalf("Authorization header = %q, want empty for a tokenless mtls node", gotAuth)
+		}
+	})
+
+	t.Run("non-mtls without token still errors", func(t *testing.T) {
+		reached = false
+		r := NewRemote(nodeForPlainServer(t, srv, "verify", ""), nil)
+		if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err == nil {
+			t.Fatal("non-mtls node with no token must still error")
+		}
+		if reached {
+			t.Fatal("non-mtls tokenless request must not reach the server")
+		}
+	})
+}

+ 42 - 0
internal/web/runtime/tls_client.go

@@ -8,6 +8,7 @@ import (
 	"encoding/hex"
 	"net/http"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -16,6 +17,34 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 
+// MasterClientCertProvider supplies the master client certificate this panel
+// presents to nodes in mtls mode. It is injected by the web layer so the
+// runtime package need not import service.
+type MasterClientCertProvider func() (tls.Certificate, error)
+
+var (
+	masterClientCertMu sync.RWMutex
+	masterClientCert   MasterClientCertProvider
+)
+
+// SetMasterClientCertProvider installs the provider used to obtain the master
+// client certificate for mtls nodes. Passing nil disables it.
+func SetMasterClientCertProvider(p MasterClientCertProvider) {
+	masterClientCertMu.Lock()
+	defer masterClientCertMu.Unlock()
+	masterClientCert = p
+}
+
+func getMasterClientCert() (tls.Certificate, error) {
+	masterClientCertMu.RLock()
+	p := masterClientCert
+	masterClientCertMu.RUnlock()
+	if p == nil {
+		return tls.Certificate{}, common.NewError("mtls: master client certificate provider not configured")
+	}
+	return p()
+}
+
 // defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify"
 // mode or plain http); shared so connections pool across nodes.
 var defaultNodeHTTPClient = &http.Client{
@@ -70,6 +99,19 @@ func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) {
 }
 
 func tlsConfigForNode(n *model.Node) (*tls.Config, error) {
+	if n.TlsVerifyMode == "mtls" {
+		// Present the master client cert; verify the node's server cert against
+		// the system roots (no InsecureSkipVerify). mtls authenticates the
+		// caller — it does not change how the node's server identity is checked.
+		cert, err := getMasterClientCert()
+		if err != nil {
+			return nil, err
+		}
+		return &tls.Config{
+			Certificates: []tls.Certificate{cert},
+			MinVersion:   tls.VersionTLS12,
+		}, nil
+	}
 	tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check]
 	if n.TlsVerifyMode == "pin" {
 		want, err := DecodeCertPin(n.PinnedCertSha256)

+ 80 - 0
internal/web/runtime/tls_client_test.go

@@ -3,6 +3,7 @@ package runtime
 import (
 	"context"
 	"crypto/sha256"
+	"crypto/tls"
 	"encoding/base64"
 	"encoding/hex"
 	"net/http"
@@ -13,8 +14,59 @@ import (
 	"testing"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 
+// masterCertForTest builds a real CA-signed client certificate for mtls tests.
+func masterCertForTest(t *testing.T) tls.Certificate {
+	t.Helper()
+	ca, err := crypto.GenerateNodeCA("test ca")
+	if err != nil {
+		t.Fatalf("GenerateNodeCA: %v", err)
+	}
+	client, err := crypto.IssueClientCert(ca, "master")
+	if err != nil {
+		t.Fatalf("IssueClientCert: %v", err)
+	}
+	cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM)
+	if err != nil {
+		t.Fatalf("X509KeyPair: %v", err)
+	}
+	return cert
+}
+
+// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents
+// the master client cert and verifies the node's server cert against system
+// roots (no InsecureSkipVerify, no custom RootCAs).
+func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) {
+	cert := masterCertForTest(t)
+	SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil })
+	t.Cleanup(func() { SetMasterClientCertProvider(nil) })
+
+	cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"})
+	if err != nil {
+		t.Fatalf("tlsConfigForNode(mtls): %v", err)
+	}
+	if len(cfg.Certificates) != 1 {
+		t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates))
+	}
+	if cfg.InsecureSkipVerify {
+		t.Fatal("mtls must NOT skip server verification")
+	}
+	if cfg.RootCAs != nil {
+		t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)")
+	}
+}
+
+// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when
+// no master client certificate is available, rather than silently dropping auth.
+func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) {
+	SetMasterClientCertProvider(nil)
+	if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil {
+		t.Fatal("mtls without a configured client cert provider must fail closed")
+	}
+}
+
 // nodeForServer builds a node pointing at a loopback test server (loopback is
 // SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer).
 func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node {
@@ -180,6 +232,34 @@ func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) {
 	}
 }
 
+// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of
+// tlsConfigForNode so the "mtls" branch added later cannot silently regress the
+// existing skip/pin modes (characterization — passes on unchanged code).
+func TestTLSConfigForNode_CurrentContract(t *testing.T) {
+	t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) {
+		cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"})
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if !cfg.InsecureSkipVerify {
+			t.Fatal("skip mode must set InsecureSkipVerify")
+		}
+		if cfg.VerifyConnection != nil {
+			t.Fatal("skip mode must not install a VerifyConnection")
+		}
+	})
+	t.Run("pin installs a VerifyConnection", func(t *testing.T) {
+		pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size))
+		cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin})
+		if err != nil {
+			t.Fatalf("unexpected error: %v", err)
+		}
+		if cfg.VerifyConnection == nil {
+			t.Fatal("pin mode must install a VerifyConnection")
+		}
+	})
+}
+
 func TestDecodeCertPin(t *testing.T) {
 	raw := sha256.Sum256([]byte("cert"))
 	hexColon := strings.ToUpper(hex.EncodeToString(raw[:]))

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

@@ -175,7 +175,7 @@ var SystemMetricKeys = []string{
 }
 
 // NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
-var NodeMetricKeys = []string{"cpu", "mem"}
+var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
 
 // XrayMetricKeys lists series sourced from xray's /debug/vars expvar
 // endpoint. Populated by XrayMetricsService.Sample on the same 2s cadence

+ 19 - 2
internal/web/service/node.go

@@ -35,7 +35,11 @@ type HeartbeatPatch struct {
 	CpuPct        float64
 	MemPct        float64
 	UptimeSecs    uint64
-	LastError     string
+	// NetUp/NetDown are the node's current interface throughput (bytes/sec),
+	// summed over non-virtual interfaces, read from its status response.
+	NetUp     uint64
+	NetDown   uint64
+	LastError string
 	// XrayState and XrayError come from the remote /panel/api/server/status when the
 	// panel API is reachable. They allow distinguishing panel connectivity from
 	// Xray core health on the node.
@@ -275,9 +279,12 @@ func (s *NodeService) normalize(n *model.Node) error {
 	if n.Scheme != "http" && n.Scheme != "https" {
 		n.Scheme = "https"
 	}
-	if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" {
+	if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" && n.TlsVerifyMode != "mtls" {
 		n.TlsVerifyMode = "verify"
 	}
+	if n.TlsVerifyMode == "mtls" && n.Scheme != "https" {
+		return common.NewError("mtls requires the node scheme to be https")
+	}
 	n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256)
 	if n.InboundSyncMode != "selected" {
 		n.InboundSyncMode = "all"
@@ -555,6 +562,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		"cpu_pct":        p.CpuPct,
 		"mem_pct":        p.MemPct,
 		"uptime_secs":    p.UptimeSecs,
+		"net_up":         p.NetUp,
+		"net_down":       p.NetDown,
 		"last_error":     p.LastError,
 		"xray_state":     p.XrayState,
 		"xray_error":     p.XrayError,
@@ -571,6 +580,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
 		now := time.Unix(p.LastHeartbeat, 0)
 		nodeMetrics.append(nodeMetricKey(id, "cpu"), now, p.CpuPct)
 		nodeMetrics.append(nodeMetricKey(id, "mem"), now, p.MemPct)
+		nodeMetrics.append(nodeMetricKey(id, "netUp"), now, float64(p.NetUp))
+		nodeMetrics.append(nodeMetricKey(id, "netDown"), now, float64(p.NetDown))
 	}
 	return nil
 }
@@ -823,6 +834,10 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
 			PanelVersion string `json:"panelVersion"`
 			PanelGuid    string `json:"panelGuid"`
 			Uptime       uint64 `json:"uptime"`
+			NetIO        struct {
+				Up   uint64 `json:"up"`
+				Down uint64 `json:"down"`
+			} `json:"netIO"`
 		} `json:"obj"`
 	}
 	if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil {
@@ -844,6 +859,8 @@ func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string)
 	patch.PanelVersion = o.PanelVersion
 	patch.Guid = o.PanelGuid
 	patch.UptimeSecs = o.Uptime
+	patch.NetUp = o.NetIO.Up
+	patch.NetDown = o.NetIO.Down
 	return patch, nil
 }
 

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

@@ -0,0 +1,43 @@
+package service
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+)
+
+// NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the
+// public half) to copy into a node's mTLS trust setting, minting the CA and the
+// master client cert on first call so the panel is ready to present a client
+// certificate to mtls nodes.
+func (s *NodeService) NodeMtlsCaCert() (string, error) {
+	settings := SettingService{}
+	ca, err := settings.EnsureNodeMtlsCA()
+	if err != nil {
+		return "", err
+	}
+	if _, err := settings.EnsureMasterClientCert(); err != nil {
+		return "", err
+	}
+	return string(ca.CertPEM), nil
+}
+
+// SetNodeMtlsTrustCA stores the CA certificate this panel trusts for incoming
+// node-API client certificates. An empty value clears it (mTLS off). A
+// non-empty value must be a PEM certificate (fail closed). Takes effect on the
+// next panel restart, when the listener's ClientCAs is rebuilt.
+func (s *NodeService) SetNodeMtlsTrustCA(caPem string) error {
+	caPem = strings.TrimSpace(caPem)
+	if caPem != "" {
+		block, _ := pem.Decode([]byte(caPem))
+		if block == nil || block.Type != "CERTIFICATE" {
+			return common.NewError("trust CA must be a PEM-encoded certificate")
+		}
+		if _, err := x509.ParseCertificate(block.Bytes); err != nil {
+			return common.NewError("invalid trust CA certificate: " + err.Error())
+		}
+	}
+	return (&SettingService{}).setString(settingNodeMtlsClientCA, caPem)
+}

+ 111 - 0
internal/web/service/node_mtls_test.go

@@ -0,0 +1,111 @@
+package service
+
+import (
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+
+	"github.com/go-playground/validator/v10"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestNormalizeKeepsMtls(t *testing.T) {
+	s := &NodeService{}
+	cases := []struct {
+		name     string
+		in       model.Node
+		wantMode string
+		wantErr  bool
+	}{
+		{"mtls over https preserved", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "mtls"}, "mtls", false},
+		{"mtls over http rejected", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "http", TlsVerifyMode: "mtls"}, "", true},
+		{"unknown mode clamped to verify", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "bogus"}, "verify", false},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			n := c.in
+			err := s.normalize(&n)
+			if c.wantErr {
+				if err == nil {
+					t.Fatal("expected an error")
+				}
+				return
+			}
+			if err != nil {
+				t.Fatalf("normalize: %v", err)
+			}
+			if n.TlsVerifyMode != c.wantMode {
+				t.Fatalf("TlsVerifyMode = %q, want %q", n.TlsVerifyMode, c.wantMode)
+			}
+		})
+	}
+}
+
+func TestNodeTlsVerifyModeValidatorAcceptsMtls(t *testing.T) {
+	v := validator.New(validator.WithRequiredStructEnabled())
+	base := model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", ApiToken: "t"}
+
+	for _, m := range []string{"verify", "skip", "pin", "mtls"} {
+		n := base
+		n.TlsVerifyMode = m
+		if err := v.Struct(n); err != nil {
+			t.Fatalf("validator rejected valid TlsVerifyMode %q: %v", m, err)
+		}
+	}
+	bad := base
+	bad.TlsVerifyMode = "bogus"
+	if err := v.Struct(bad); err == nil {
+		t.Fatal("validator must reject an unknown TlsVerifyMode")
+	}
+}
+
+func TestNodeMtlsCaCert(t *testing.T) {
+	_ = setupSettingMtlsDB(t)
+
+	got, err := (&NodeService{}).NodeMtlsCaCert()
+	if err != nil {
+		t.Fatalf("NodeMtlsCaCert: %v", err)
+	}
+	block, _ := pem.Decode([]byte(got))
+	if block == nil || block.Type != "CERTIFICATE" {
+		t.Fatalf("NodeMtlsCaCert must return a CERTIFICATE PEM, got %q", got)
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		t.Fatalf("parse returned cert: %v", err)
+	}
+	if !cert.IsCA {
+		t.Fatal("NodeMtlsCaCert must return the CA certificate (IsCA)")
+	}
+}
+
+func TestSetNodeMtlsTrustCA(t *testing.T) {
+	_ = setupSettingMtlsDB(t)
+	ns := &NodeService{}
+	settings := SettingService{}
+
+	ca, err := settings.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatalf("EnsureNodeMtlsCA: %v", err)
+	}
+
+	if err := ns.SetNodeMtlsTrustCA(string(ca.CertPEM)); err != nil {
+		t.Fatalf("SetNodeMtlsTrustCA(valid): %v", err)
+	}
+	pool, err := settings.NodeMtlsClientCAPool()
+	if err != nil || pool == nil {
+		t.Fatalf("valid trust CA must persist + build a pool: pool=%v err=%v", pool, err)
+	}
+
+	if err := ns.SetNodeMtlsTrustCA("not a certificate"); err == nil {
+		t.Fatal("invalid PEM must be rejected (fail closed)")
+	}
+
+	if err := ns.SetNodeMtlsTrustCA(""); err != nil {
+		t.Fatalf("clearing the trust CA must be allowed: %v", err)
+	}
+	pool, _ = settings.NodeMtlsClientCAPool()
+	if pool != nil {
+		t.Fatal("cleared trust CA must yield a nil pool (mTLS off)")
+	}
+}

+ 72 - 0
internal/web/service/node_netmetrics_test.go

@@ -0,0 +1,72 @@
+package service
+
+import (
+	"context"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"slices"
+	"strconv"
+	"testing"
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestProbeParsesNetIO(t *testing.T) {
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.Header().Set("Content-Type", "application/json")
+		_, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":5,"mem":{"current":1,"total":2},"netIO":{"up":1000,"down":2000},"panelGuid":"g","uptime":42}}`))
+	}))
+	defer srv.Close()
+
+	u, err := url.Parse(srv.URL)
+	if err != nil {
+		t.Fatalf("parse url: %v", err)
+	}
+	port, _ := strconv.Atoi(u.Port())
+	n := &model.Node{Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", ApiToken: "t", AllowPrivateAddress: true}
+
+	patch, err := (&NodeService{}).probe(context.Background(), n, "")
+	if err != nil {
+		t.Fatalf("probe: %v", err)
+	}
+	if patch.NetUp != 1000 || patch.NetDown != 2000 {
+		t.Fatalf("net throughput not parsed from status: up=%d down=%d", patch.NetUp, patch.NetDown)
+	}
+}
+
+func TestUpdateHeartbeatStoresNetMetrics(t *testing.T) {
+	_ = setupSettingMtlsDB(t)
+	s := &NodeService{}
+
+	n := &model.Node{Name: "netn", Address: "1.2.3.4", Port: 2053, Scheme: "https", ApiToken: "t"}
+	if err := database.GetDB().Create(n).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+
+	patch := HeartbeatPatch{Status: "online", LastHeartbeat: time.Now().Unix(), NetUp: 111, NetDown: 222}
+	if err := s.UpdateHeartbeat(n.Id, patch); err != nil {
+		t.Fatalf("UpdateHeartbeat: %v", err)
+	}
+
+	var got model.Node
+	if err := database.GetDB().First(&got, n.Id).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if got.NetUp != 111 || got.NetDown != 222 {
+		t.Fatalf("net columns not persisted: up=%d down=%d", got.NetUp, got.NetDown)
+	}
+	if len(s.AggregateNodeMetric(n.Id, "netUp", 2, 60)) == 0 {
+		t.Fatal("expected netUp history points after an online heartbeat")
+	}
+}
+
+func TestNodeMetricKeysIncludesNet(t *testing.T) {
+	for _, k := range []string{"netUp", "netDown"} {
+		if !slices.Contains(NodeMetricKeys, k) {
+			t.Fatalf("NodeMetricKeys must include %q so the history endpoint accepts it", k)
+		}
+	}
+}

+ 18 - 9
internal/web/service/setting.go

@@ -29,15 +29,24 @@ import (
 var xrayTemplateConfig string
 
 var defaultValueMap = map[string]string{
-	"xrayTemplateConfig":          xrayTemplateConfig,
-	"webListen":                   "",
-	"webDomain":                   "",
-	"webPort":                     "2053",
-	"webCertFile":                 "",
-	"webKeyFile":                  "",
-	"secret":                      random.Seq(32),
-	"panelGuid":                   uuid.NewString(),
-	"apiToken":                    "",
+	"xrayTemplateConfig": xrayTemplateConfig,
+	"webListen":          "",
+	"webDomain":          "",
+	"webPort":            "2053",
+	"webCertFile":        "",
+	"webKeyFile":         "",
+	"secret":             random.Seq(32),
+	"panelGuid":          uuid.NewString(),
+	"apiToken":           "",
+	// Node mTLS material (opt-in). All default empty: the CA + master client
+	// cert are minted lazily on first use, and the node-side trust CA is pasted
+	// in by the operator. Kept out of entity.AllSetting so private keys never
+	// reach the settings UI/export.
+	"nodeMtlsCaCertPem":           "",
+	"nodeMtlsCaKeyPem":            "",
+	"nodeMtlsClientCertPem":       "",
+	"nodeMtlsClientKeyPem":        "",
+	"nodeMtlsClientCAPem":         "",
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"trustedProxyCIDRs":           "127.0.0.1/32,::1/128",

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

@@ -0,0 +1,106 @@
+package service
+
+import (
+	"crypto/x509"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+)
+
+const (
+	settingNodeMtlsCaCert     = "nodeMtlsCaCertPem"
+	settingNodeMtlsCaKey      = "nodeMtlsCaKeyPem"
+	settingNodeMtlsClientCert = "nodeMtlsClientCertPem"
+	settingNodeMtlsClientKey  = "nodeMtlsClientKeyPem"
+	settingNodeMtlsClientCA   = "nodeMtlsClientCAPem"
+)
+
+// EnsureNodeMtlsCA returns this panel's node-auth CA, minting and persisting it
+// on first use and reusing the stored pair thereafter. The CA private key never
+// leaves the panel.
+func (s *SettingService) EnsureNodeMtlsCA() (crypto.CertKeyPEM, error) {
+	certPem, err := s.getString(settingNodeMtlsCaCert)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	keyPem, err := s.getString(settingNodeMtlsCaKey)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if certPem != "" && keyPem != "" {
+		return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
+	}
+	// Fail closed on a half-present pair: regenerating here would silently rotate
+	// the CA and break trust on nodes that already hold the old cert. Only mint
+	// when neither half exists (first use).
+	if certPem != "" || keyPem != "" {
+		return crypto.CertKeyPEM{}, common.NewError("node mTLS CA is incomplete: one of cert/key is missing; refusing to regenerate")
+	}
+	ca, err := crypto.GenerateNodeCA("3x-ui node mTLS CA")
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if err := s.saveSetting(settingNodeMtlsCaCert, string(ca.CertPEM)); err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if err := s.saveSetting(settingNodeMtlsCaKey, string(ca.KeyPEM)); err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	return ca, nil
+}
+
+// EnsureMasterClientCert returns the client certificate this panel presents when
+// calling its nodes over mTLS, issuing it from the node CA on first use and
+// reusing the stored pair thereafter.
+func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
+	certPem, err := s.getString(settingNodeMtlsClientCert)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	keyPem, err := s.getString(settingNodeMtlsClientKey)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if certPem != "" && keyPem != "" {
+		return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
+	}
+	// Half a stored pair signals corrupted settings; reissuing would rotate the
+	// master client credential (and indirectly the CA). Only mint on first use.
+	if certPem != "" || keyPem != "" {
+		return crypto.CertKeyPEM{}, common.NewError("master client cert is incomplete: one of cert/key is missing; refusing to reissue")
+	}
+	ca, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	client, err := crypto.IssueClientCert(ca, "3x-ui master")
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if err := s.saveSetting(settingNodeMtlsClientCert, string(client.CertPEM)); err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	if err := s.saveSetting(settingNodeMtlsClientKey, string(client.KeyPEM)); err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	return client, nil
+}
+
+// NodeMtlsClientCAPool builds the trust pool used as the panel listener's
+// ClientCAs for incoming node-API client certificates. It returns (nil, nil)
+// when no trust CA is configured, so mTLS stays off and the listener behaves
+// exactly as before.
+func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) {
+	caPem, err := s.getString(settingNodeMtlsClientCA)
+	if err != nil {
+		return nil, err
+	}
+	if caPem == "" {
+		return nil, nil
+	}
+	pool := x509.NewCertPool()
+	if !pool.AppendCertsFromPEM([]byte(caPem)) {
+		return nil, common.NewError("nodeMtlsClientCAPem is not a valid certificate")
+	}
+	return pool, nil
+}

+ 125 - 0
internal/web/service/setting_mtls_test.go

@@ -0,0 +1,125 @@
+package service
+
+import (
+	"bytes"
+	"crypto/x509"
+	"encoding/pem"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+)
+
+func setupSettingMtlsDB(t *testing.T) *SettingService {
+	t.Helper()
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	return &SettingService{}
+}
+
+func TestEnsureNodeMtlsCA_Idempotent(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	first, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatalf("EnsureNodeMtlsCA (first): %v", err)
+	}
+	block, _ := pem.Decode(first.CertPEM)
+	if block == nil {
+		t.Fatal("CA cert is not valid PEM")
+	}
+	caCert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		t.Fatalf("parse CA cert: %v", err)
+	}
+	if !caCert.IsCA {
+		t.Fatal("stored CA must have IsCA=true")
+	}
+
+	second, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatalf("EnsureNodeMtlsCA (second): %v", err)
+	}
+	if !bytes.Equal(first.CertPEM, second.CertPEM) || !bytes.Equal(first.KeyPEM, second.KeyPEM) {
+		t.Fatal("EnsureNodeMtlsCA must be idempotent: second call returned different PEMs")
+	}
+}
+
+func TestEnsureMasterClientCert_VerifiesAndIdempotent(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	ca, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatalf("EnsureNodeMtlsCA: %v", err)
+	}
+	client, err := s.EnsureMasterClientCert()
+	if err != nil {
+		t.Fatalf("EnsureMasterClientCert: %v", err)
+	}
+
+	cblock, _ := pem.Decode(client.CertPEM)
+	if cblock == nil {
+		t.Fatal("client cert is not valid PEM")
+	}
+	leaf, err := x509.ParseCertificate(cblock.Bytes)
+	if err != nil {
+		t.Fatalf("parse client cert: %v", err)
+	}
+	caBlock, _ := pem.Decode(ca.CertPEM)
+	roots := x509.NewCertPool()
+	roots.AddCert(mustParse(t, caBlock.Bytes))
+	if _, err := leaf.Verify(x509.VerifyOptions{
+		Roots:     roots,
+		KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
+	}); err != nil {
+		t.Fatalf("master client cert must verify against the node CA for client auth: %v", err)
+	}
+
+	again, err := s.EnsureMasterClientCert()
+	if err != nil {
+		t.Fatalf("EnsureMasterClientCert (second): %v", err)
+	}
+	if !bytes.Equal(client.CertPEM, again.CertPEM) || !bytes.Equal(client.KeyPEM, again.KeyPEM) {
+		t.Fatal("EnsureMasterClientCert must be idempotent")
+	}
+}
+
+func TestNodeMtlsClientCAPool(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	pool, err := s.NodeMtlsClientCAPool()
+	if err != nil {
+		t.Fatalf("NodeMtlsClientCAPool (unset): %v", err)
+	}
+	if pool != nil {
+		t.Fatal("with no trust CA configured, the pool must be nil (mTLS off; listener unchanged)")
+	}
+
+	ca, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatalf("EnsureNodeMtlsCA: %v", err)
+	}
+	if err := s.setString("nodeMtlsClientCAPem", string(ca.CertPEM)); err != nil {
+		t.Fatalf("set trust CA: %v", err)
+	}
+	pool, err = s.NodeMtlsClientCAPool()
+	if err != nil {
+		t.Fatalf("NodeMtlsClientCAPool (set): %v", err)
+	}
+	if pool == nil {
+		t.Fatal("with a trust CA configured, the pool must be non-nil")
+	}
+}
+
+func mustParse(t *testing.T, der []byte) *x509.Certificate {
+	t.Helper()
+	c, err := x509.ParseCertificate(der)
+	if err != nil {
+		t.Fatalf("ParseCertificate: %v", err)
+	}
+	return c
+}

+ 84 - 85
internal/web/translation/ar-EG.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "تمت إضافة عميل(عملاء) وارد",
         "inboundClientDeleteSuccess": "تم حذف عميل وارد",
         "inboundClientUpdateSuccess": "تم تحديث عميل وارد",
+        "savedNodeOfflineWillSync": "تم الحفظ محليًا. إحدى العُقد الداعمة غير متصلة أو معطّلة — ستتم مزامنة التغيير بمجرد إعادة الاتصال.",
         "delDepletedClientsSuccess": "تم حذف جميع العملاء المستنفذين",
         "resetAllClientTrafficSuccess": "تم إعادة تعيين كل حركة المرور من العميل",
         "resetAllTrafficSuccess": "تم إعادة تعيين كل حركة المرور",
@@ -912,6 +913,8 @@
       "status": "الحالة",
       "cpu": "CPU",
       "mem": "الذاكرة",
+      "netUp": "صعود الشبكة (KB/s)",
+      "netDown": "نزول الشبكة (KB/s)",
       "uptime": "مدة التشغيل",
       "latency": "الكمون",
       "lastHeartbeat": "آخر نبضة",
@@ -953,13 +956,29 @@
         "probeFailed": "فشل الفحص",
         "updateStarted": "بدأ تحديث اللوحة",
         "updateResult": "تم بدء التحديث على {ok} عقدة، فشل {failed}",
-        "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة"
+        "updateNoneEligible": "اختر عقدة واحدة على الأقل متصلة ومفعّلة",
+        "saveMtls": "حفظ mTLS النود"
       },
       "tlsVerifyMode": "التحقق من TLS",
       "tlsVerifyModeHint": "كيف يتحقق اللوحة من شهادة HTTPS الخاصة بالعقدة. التثبيت أو التخطّي مخصّصان للشهادات الموقّعة ذاتيًا (عُقد https فقط).",
       "tlsVerify": "تحقّق (CA الافتراضية)",
       "tlsPin": "تثبيت الشهادة (SHA-256)",
       "tlsSkip": "تخطّي التحقق",
+      "tlsMtls": "TLS متبادل (شهادة العميل)",
+      "mtlsFormHint": "يصادق هذا النود اللوحة باستخدام شهادة عميل. انسخ CA الخاص بهذه اللوحة من قسم mTLS النود إلى النود، واضبط CA الموثوق به، ثم أعد تشغيله.",
+      "mtls": {
+        "title": "mTLS النود",
+        "intro": "يضيف TLS المتبادل عامل شهادة العميل فوق رمز API في الاتصالات بين النودات. وهو اختياري: اتركه فارغًا للاكتفاء بالمصادقة عبر الرمز.",
+        "copyCa": "نسخ CA الخاص بهذه اللوحة",
+        "copyCaHint": "سلّم هذا الـ CA إلى النودات التي تديرها هذه اللوحة، ثم اضبط التحقق من TLS لديها على TLS المتبادل.",
+        "caCopied": "تم نسخ شهادة CA إلى الحافظة",
+        "caFailed": "تعذّر الحصول على شهادة CA",
+        "trustLabel": "CA الموثوق (اللوحة الأم)",
+        "trustHint": "عندما تكون هذه اللوحة نفسها نودًا، الصق هنا CA الخاص باللوحة التي تديرها لطلب شهادة العميل الخاصة بها. أعد تشغيل اللوحة للتطبيق.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "حفظ CA الموثوق",
+        "saved": "تم حفظ CA الموثوق — أعد تشغيل اللوحة للتطبيق"
+      },
       "tlsSkipWarning": "تخطّي التحقق يزيل الحماية من هجمات الوسيط — قد يُعترض رمز الـ API. يُفضَّل تثبيت الشهادة بدلاً من ذلك.",
       "pinnedCert": "SHA-256 للشهادة المثبّتة",
       "pinnedCertHint": "SHA-256 لشهادة العقدة بصيغة base64 أو hex. استخدم \"جلب\" لقراءتها من العقدة الآن.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "خطأ في الحصول على حركات المرور الصادرة",
         "resetOutboundTrafficError": "خطأ في إعادة تعيين حركات المرور الصادرة"
       },
-      "emailNotifications": "الإشعارات",
+      "smtpSettings": "إعدادات SMTP",
+      "smtpEnable": "تفعيل إشعارات البريد الإلكتروني",
+      "smtpEnableDesc": "تفعيل إشعارات البريد الإلكتروني عبر SMTP",
+      "smtpHost": "خادم SMTP",
+      "smtpHostDesc": "اسم مضيف خادم SMTP (مثال: smtp.gmail.com)",
+      "smtpPort": "منفذ SMTP",
+      "smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)",
+      "smtpUsername": "اسم مستخدم SMTP",
+      "smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP",
+      "smtpPassword": "كلمة مرور SMTP",
+      "smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP",
+      "smtpTo": "المستلمون",
+      "smtpToDesc": "عناوين البريد الإلكتروني للمستلمين مفصولة بفواصل",
       "emailSettings": "البريد الإلكتروني",
-      "eventCPUHigh": "ارتفاع استخدام المعالج (%)",
+      "emailNotifications": "الإشعارات",
+      "smtpEventBusNotify": "إشعارات الأحداث بالبريد الإلكتروني",
+      "smtpEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات البريد الإلكتروني",
+      "tgEventBusNotify": "إشعارات الأحداث عبر Telegram",
+      "tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram",
+      "testSmtp": "إرسال بريد تجريبي",
+      "testTgBot": "إرسال رسالة تجريبية",
       "eventGroupOutbound": "الصادر",
-      "eventGroupSecurity": "الأمان",
-      "eventGroupSystem": "النظام",
       "eventGroupXray": "نواة Xray",
-      "eventLoginAttempt": "محاولة تسجيل دخول",
+      "eventGroupSystem": "النظام",
+      "eventGroupSecurity": "الأمان",
+      "eventGroupNode": "العقد",
       "eventOutboundDown": "غير متصل",
       "eventOutboundUp": "متصل",
       "eventXrayCrash": "تعطّل",
+      "eventNodeDown": "غير متصلة",
+      "eventNodeUp": "متصلة",
+      "eventCPUHigh": "ارتفاع استخدام المعالج (%)",
       "requestFailed": "فشل الطلب",
-      "smtpEnable": "تفعيل إشعارات البريد الإلكتروني",
-      "smtpEnableDesc": "تفعيل إشعارات البريد الإلكتروني عبر SMTP",
       "smtpEncryption": "التشفير",
       "smtpEncryptionDesc": "طريقة تشفير اتصال SMTP",
       "smtpEncryptionNone": "بدون (نص عادي)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (ضمني)",
-      "smtpEventBusNotify": "إشعارات الأحداث بالبريد الإلكتروني",
-      "smtpEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات البريد الإلكتروني",
-      "smtpHost": "خادم SMTP",
-      "smtpHostDesc": "اسم مضيف خادم SMTP (مثال: smtp.gmail.com)",
-      "smtpHostNotConfigured": "خادم SMTP غير مهيأ",
-      "smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
-      "smtpNotInitialized": "لم تتم تهيئة SMTP",
-      "smtpPassword": "كلمة مرور SMTP",
-      "smtpPasswordDesc": "كلمة المرور للمصادقة على SMTP",
-      "smtpPort": "منفذ SMTP",
-      "smtpPortDesc": "منفذ خادم SMTP (الافتراضي: 587)",
-      "smtpSettings": "إعدادات SMTP",
-      "smtpStageAuth": "المصادقة",
       "smtpStageConnect": "الاتصال",
+      "smtpStageAuth": "المصادقة",
       "smtpStageSend": "الإرسال",
       "smtpTestSuccess": "تم إرسال البريد التجريبي بنجاح",
-      "smtpTo": "المستلمون",
-      "smtpToDesc": "عناوين البريد الإلكتروني للمستلمين مفصولة بفواصل",
-      "smtpUsername": "اسم مستخدم SMTP",
-      "smtpUsernameDesc": "اسم المستخدم للمصادقة على SMTP",
+      "smtpHostNotConfigured": "خادم SMTP غير مهيأ",
+      "smtpNoRecipients": "لا يوجد مستلمون مهيؤون",
+      "eventLoginAttempt": "محاولة تسجيل دخول",
       "telegramTokenConfigured": "مهيأ؛ اتركه فارغاً للاحتفاظ بالتوكن الحالي.",
       "telegramTokenPlaceholder": "مهيأ — أدخل توكن جديد لاستبداله",
-      "testSmtp": "إرسال بريد تجريبي",
-      "testTgBot": "إرسال رسالة تجريبية",
+      "smtpPasswordConfigured": "مهيأة؛ اتركها فارغة للاحتفاظ بكلمة المرور الحالية.",
+      "smtpPasswordPlaceholder": "مهيأة — أدخل كلمة مرور جديدة لاستبدالها",
+      "smtpNotInitialized": "لم تتم تهيئة SMTP",
       "tgBotNotEnabled": "بوت Telegram غير مفعّل",
-      "tgBotNotRunning": "بوت Telegram لا يعمل",
-      "tgEventBusNotify": "إشعارات الأحداث عبر Telegram",
-      "tgEventBusNotifyDesc": "اختر الأحداث التي تُطلق إشعارات Telegram",
       "tgTestFailed": "فشل اختبار Telegram",
       "tgTestSuccess": "تم إرسال رسالة تجريبية إلى Telegram",
+      "tgBotNotRunning": "بوت Telegram لا يعمل",
       "smtpErrorAuth": "فشلت المصادقة — تحقق من اسم المستخدم وكلمة المرور",
       "smtpErrorStarttls": "الخادم يتطلب STARTTLS — غيّر نوع التشفير",
       "smtpErrorTls": "الخادم يتطلب TLS — غيّر نوع التشفير",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "انتهت مهلة الاتصال — تعذّر الوصول إلى الخادم",
       "smtpErrorRelay": "الخادم يرفض الإرسال من هذا العنوان",
       "smtpErrorEof": "تم إغلاق الاتصال من قبل الخادم",
-      "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}",
-      "eventGroupNode": "العقد",
-      "eventNodeDown": "غير متصلة",
-      "eventNodeUp": "متصلة",
-      "smtpPasswordConfigured": "مهيأة؛ اتركها فارغة للاحتفاظ بكلمة المرور الحالية.",
-      "smtpPasswordPlaceholder": "مهيأة — أدخل كلمة مرور جديدة لاستبدالها"
+      "smtpErrorUnknown": "خطأ SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "إعدادات Xray",
@@ -1319,6 +1338,8 @@
       "Inbounds": "الواردات",
       "InboundsDesc": "قبول العملاء المعينين.",
       "Outbounds": "الصادرات",
+      "OutboundSubscriptions": "اشتراكات الصادرات",
+      "OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
       "Balancers": "موازنات التحميل",
       "balancerTagRequired": "الوسم مطلوب",
       "balancerSelectorRequired": "اختر صادراً واحداً على الأقل",
@@ -1496,8 +1517,6 @@
         "privateKey": "المفتاح الخاص",
         "load": "الحمل"
       },
-      "OutboundSubscriptions": "اشتراكات الصادرات",
-      "OutboundSubscriptionsDesc": "استورد الصادرات من روابط اشتراك بعيدة (vmess/vless/trojan/ss/...). الوسوم بتفضل ثابتة عشان تستخدمها في موازنات التحميل وقواعد التوجيه. التحديثات بتتم تلقائياً.",
       "outboundSub": {
         "manage": "الاشتراكات",
         "title": "اشتراكات الصادرات",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ✅ تم بنجاح",
       "FailedResetTraffic": "📧 البريد الإلكتروني: {{ .ClientEmail }}\n🏁 النتيجة: ❌ فشل \n\n🛠️ الخطأ: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 عملية إعادة ضبط الترافيك خلصت لكل العملاء.",
-      "eventCPUHigh": "ارتفاع استخدام المعالج",
-      "eventCPUHighDetail": "المعالج: {{ .Detail }}",
-      "eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية",
-      "eventErrorDetail": "الخطأ: {{ .Error }}",
-      "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}",
       "eventOutboundDown": "الصادر {{ .Tag }} غير متصل",
       "eventOutboundUp": "الصادر {{ .Tag }} متصل",
+      "eventErrorDetail": "الخطأ: {{ .Error }}",
+      "eventDelayDetail": "التأخير: {{ .Delay }} مللي ثانية",
       "eventXrayCrash": "تعطّل Xray",
       "eventXrayCrashError": "الخطأ: {{ .Error }}",
       "eventNodeDown": "العقدة {{ .Name }} غير متصلة",
-      "eventNodeUp": "العقدة {{ .Name }} متصلة"
+      "eventNodeUp": "العقدة {{ .Name }} متصلة",
+      "eventCPUHigh": "ارتفاع استخدام المعالج",
+      "eventCPUHighDetail": "المعالج: {{ .Detail }}",
+      "eventLoginFallback": "فشل تسجيل الدخول من {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ اقفل الكيبورد",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
+    "subjectOutboundUp": "الصادر {{ .Tag }} متصل",
+    "subjectXrayCrash": "تعطّل Xray",
+    "subjectCPUHigh": "ارتفاع استخدام المعالج",
+    "subjectLoginSuccess": "نجح تسجيل الدخول",
+    "subjectLoginFailed": "فشل تسجيل الدخول",
+    "titleOutboundDown": "الصادر غير متصل",
+    "titleOutboundUp": "الصادر متصل",
+    "titleXrayCrash": "تعطّل Xray",
+    "titleCPUHigh": "ارتفاع استخدام المعالج",
+    "titleLoginSuccess": "نجح تسجيل الدخول",
+    "titleLoginFailed": "فشل تسجيل الدخول",
+    "labelStatus": "الحالة",
+    "labelOutbound": "الصادر",
+    "labelNode": "العقدة",
+    "labelError": "الخطأ",
     "labelDelay": "التأخير",
     "labelDetail": "التفاصيل",
-    "labelError": "الخطأ",
+    "labelUsername": "اسم المستخدم",
     "labelIP": "IP",
-    "labelOutbound": "الصادر",
     "labelReason": "السبب",
     "labelSource": "المصدر",
-    "labelStatus": "الحالة",
     "labelTime": "الوقت",
-    "labelUsername": "اسم المستخدم",
-    "statusBanned": "BANNED",
     "statusCrashed": "متعطّل",
-    "statusDown": "غير متصل",
-    "statusFailed": "فشل",
-    "statusFull": "FULL",
-    "statusHigh": "مرتفع",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "يعمل",
+    "statusHigh": "مرتفع",
     "statusSuccess": "نجاح",
-    "statusUp": "متصل",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "ارتفاع استخدام المعالج",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "فشل تسجيل الدخول",
-    "subjectLoginSuccess": "نجح تسجيل الدخول",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "الصادر {{ .Tag }} غير متصل",
-    "subjectOutboundUp": "الصادر {{ .Tag }} متصل",
-    "subjectXrayCrash": "تعطّل Xray",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "ارتفاع استخدام المعالج",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "فشل تسجيل الدخول",
-    "titleLoginSuccess": "نجح تسجيل الدخول",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "الصادر غير متصل",
-    "titleOutboundUp": "الصادر متصل",
-    "titleXrayCrash": "تعطّل Xray",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "العقدة"
+    "statusFailed": "فشل",
+    "statusDown": "غير متصل",
+    "statusUp": "متصل"
   }
 }

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

@@ -913,6 +913,8 @@
       "status": "Status",
       "cpu": "CPU",
       "mem": "Memory",
+      "netUp": "Net Up (KB/s)",
+      "netDown": "Net Down (KB/s)",
       "uptime": "Uptime",
       "latency": "Latency",
       "lastHeartbeat": "Last Heartbeat",
@@ -954,13 +956,29 @@
         "probeFailed": "Probe failed",
         "updateStarted": "Panel update started",
         "updateResult": "Update triggered on {ok} node(s), {failed} failed",
-        "updateNoneEligible": "Select at least one online, enabled node"
+        "updateNoneEligible": "Select at least one online, enabled node",
+        "saveMtls": "Save node mTLS"
       },
       "tlsVerifyMode": "TLS verification",
       "tlsVerifyModeHint": "How the panel validates the node's HTTPS certificate. Pin or Skip are for self-signed certs (https nodes only).",
       "tlsVerify": "Verify (default CA)",
       "tlsPin": "Pin certificate (SHA-256)",
       "tlsSkip": "Skip verification",
+      "tlsMtls": "Mutual TLS (client certificate)",
+      "mtlsFormHint": "This node authenticates the panel with a client certificate. Copy this panel's CA from the Node mTLS section onto the node, set its Trusted parent CA, then restart it.",
+      "mtls": {
+        "title": "Node mTLS",
+        "intro": "Mutual TLS adds a client-certificate factor on top of the API token for node-to-node calls. It is opt-in: leave it empty to keep token-only auth.",
+        "copyCa": "Copy this panel's CA",
+        "copyCaHint": "Hand this CA to the nodes this panel manages, then set their TLS verification to Mutual TLS.",
+        "caCopied": "CA certificate copied to clipboard",
+        "caFailed": "Failed to obtain the CA certificate",
+        "trustLabel": "Trusted parent CA",
+        "trustHint": "When this panel is itself a node, paste the managing panel's CA here to require its client certificate. Restart the panel to apply.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Save trust CA",
+        "saved": "Trust CA saved — restart the panel to apply"
+      },
       "tlsSkipWarning": "Skipping verification removes protection against man-in-the-middle attacks — the API token could be intercepted. Prefer pinning the certificate.",
       "pinnedCert": "Pinned certificate SHA-256",
       "pinnedCertHint": "Base64 or hex SHA-256 of the node's certificate. Use Fetch to read it from the node now.",

+ 84 - 85
internal/web/translation/es-ES.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Cliente(s) de entrada añadido(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada eliminado",
         "inboundClientUpdateSuccess": "Cliente de entrada actualizado",
+        "savedNodeOfflineWillSync": "Guardado localmente. Un nodo de respaldo está desconectado o deshabilitado: el cambio se sincronizará cuando vuelva a conectarse.",
         "delDepletedClientsSuccess": "Todos los clientes con tráfico agotado fueron eliminados",
         "resetAllClientTrafficSuccess": "Todo el tráfico del cliente ha sido reiniciado",
         "resetAllTrafficSuccess": "Todo el tráfico ha sido reiniciado",
@@ -912,6 +913,8 @@
       "status": "Estado",
       "cpu": "CPU",
       "mem": "Memoria",
+      "netUp": "Subida de red (KB/s)",
+      "netDown": "Bajada de red (KB/s)",
       "uptime": "Tiempo activo",
       "latency": "Latencia",
       "lastHeartbeat": "Último latido",
@@ -953,13 +956,29 @@
         "probeFailed": "Sondeo fallido",
         "updateStarted": "Actualización del panel iniciada",
         "updateResult": "Actualización iniciada en {ok} nodo(s), {failed} fallaron",
-        "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado"
+        "updateNoneEligible": "Selecciona al menos un nodo en línea y habilitado",
+        "saveMtls": "Guardar mTLS del nodo"
       },
       "tlsVerifyMode": "Verificación TLS",
       "tlsVerifyModeHint": "Cómo valida el panel el certificado HTTPS del nodo. Fijar u Omitir son para certificados autofirmados (solo nodos https).",
       "tlsVerify": "Verificar (CA predeterminada)",
       "tlsPin": "Fijar certificado (SHA-256)",
       "tlsSkip": "Omitir verificación",
+      "tlsMtls": "TLS mutuo (certificado de cliente)",
+      "mtlsFormHint": "Este nodo autentica al panel con un certificado de cliente. Copia el CA de este panel desde la sección mTLS del nodo al nodo, configura su CA de confianza y luego reinícialo.",
+      "mtls": {
+        "title": "mTLS del nodo",
+        "intro": "TLS mutuo añade un factor de certificado de cliente además del token de API para las llamadas entre nodos. Es opcional: déjalo vacío para mantener solo la autenticación por token.",
+        "copyCa": "Copiar el CA de este panel",
+        "copyCaHint": "Entrega este CA a los nodos que gestiona este panel y luego configura su verificación TLS como TLS mutuo.",
+        "caCopied": "Certificado CA copiado al portapapeles",
+        "caFailed": "No se pudo obtener el certificado CA",
+        "trustLabel": "CA de confianza (panel superior)",
+        "trustHint": "Cuando este panel es a su vez un nodo, pega aquí el CA del panel que lo gestiona para exigir su certificado de cliente. Reinicia el panel para aplicar.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Guardar CA de confianza",
+        "saved": "CA de confianza guardado — reinicia el panel para aplicar"
+      },
       "tlsSkipWarning": "Omitir la verificación elimina la protección contra ataques de intermediario; el token de API podría ser interceptado. Es preferible fijar el certificado.",
       "pinnedCert": "SHA-256 del certificado fijado",
       "pinnedCertHint": "SHA-256 del certificado del nodo en base64 o hex. Usa Obtener para leerlo del nodo ahora.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "Error al obtener el tráfico saliente",
         "resetOutboundTrafficError": "Error al reiniciar el tráfico saliente"
       },
-      "emailNotifications": "Notificaciones",
+      "smtpSettings": "Configuración de SMTP",
+      "smtpEnable": "Activar notificaciones por correo",
+      "smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
+      "smtpHost": "Servidor SMTP",
+      "smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
+      "smtpPort": "Puerto SMTP",
+      "smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
+      "smtpUsername": "Usuario SMTP",
+      "smtpUsernameDesc": "Usuario de autenticación SMTP",
+      "smtpPassword": "Contraseña SMTP",
+      "smtpPasswordDesc": "Contraseña de autenticación SMTP",
+      "smtpTo": "Destinatarios",
+      "smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
       "emailSettings": "Correo",
-      "eventCPUHigh": "CPU alta (%)",
+      "emailNotifications": "Notificaciones",
+      "smtpEventBusNotify": "Notificaciones por correo de eventos",
+      "smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
+      "tgEventBusNotify": "Notificaciones de Telegram de eventos",
+      "tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
+      "testSmtp": "Enviar correo de prueba",
+      "testTgBot": "Enviar mensaje de prueba",
       "eventGroupOutbound": "Saliente",
-      "eventGroupSecurity": "Seguridad",
-      "eventGroupSystem": "Sistema",
       "eventGroupXray": "Núcleo de Xray",
-      "eventLoginAttempt": "Intento de inicio de sesión",
+      "eventGroupSystem": "Sistema",
+      "eventGroupSecurity": "Seguridad",
+      "eventGroupNode": "Nodos",
       "eventOutboundDown": "Caído",
       "eventOutboundUp": "Activo",
       "eventXrayCrash": "Caída",
+      "eventNodeDown": "Caído",
+      "eventNodeUp": "Activo",
+      "eventCPUHigh": "CPU alta (%)",
       "requestFailed": "La solicitud falló",
-      "smtpEnable": "Activar notificaciones por correo",
-      "smtpEnableDesc": "Activar notificaciones por correo mediante SMTP",
       "smtpEncryption": "Cifrado",
       "smtpEncryptionDesc": "Método de cifrado de la conexión SMTP",
       "smtpEncryptionNone": "Ninguno (texto sin cifrar)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (implícito)",
-      "smtpEventBusNotify": "Notificaciones por correo de eventos",
-      "smtpEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones por correo",
-      "smtpHost": "Servidor SMTP",
-      "smtpHostDesc": "Nombre del servidor SMTP (p. ej. smtp.gmail.com)",
-      "smtpHostNotConfigured": "Servidor SMTP no configurado",
-      "smtpNoRecipients": "No hay destinatarios configurados",
-      "smtpNotInitialized": "SMTP no inicializado",
-      "smtpPassword": "Contraseña SMTP",
-      "smtpPasswordDesc": "Contraseña de autenticación SMTP",
-      "smtpPort": "Puerto SMTP",
-      "smtpPortDesc": "Puerto del servidor SMTP (predeterminado: 587)",
-      "smtpSettings": "Configuración de SMTP",
-      "smtpStageAuth": "Autenticación",
       "smtpStageConnect": "Conexión",
+      "smtpStageAuth": "Autenticación",
       "smtpStageSend": "Envío",
       "smtpTestSuccess": "Correo de prueba enviado correctamente",
-      "smtpTo": "Destinatarios",
-      "smtpToDesc": "Direcciones de correo de los destinatarios separadas por comas",
-      "smtpUsername": "Usuario SMTP",
-      "smtpUsernameDesc": "Usuario de autenticación SMTP",
+      "smtpHostNotConfigured": "Servidor SMTP no configurado",
+      "smtpNoRecipients": "No hay destinatarios configurados",
+      "eventLoginAttempt": "Intento de inicio de sesión",
       "telegramTokenConfigured": "Configurado; deje en blanco para mantener el token actual.",
       "telegramTokenPlaceholder": "Configurado: introduzca un nuevo token para reemplazarlo",
-      "testSmtp": "Enviar correo de prueba",
-      "testTgBot": "Enviar mensaje de prueba",
+      "smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
+      "smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla",
+      "smtpNotInitialized": "SMTP no inicializado",
       "tgBotNotEnabled": "El bot de Telegram no está activado",
-      "tgBotNotRunning": "El bot de Telegram no está en ejecución",
-      "tgEventBusNotify": "Notificaciones de Telegram de eventos",
-      "tgEventBusNotifyDesc": "Seleccione qué eventos generan notificaciones de Telegram",
       "tgTestFailed": "La prueba de Telegram falló",
       "tgTestSuccess": "Mensaje de prueba enviado a Telegram",
+      "tgBotNotRunning": "El bot de Telegram no está en ejecución",
       "smtpErrorAuth": "Error de autenticación: compruebe el usuario y la contraseña",
       "smtpErrorStarttls": "El servidor requiere STARTTLS: cambie el tipo de cifrado",
       "smtpErrorTls": "El servidor requiere TLS: cambie el tipo de cifrado",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Tiempo de conexión agotado: servidor inaccesible",
       "smtpErrorRelay": "El servidor rechaza el envío desde esta dirección",
       "smtpErrorEof": "Conexión cerrada por el servidor",
-      "smtpErrorUnknown": "Error de SMTP: {{ .Error }}",
-      "eventGroupNode": "Nodos",
-      "eventNodeDown": "Caído",
-      "eventNodeUp": "Activo",
-      "smtpPasswordConfigured": "Configurada; deje en blanco para mantener la contraseña actual.",
-      "smtpPasswordPlaceholder": "Configurada: introduzca una nueva contraseña para reemplazarla"
+      "smtpErrorUnknown": "Error de SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Xray Configuración",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Entradas",
       "InboundsDesc": "Cambia la plantilla de configuración para aceptar clientes específicos.",
       "Outbounds": "Salidas",
+      "OutboundSubscriptions": "Suscripciones de salida",
+      "OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
       "Balancers": "Equilibradores",
       "balancerTagRequired": "La etiqueta es obligatoria",
       "balancerSelectorRequired": "Elige al menos una salida",
@@ -1496,8 +1517,6 @@
         "privateKey": "Clave privada",
         "load": "Carga"
       },
-      "OutboundSubscriptions": "Suscripciones de salida",
-      "OutboundSubscriptionsDesc": "Importa salidas desde URLs de suscripción remotas (vmess/vless/trojan/ss/...). Las etiquetas se mantienen estables para usarlas en balanceadores y reglas de enrutamiento. Las actualizaciones son automáticas.",
       "outboundSub": {
         "manage": "Suscripciones",
         "title": "Suscripciones de salida",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ✅ Éxito",
       "FailedResetTraffic": "📧 Correo: {{ .ClientEmail }}\n🏁 Resultado: ❌ Fallido \n\n🛠️ Error: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Proceso de reinicio de tráfico finalizado para todos los clientes.",
-      "eventCPUHigh": "CPU alta",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Retardo: {{ .Delay }} ms",
-      "eventErrorDetail": "Error: {{ .Error }}",
-      "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}",
       "eventOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
       "eventOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
+      "eventErrorDetail": "Error: {{ .Error }}",
+      "eventDelayDetail": "Retardo: {{ .Delay }} ms",
       "eventXrayCrash": "Xray se ha BLOQUEADO",
       "eventXrayCrashError": "Error: {{ .Error }}",
       "eventNodeDown": "El nodo {{ .Name }} está CAÍDO",
-      "eventNodeUp": "El nodo {{ .Name }} está ACTIVO"
+      "eventNodeUp": "El nodo {{ .Name }} está ACTIVO",
+      "eventCPUHigh": "CPU alta",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Inicio de sesión fallido desde {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Cerrar Teclado",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
+    "subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
+    "subjectXrayCrash": "Xray se ha BLOQUEADO",
+    "subjectCPUHigh": "CPU alta",
+    "subjectLoginSuccess": "Inicio de sesión correcto",
+    "subjectLoginFailed": "Inicio de sesión fallido",
+    "titleOutboundDown": "Saliente CAÍDO",
+    "titleOutboundUp": "Saliente ACTIVO",
+    "titleXrayCrash": "Xray se ha BLOQUEADO",
+    "titleCPUHigh": "CPU alta",
+    "titleLoginSuccess": "Inicio de sesión correcto",
+    "titleLoginFailed": "Inicio de sesión fallido",
+    "labelStatus": "Estado",
+    "labelOutbound": "Saliente",
+    "labelNode": "Nodo",
+    "labelError": "Error",
     "labelDelay": "Retardo",
     "labelDetail": "Detalle",
-    "labelError": "Error",
+    "labelUsername": "Usuario",
     "labelIP": "IP",
-    "labelOutbound": "Saliente",
     "labelReason": "Motivo",
     "labelSource": "Origen",
-    "labelStatus": "Estado",
     "labelTime": "Hora",
-    "labelUsername": "Usuario",
-    "statusBanned": "BANNED",
     "statusCrashed": "BLOQUEADO",
-    "statusDown": "CAÍDO",
-    "statusFailed": "FALLIDO",
-    "statusFull": "FULL",
-    "statusHigh": "ALTA",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "En ejecución",
+    "statusHigh": "ALTA",
     "statusSuccess": "CORRECTO",
-    "statusUp": "ACTIVO",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU alta",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Inicio de sesión fallido",
-    "subjectLoginSuccess": "Inicio de sesión correcto",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "El saliente {{ .Tag }} está CAÍDO",
-    "subjectOutboundUp": "El saliente {{ .Tag }} está ACTIVO",
-    "subjectXrayCrash": "Xray se ha BLOQUEADO",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU alta",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Inicio de sesión fallido",
-    "titleLoginSuccess": "Inicio de sesión correcto",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Saliente CAÍDO",
-    "titleOutboundUp": "Saliente ACTIVO",
-    "titleXrayCrash": "Xray se ha BLOQUEADO",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Nodo"
+    "statusFailed": "FALLIDO",
+    "statusDown": "CAÍDO",
+    "statusUp": "ACTIVO"
   }
 }

+ 84 - 85
internal/web/translation/fa-IR.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "کلاینت(های) ورودی اضافه شدند",
         "inboundClientDeleteSuccess": "کلاینت ورودی حذف شد",
         "inboundClientUpdateSuccess": "کلاینت ورودی به‌روزرسانی شد",
+        "savedNodeOfflineWillSync": "به‌صورت محلی ذخیره شد. یک نود پشتیبان آفلاین یا غیرفعال است — تغییر پس از اتصال مجدد همگام‌سازی می‌شود.",
         "delDepletedClientsSuccess": "تمام کلاینت‌های مصرف شده حذف شدند",
         "resetAllClientTrafficSuccess": "تمام ترافیک کلاینت بازنشانی شد",
         "resetAllTrafficSuccess": "تمام ترافیک‌ها بازنشانی شدند",
@@ -912,6 +913,8 @@
       "status": "وضعیت",
       "cpu": "CPU",
       "mem": "حافظه",
+      "netUp": "آپلود شبکه (KB/s)",
+      "netDown": "دانلود شبکه (KB/s)",
       "uptime": "مدت فعالیت",
       "latency": "تاخیر",
       "lastHeartbeat": "آخرین ضربان",
@@ -953,13 +956,29 @@
         "probeFailed": "بررسی ناموفق",
         "updateStarted": "به‌روزرسانی پنل آغاز شد",
         "updateResult": "به‌روزرسانی روی {ok} نود آغاز شد، {failed} ناموفق",
-        "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید"
+        "updateNoneEligible": "حداقل یک نود آنلاین و فعال انتخاب کنید",
+        "saveMtls": "ذخیره mTLS نود"
       },
       "tlsVerifyMode": "اعتبارسنجی TLS",
       "tlsVerifyModeHint": "اینکه پنل گواهی HTTPS نود را چطور بررسی کند. Pin یا Skip برای گواهی‌های self-signed است (فقط نودهای https).",
       "tlsVerify": "اعتبارسنجی (CA پیش‌فرض)",
       "tlsPin": "Pin گواهی (SHA-256)",
       "tlsSkip": "رد کردن اعتبارسنجی",
+      "tlsMtls": "TLS متقابل (گواهی کلاینت)",
+      "mtlsFormHint": "این نود با یک گواهی کلاینت، پنل را احراز هویت می‌کند. CA این پنل را از بخش mTLS نود به نود کپی کنید، CA مورد اعتماد آن را تنظیم کنید و سپس آن را راه‌اندازی مجدد کنید.",
+      "mtls": {
+        "title": "mTLS نود",
+        "intro": "TLS متقابل علاوه بر توکن API، یک عامل گواهی کلاینت برای ارتباط بین نودها اضافه می‌کند. اختیاری است: برای استفاده فقط از احراز هویت با توکن، آن را خالی بگذارید.",
+        "copyCa": "کپی CA این پنل",
+        "copyCaHint": "این CA را به نودهایی که این پنل مدیریت می‌کند بدهید، سپس حالت اعتبارسنجی TLS آن‌ها را روی TLS متقابل تنظیم کنید.",
+        "caCopied": "گواهی CA در کلیپ‌بورد کپی شد",
+        "caFailed": "دریافت گواهی CA ناموفق بود",
+        "trustLabel": "CA مورد اعتماد (پنل والد)",
+        "trustHint": "وقتی این پنل خود یک نود است، CA پنل مدیریت‌کننده را اینجا بچسبانید تا گواهی کلاینت آن الزامی شود. برای اعمال، پنل را راه‌اندازی مجدد کنید.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "ذخیره CA مورد اعتماد",
+        "saved": "CA مورد اعتماد ذخیره شد — برای اعمال، پنل را راه‌اندازی مجدد کنید"
+      },
       "tlsSkipWarning": "رد کردن اعتبارسنجی محافظت در برابر حملهٔ مرد میانی را از بین می‌برد و توکن API ممکن است شنود شود. ترجیحاً به‌جای آن گواهی را Pin کنید.",
       "pinnedCert": "SHA-256 گواهیِ Pin‌شده",
       "pinnedCertHint": "SHA-256 گواهیِ نود به‌صورت base64 یا hex. برای خواندنِ همین حالا از نود، از دکمهٔ Fetch استفاده کنید.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "خطا در دریافت ترافیک خروجی",
         "resetOutboundTrafficError": "خطا در بازنشانی ترافیک خروجی"
       },
-      "emailNotifications": "اعلان‌ها",
+      "smtpSettings": "تنظیمات SMTP",
+      "smtpEnable": "فعال‌سازی اعلان‌های ایمیلی",
+      "smtpEnableDesc": "فعال‌سازی اعلان‌های ایمیلی از طریق SMTP",
+      "smtpHost": "میزبان SMTP",
+      "smtpHostDesc": "نام میزبان سرور SMTP (مثلاً smtp.gmail.com)",
+      "smtpPort": "پورت SMTP",
+      "smtpPortDesc": "پورت سرور SMTP (پیش‌فرض: ۵۸۷)",
+      "smtpUsername": "نام‌کاربری SMTP",
+      "smtpUsernameDesc": "نام‌کاربری احراز هویت SMTP",
+      "smtpPassword": "رمز عبور SMTP",
+      "smtpPasswordDesc": "رمز عبور احراز هویت SMTP",
+      "smtpTo": "گیرندگان",
+      "smtpToDesc": "آدرس‌های ایمیل گیرندگان، جداشده با کاما",
       "emailSettings": "ایمیل",
-      "eventCPUHigh": "بالا بودن CPU (٪)",
+      "emailNotifications": "اعلان‌ها",
+      "smtpEventBusNotify": "اعلان‌های رویداد ایمیلی",
+      "smtpEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان ایمیلی را فعال می‌کنند",
+      "tgEventBusNotify": "اعلان‌های رویداد تلگرام",
+      "tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال می‌کنند",
+      "testSmtp": "ارسال ایمیل آزمایشی",
+      "testTgBot": "ارسال پیام آزمایشی",
       "eventGroupOutbound": "خروجی",
-      "eventGroupSecurity": "امنیت",
-      "eventGroupSystem": "سیستم",
       "eventGroupXray": "هسته Xray",
-      "eventLoginAttempt": "تلاش برای ورود",
+      "eventGroupSystem": "سیستم",
+      "eventGroupSecurity": "امنیت",
+      "eventGroupNode": "نودها",
       "eventOutboundDown": "قطع",
       "eventOutboundUp": "وصل",
       "eventXrayCrash": "کرش",
+      "eventNodeDown": "قطع",
+      "eventNodeUp": "وصل",
+      "eventCPUHigh": "بالا بودن CPU (٪)",
       "requestFailed": "درخواست ناموفق بود",
-      "smtpEnable": "فعال‌سازی اعلان‌های ایمیلی",
-      "smtpEnableDesc": "فعال‌سازی اعلان‌های ایمیلی از طریق SMTP",
       "smtpEncryption": "رمزنگاری",
       "smtpEncryptionDesc": "روش رمزنگاری اتصال SMTP",
       "smtpEncryptionNone": "هیچ‌کدام (متن ساده)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (ضمنی)",
-      "smtpEventBusNotify": "اعلان‌های رویداد ایمیلی",
-      "smtpEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان ایمیلی را فعال می‌کنند",
-      "smtpHost": "میزبان SMTP",
-      "smtpHostDesc": "نام میزبان سرور SMTP (مثلاً smtp.gmail.com)",
-      "smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
-      "smtpNoRecipients": "هیچ گیرنده‌ای پیکربندی نشده است",
-      "smtpNotInitialized": "SMTP مقداردهی اولیه نشده است",
-      "smtpPassword": "رمز عبور SMTP",
-      "smtpPasswordDesc": "رمز عبور احراز هویت SMTP",
-      "smtpPort": "پورت SMTP",
-      "smtpPortDesc": "پورت سرور SMTP (پیش‌فرض: ۵۸۷)",
-      "smtpSettings": "تنظیمات SMTP",
-      "smtpStageAuth": "احراز هویت",
       "smtpStageConnect": "اتصال",
+      "smtpStageAuth": "احراز هویت",
       "smtpStageSend": "ارسال",
       "smtpTestSuccess": "ایمیل آزمایشی با موفقیت ارسال شد",
-      "smtpTo": "گیرندگان",
-      "smtpToDesc": "آدرس‌های ایمیل گیرندگان، جداشده با کاما",
-      "smtpUsername": "نام‌کاربری SMTP",
-      "smtpUsernameDesc": "نام‌کاربری احراز هویت SMTP",
+      "smtpHostNotConfigured": "میزبان SMTP پیکربندی نشده است",
+      "smtpNoRecipients": "هیچ گیرنده‌ای پیکربندی نشده است",
+      "eventLoginAttempt": "تلاش برای ورود",
       "telegramTokenConfigured": "پیکربندی شده؛ برای حفظ توکن فعلی خالی بگذارید.",
       "telegramTokenPlaceholder": "پیکربندی شده - برای جایگزینی، توکن جدید وارد کنید",
-      "testSmtp": "ارسال ایمیل آزمایشی",
-      "testTgBot": "ارسال پیام آزمایشی",
+      "smtpPasswordConfigured": "پیکربندی شده؛ برای حفظ رمز عبور فعلی خالی بگذارید.",
+      "smtpPasswordPlaceholder": "پیکربندی شده - برای جایگزینی، رمز عبور جدید وارد کنید",
+      "smtpNotInitialized": "SMTP مقداردهی اولیه نشده است",
       "tgBotNotEnabled": "ربات تلگرام فعال نیست",
-      "tgBotNotRunning": "ربات تلگرام در حال اجرا نیست",
-      "tgEventBusNotify": "اعلان‌های رویداد تلگرام",
-      "tgEventBusNotifyDesc": "انتخاب کنید کدام رویدادها اعلان تلگرام را فعال می‌کنند",
       "tgTestFailed": "آزمایش تلگرام ناموفق بود",
       "tgTestSuccess": "پیام آزمایشی به تلگرام ارسال شد",
+      "tgBotNotRunning": "ربات تلگرام در حال اجرا نیست",
       "smtpErrorAuth": "احراز هویت ناموفق بود — نام‌کاربری و رمز عبور را بررسی کنید",
       "smtpErrorStarttls": "سرور به STARTTLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
       "smtpErrorTls": "سرور به TLS نیاز دارد — نوع رمزنگاری را تغییر دهید",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "مهلت اتصال به پایان رسید — میزبان در دسترس نیست",
       "smtpErrorRelay": "سرور ارسال از این آدرس را رد می‌کند",
       "smtpErrorEof": "اتصال توسط سرور بسته شد",
-      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}",
-      "eventGroupNode": "نودها",
-      "eventNodeDown": "قطع",
-      "eventNodeUp": "وصل",
-      "smtpPasswordConfigured": "پیکربندی شده؛ برای حفظ رمز عبور فعلی خالی بگذارید.",
-      "smtpPasswordPlaceholder": "پیکربندی شده - برای جایگزینی، رمز عبور جدید وارد کنید"
+      "smtpErrorUnknown": "خطای SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "پیکربندی ایکس‌ری",
@@ -1319,6 +1338,8 @@
       "Inbounds": "ورودی‌ها",
       "InboundsDesc": "پذیرش کلاینت خاص",
       "Outbounds": "خروجی‌ها",
+      "OutboundSubscriptions": "سابسکریپشن‌های خروجی",
+      "OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
       "Balancers": "بالانسرها",
       "balancerTagRequired": "تگ الزامی است",
       "balancerSelectorRequired": "حداقل یک خروجی انتخاب کنید",
@@ -1496,8 +1517,6 @@
         "privateKey": "کلید خصوصی",
         "load": "فشار سرور"
       },
-      "OutboundSubscriptions": "سابسکریپشن‌های خروجی",
-      "OutboundSubscriptionsDesc": "خروجی‌ها را از آدرس‌های سابسکریپشن راه‌دور (vmess/vless/trojan/ss/...) وارد کنید. تگ‌ها ثابت می‌مانند تا در بالانسرها و قوانین مسیریابی قابل استفاده باشند. به‌روزرسانی‌ها به‌صورت خودکار انجام می‌شوند.",
       "outboundSub": {
         "manage": "سابسکریپشن‌ها",
         "title": "سابسکریپشن‌های خروجی",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ✅ موفقیت‌آمیز",
       "FailedResetTraffic": "📧 ایمیل: {{ .ClientEmail }}\n🏁 نتیجه: ❌ ناموفق \n\n🛠️ خطا: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 فرآیند بازنشانی ترافیک برای همه مشتریان به پایان رسید.",
-      "eventCPUHigh": "بالا بودن CPU",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه",
-      "eventErrorDetail": "خطا: {{ .Error }}",
-      "eventLoginFallback": "ورود ناموفق از {{ .Source }}",
       "eventOutboundDown": "خروجی {{ .Tag }} قطع است",
       "eventOutboundUp": "خروجی {{ .Tag }} وصل است",
+      "eventErrorDetail": "خطا: {{ .Error }}",
+      "eventDelayDetail": "تأخیر: {{ .Delay }} میلی‌ثانیه",
       "eventXrayCrash": "Xray کرش کرد",
       "eventXrayCrashError": "خطا: {{ .Error }}",
       "eventNodeDown": "نود {{ .Name }} قطع است",
-      "eventNodeUp": "نود {{ .Name }} وصل است"
+      "eventNodeUp": "نود {{ .Name }} وصل است",
+      "eventCPUHigh": "بالا بودن CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "ورود ناموفق از {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ بستن کیبورد",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
+    "subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
+    "subjectXrayCrash": "Xray کرش کرد",
+    "subjectCPUHigh": "بالا بودن CPU",
+    "subjectLoginSuccess": "ورود موفق",
+    "subjectLoginFailed": "ورود ناموفق",
+    "titleOutboundDown": "خروجی قطع شد",
+    "titleOutboundUp": "خروجی وصل شد",
+    "titleXrayCrash": "Xray کرش کرد",
+    "titleCPUHigh": "بالا بودن CPU",
+    "titleLoginSuccess": "ورود موفق",
+    "titleLoginFailed": "ورود ناموفق",
+    "labelStatus": "وضعیت",
+    "labelOutbound": "خروجی",
+    "labelNode": "نود",
+    "labelError": "خطا",
     "labelDelay": "تأخیر",
     "labelDetail": "جزئیات",
-    "labelError": "خطا",
+    "labelUsername": "نام‌کاربری",
     "labelIP": "IP",
-    "labelOutbound": "خروجی",
     "labelReason": "دلیل",
     "labelSource": "مبدأ",
-    "labelStatus": "وضعیت",
     "labelTime": "زمان",
-    "labelUsername": "نام‌کاربری",
-    "statusBanned": "BANNED",
     "statusCrashed": "کرش کرد",
-    "statusDown": "قطع",
-    "statusFailed": "ناموفق",
-    "statusFull": "FULL",
-    "statusHigh": "بالا",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "در حال اجرا",
+    "statusHigh": "بالا",
     "statusSuccess": "موفق",
-    "statusUp": "وصل",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "بالا بودن CPU",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "ورود ناموفق",
-    "subjectLoginSuccess": "ورود موفق",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "خروجی {{ .Tag }} قطع است",
-    "subjectOutboundUp": "خروجی {{ .Tag }} وصل است",
-    "subjectXrayCrash": "Xray کرش کرد",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "بالا بودن CPU",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "ورود ناموفق",
-    "titleLoginSuccess": "ورود موفق",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "خروجی قطع شد",
-    "titleOutboundUp": "خروجی وصل شد",
-    "titleXrayCrash": "Xray کرش کرد",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "نود"
+    "statusFailed": "ناموفق",
+    "statusDown": "قطع",
+    "statusUp": "وصل"
   }
 }

+ 84 - 85
internal/web/translation/id-ID.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Klien inbound telah ditambahkan",
         "inboundClientDeleteSuccess": "Klien inbound telah dihapus",
         "inboundClientUpdateSuccess": "Klien inbound telah diperbarui",
+        "savedNodeOfflineWillSync": "Disimpan secara lokal. Node pendukung sedang offline atau dinonaktifkan — perubahan akan disinkronkan setelah terhubung kembali.",
         "delDepletedClientsSuccess": "Semua klien yang habis telah dihapus",
         "resetAllClientTrafficSuccess": "Semua lalu lintas klien telah direset",
         "resetAllTrafficSuccess": "Semua lalu lintas telah direset",
@@ -912,6 +913,8 @@
       "status": "Status",
       "cpu": "CPU",
       "mem": "Memori",
+      "netUp": "Upload Jaringan (KB/s)",
+      "netDown": "Download Jaringan (KB/s)",
       "uptime": "Uptime",
       "latency": "Latensi",
       "lastHeartbeat": "Heartbeat Terakhir",
@@ -953,13 +956,29 @@
         "probeFailed": "Probe gagal",
         "updateStarted": "Pembaruan panel dimulai",
         "updateResult": "Pembaruan dipicu pada {ok} node, {failed} gagal",
-        "updateNoneEligible": "Pilih minimal satu node online dan aktif"
+        "updateNoneEligible": "Pilih minimal satu node online dan aktif",
+        "saveMtls": "Simpan mTLS node"
       },
       "tlsVerifyMode": "Verifikasi TLS",
       "tlsVerifyModeHint": "Cara panel memvalidasi sertifikat HTTPS node. Pin atau Lewati untuk sertifikat self-signed (hanya node https).",
       "tlsVerify": "Verifikasi (CA bawaan)",
       "tlsPin": "Pin sertifikat (SHA-256)",
       "tlsSkip": "Lewati verifikasi",
+      "tlsMtls": "TLS mutual (sertifikat klien)",
+      "mtlsFormHint": "Node ini mengautentikasi panel dengan sertifikat klien. Salin CA panel ini dari bagian mTLS Node ke node, atur CA tepercayanya, lalu mulai ulang.",
+      "mtls": {
+        "title": "mTLS Node",
+        "intro": "TLS mutual menambahkan faktor sertifikat klien di atas token API untuk panggilan antar-node. Bersifat opsional: biarkan kosong untuk tetap menggunakan autentikasi token saja.",
+        "copyCa": "Salin CA panel ini",
+        "copyCaHint": "Berikan CA ini ke node yang dikelola panel ini, lalu atur verifikasi TLS mereka ke TLS mutual.",
+        "caCopied": "Sertifikat CA disalin ke papan klip",
+        "caFailed": "Gagal memperoleh sertifikat CA",
+        "trustLabel": "CA induk tepercaya",
+        "trustHint": "Jika panel ini sendiri merupakan node, tempelkan CA panel pengelola di sini untuk mewajibkan sertifikat kliennya. Mulai ulang panel untuk menerapkan.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Simpan CA tepercaya",
+        "saved": "CA tepercaya disimpan — mulai ulang panel untuk menerapkan"
+      },
       "tlsSkipWarning": "Melewati verifikasi menghilangkan perlindungan terhadap serangan man-in-the-middle — token API bisa disadap. Lebih baik pin sertifikat.",
       "pinnedCert": "SHA-256 sertifikat yang dipin",
       "pinnedCertHint": "SHA-256 sertifikat node dalam base64 atau hex. Gunakan Ambil untuk membacanya dari node sekarang.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "Gagal mendapatkan lalu lintas keluar",
         "resetOutboundTrafficError": "Gagal mereset lalu lintas keluar"
       },
-      "emailNotifications": "Notifikasi",
+      "smtpSettings": "Pengaturan SMTP",
+      "smtpEnable": "Aktifkan Notifikasi Email",
+      "smtpEnableDesc": "Aktifkan notifikasi email melalui SMTP",
+      "smtpHost": "Host SMTP",
+      "smtpHostDesc": "Nama host server SMTP (mis. smtp.gmail.com)",
+      "smtpPort": "Port SMTP",
+      "smtpPortDesc": "Port server SMTP (bawaan: 587)",
+      "smtpUsername": "Nama Pengguna SMTP",
+      "smtpUsernameDesc": "Nama pengguna autentikasi SMTP",
+      "smtpPassword": "Kata Sandi SMTP",
+      "smtpPasswordDesc": "Kata sandi autentikasi SMTP",
+      "smtpTo": "Penerima",
+      "smtpToDesc": "Alamat email penerima dipisahkan dengan koma",
       "emailSettings": "Email",
-      "eventCPUHigh": "CPU tinggi (%)",
+      "emailNotifications": "Notifikasi",
+      "smtpEventBusNotify": "Notifikasi Peristiwa Email",
+      "smtpEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi email",
+      "tgEventBusNotify": "Notifikasi Peristiwa Telegram",
+      "tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram",
+      "testSmtp": "Kirim Email Uji",
+      "testTgBot": "Kirim Pesan Uji",
       "eventGroupOutbound": "Outbound",
-      "eventGroupSecurity": "Keamanan",
-      "eventGroupSystem": "Sistem",
       "eventGroupXray": "Xray Core",
-      "eventLoginAttempt": "Percobaan masuk",
+      "eventGroupSystem": "Sistem",
+      "eventGroupSecurity": "Keamanan",
+      "eventGroupNode": "Node",
       "eventOutboundDown": "Mati",
       "eventOutboundUp": "Aktif",
       "eventXrayCrash": "Crash",
+      "eventNodeDown": "Mati",
+      "eventNodeUp": "Aktif",
+      "eventCPUHigh": "CPU tinggi (%)",
       "requestFailed": "Permintaan gagal",
-      "smtpEnable": "Aktifkan Notifikasi Email",
-      "smtpEnableDesc": "Aktifkan notifikasi email melalui SMTP",
       "smtpEncryption": "Enkripsi",
       "smtpEncryptionDesc": "Metode enkripsi koneksi SMTP",
       "smtpEncryptionNone": "Tidak ada (teks biasa)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (implisit)",
-      "smtpEventBusNotify": "Notifikasi Peristiwa Email",
-      "smtpEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi email",
-      "smtpHost": "Host SMTP",
-      "smtpHostDesc": "Nama host server SMTP (mis. smtp.gmail.com)",
-      "smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
-      "smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
-      "smtpNotInitialized": "SMTP belum diinisialisasi",
-      "smtpPassword": "Kata Sandi SMTP",
-      "smtpPasswordDesc": "Kata sandi autentikasi SMTP",
-      "smtpPort": "Port SMTP",
-      "smtpPortDesc": "Port server SMTP (bawaan: 587)",
-      "smtpSettings": "Pengaturan SMTP",
-      "smtpStageAuth": "Autentikasi",
       "smtpStageConnect": "Koneksi",
+      "smtpStageAuth": "Autentikasi",
       "smtpStageSend": "Kirim",
       "smtpTestSuccess": "Email uji berhasil dikirim",
-      "smtpTo": "Penerima",
-      "smtpToDesc": "Alamat email penerima dipisahkan dengan koma",
-      "smtpUsername": "Nama Pengguna SMTP",
-      "smtpUsernameDesc": "Nama pengguna autentikasi SMTP",
+      "smtpHostNotConfigured": "Host SMTP belum dikonfigurasi",
+      "smtpNoRecipients": "Tidak ada penerima yang dikonfigurasi",
+      "eventLoginAttempt": "Percobaan masuk",
       "telegramTokenConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan token saat ini.",
       "telegramTokenPlaceholder": "Terkonfigurasi - masukkan token baru untuk mengganti",
-      "testSmtp": "Kirim Email Uji",
-      "testTgBot": "Kirim Pesan Uji",
+      "smtpPasswordConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan kata sandi saat ini.",
+      "smtpPasswordPlaceholder": "Terkonfigurasi - masukkan kata sandi baru untuk mengganti",
+      "smtpNotInitialized": "SMTP belum diinisialisasi",
       "tgBotNotEnabled": "Bot Telegram tidak aktif",
-      "tgBotNotRunning": "Bot Telegram tidak berjalan",
-      "tgEventBusNotify": "Notifikasi Peristiwa Telegram",
-      "tgEventBusNotifyDesc": "Pilih peristiwa yang memicu notifikasi Telegram",
       "tgTestFailed": "Uji Telegram gagal",
       "tgTestSuccess": "Pesan uji terkirim ke Telegram",
+      "tgBotNotRunning": "Bot Telegram tidak berjalan",
       "smtpErrorAuth": "Autentikasi gagal — periksa nama pengguna dan kata sandi",
       "smtpErrorStarttls": "Server memerlukan STARTTLS — ubah jenis enkripsi",
       "smtpErrorTls": "Server memerlukan TLS — ubah jenis enkripsi",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Koneksi waktu habis — host tidak dapat dijangkau",
       "smtpErrorRelay": "Server menolak pengiriman dari alamat ini",
       "smtpErrorEof": "Koneksi ditutup oleh server",
-      "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}",
-      "eventGroupNode": "Node",
-      "eventNodeDown": "Mati",
-      "eventNodeUp": "Aktif",
-      "smtpPasswordConfigured": "Terkonfigurasi; kosongkan untuk mempertahankan kata sandi saat ini.",
-      "smtpPasswordPlaceholder": "Terkonfigurasi - masukkan kata sandi baru untuk mengganti"
+      "smtpErrorUnknown": "Kesalahan SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Konfigurasi Xray",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Inbound",
       "InboundsDesc": "Menerima klien tertentu.",
       "Outbounds": "Outbound",
+      "OutboundSubscriptions": "Langganan Outbound",
+      "OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
       "Balancers": "Penyeimbang",
       "balancerTagRequired": "Tag wajib diisi",
       "balancerSelectorRequired": "Pilih setidaknya satu outbound",
@@ -1496,8 +1517,6 @@
         "privateKey": "Kunci Privat",
         "load": "Beban"
       },
-      "OutboundSubscriptions": "Langganan Outbound",
-      "OutboundSubscriptionsDesc": "Impor outbound dari URL langganan jarak jauh (vmess/vless/trojan/ss/...). Tag dijaga tetap stabil untuk digunakan pada penyeimbang dan aturan routing. Pembaruan berjalan otomatis.",
       "outboundSub": {
         "manage": "Langganan",
         "title": "Langganan Outbound",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ✅ Berhasil",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Hasil: ❌ Gagal \n\n🛠️ Kesalahan: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Proses reset traffic selesai untuk semua klien.",
-      "eventCPUHigh": "CPU tinggi",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Penundaan: {{ .Delay }}ms",
-      "eventErrorDetail": "Kesalahan: {{ .Error }}",
-      "eventLoginFallback": "Gagal masuk dari {{ .Source }}",
       "eventOutboundDown": "Outbound {{ .Tag }} MATI",
       "eventOutboundUp": "Outbound {{ .Tag }} AKTIF",
+      "eventErrorDetail": "Kesalahan: {{ .Error }}",
+      "eventDelayDetail": "Penundaan: {{ .Delay }}ms",
       "eventXrayCrash": "Xray CRASH",
       "eventXrayCrashError": "Kesalahan: {{ .Error }}",
       "eventNodeDown": "Node {{ .Name }} MATI",
-      "eventNodeUp": "Node {{ .Name }} AKTIF"
+      "eventNodeUp": "Node {{ .Name }} AKTIF",
+      "eventCPUHigh": "CPU tinggi",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Gagal masuk dari {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Tutup Papan Ketik",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "Outbound {{ .Tag }} MATI",
+    "subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
+    "subjectXrayCrash": "Xray CRASH",
+    "subjectCPUHigh": "CPU tinggi",
+    "subjectLoginSuccess": "Berhasil masuk",
+    "subjectLoginFailed": "Gagal masuk",
+    "titleOutboundDown": "Outbound MATI",
+    "titleOutboundUp": "Outbound AKTIF",
+    "titleXrayCrash": "Xray CRASH",
+    "titleCPUHigh": "CPU tinggi",
+    "titleLoginSuccess": "Berhasil masuk",
+    "titleLoginFailed": "Gagal masuk",
+    "labelStatus": "Status",
+    "labelOutbound": "Outbound",
+    "labelNode": "Node",
+    "labelError": "Kesalahan",
     "labelDelay": "Penundaan",
     "labelDetail": "Detail",
-    "labelError": "Kesalahan",
+    "labelUsername": "Nama Pengguna",
     "labelIP": "IP",
-    "labelOutbound": "Outbound",
     "labelReason": "Alasan",
     "labelSource": "Sumber",
-    "labelStatus": "Status",
     "labelTime": "Waktu",
-    "labelUsername": "Nama Pengguna",
-    "statusBanned": "BANNED",
     "statusCrashed": "CRASH",
-    "statusDown": "MATI",
-    "statusFailed": "GAGAL",
-    "statusFull": "FULL",
-    "statusHigh": "TINGGI",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "Berjalan",
+    "statusHigh": "TINGGI",
     "statusSuccess": "BERHASIL",
-    "statusUp": "AKTIF",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU tinggi",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Gagal masuk",
-    "subjectLoginSuccess": "Berhasil masuk",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "Outbound {{ .Tag }} MATI",
-    "subjectOutboundUp": "Outbound {{ .Tag }} AKTIF",
-    "subjectXrayCrash": "Xray CRASH",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU tinggi",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Gagal masuk",
-    "titleLoginSuccess": "Berhasil masuk",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Outbound MATI",
-    "titleOutboundUp": "Outbound AKTIF",
-    "titleXrayCrash": "Xray CRASH",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Node"
+    "statusFailed": "GAGAL",
+    "statusDown": "MATI",
+    "statusUp": "AKTIF"
   }
 }

+ 84 - 85
internal/web/translation/ja-JP.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "インバウンドクライアントが追加されました",
         "inboundClientDeleteSuccess": "インバウンドクライアントが削除されました",
         "inboundClientUpdateSuccess": "インバウンドクライアントが更新されました",
+        "savedNodeOfflineWillSync": "ローカルに保存しました。バックエンドのノードがオフラインまたは無効になっています — 再接続後に変更が同期されます。",
         "delDepletedClientsSuccess": "すべての枯渇したクライアントが削除されました",
         "resetAllClientTrafficSuccess": "クライアントのすべてのトラフィックがリセットされました",
         "resetAllTrafficSuccess": "すべてのトラフィックがリセットされました",
@@ -912,6 +913,8 @@
       "status": "ステータス",
       "cpu": "CPU",
       "mem": "メモリ",
+      "netUp": "送信 (KB/s)",
+      "netDown": "受信 (KB/s)",
       "uptime": "稼働時間",
       "latency": "レイテンシ",
       "lastHeartbeat": "最後のハートビート",
@@ -953,13 +956,29 @@
         "probeFailed": "プローブに失敗しました",
         "updateStarted": "パネルの更新を開始しました",
         "updateResult": "{ok} 個のノードで更新を開始、{failed} 個失敗",
-        "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください"
+        "updateNoneEligible": "オンラインで有効なノードを少なくとも1つ選択してください",
+        "saveMtls": "ノード mTLS を保存"
       },
       "tlsVerifyMode": "TLS 検証",
       "tlsVerifyModeHint": "パネルがノードの HTTPS 証明書を検証する方法。ピン留めやスキップは自己署名証明書向け(https ノードのみ)。",
       "tlsVerify": "検証(既定の CA)",
       "tlsPin": "証明書をピン留め(SHA-256)",
       "tlsSkip": "検証をスキップ",
+      "tlsMtls": "相互 TLS(クライアント証明書)",
+      "mtlsFormHint": "このノードはクライアント証明書でパネルを認証します。「ノード mTLS」セクションからこのパネルの CA をノードにコピーし、信頼する CA を設定してから再起動してください。",
+      "mtls": {
+        "title": "ノード mTLS",
+        "intro": "相互 TLS は、ノード間通信で API トークンに加えてクライアント証明書による認証を追加します。任意です。空のままにするとトークンのみの認証になります。",
+        "copyCa": "このパネルの CA をコピー",
+        "copyCaHint": "この CA を、このパネルが管理するノードに渡し、それらの TLS 検証を相互 TLS に設定してください。",
+        "caCopied": "CA 証明書をクリップボードにコピーしました",
+        "caFailed": "CA 証明書を取得できませんでした",
+        "trustLabel": "信頼する親 CA",
+        "trustHint": "このパネル自体がノードである場合は、管理元パネルの CA をここに貼り付けて、そのクライアント証明書を必須にします。適用するにはパネルを再起動してください。",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "信頼する CA を保存",
+        "saved": "信頼する CA を保存しました — 適用するにはパネルを再起動してください"
+      },
       "tlsSkipWarning": "検証をスキップすると中間者攻撃への保護がなくなり、API トークンが傍受される恐れがあります。証明書のピン留めを推奨します。",
       "pinnedCert": "ピン留め証明書の SHA-256",
       "pinnedCertHint": "ノード証明書の SHA-256(base64 または hex)。「取得」でノードから今すぐ読み取れます。",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "送信トラフィックの取得エラー",
         "resetOutboundTrafficError": "送信トラフィックのリセットエラー"
       },
-      "emailNotifications": "通知",
+      "smtpSettings": "SMTP設定",
+      "smtpEnable": "メール通知を有効化",
+      "smtpEnableDesc": "SMTP経由のメール通知を有効にします",
+      "smtpHost": "SMTPホスト",
+      "smtpHostDesc": "SMTPサーバーのホスト名(例: smtp.gmail.com)",
+      "smtpPort": "SMTPポート",
+      "smtpPortDesc": "SMTPサーバーのポート(既定値: 587)",
+      "smtpUsername": "SMTPユーザー名",
+      "smtpUsernameDesc": "SMTP認証用のユーザー名",
+      "smtpPassword": "SMTPパスワード",
+      "smtpPasswordDesc": "SMTP認証用のパスワード",
+      "smtpTo": "受信者",
+      "smtpToDesc": "受信者のメールアドレス(カンマ区切り)",
       "emailSettings": "メール",
-      "eventCPUHigh": "CPU高負荷(%)",
+      "emailNotifications": "通知",
+      "smtpEventBusNotify": "メールイベント通知",
+      "smtpEventBusNotifyDesc": "メール通知をトリガーするイベントを選択してください",
+      "tgEventBusNotify": "Telegramイベント通知",
+      "tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください",
+      "testSmtp": "テストメールを送信",
+      "testTgBot": "テストメッセージを送信",
       "eventGroupOutbound": "アウトバウンド",
-      "eventGroupSecurity": "セキュリティ",
-      "eventGroupSystem": "システム",
       "eventGroupXray": "Xrayコア",
-      "eventLoginAttempt": "ログイン試行",
+      "eventGroupSystem": "システム",
+      "eventGroupSecurity": "セキュリティ",
+      "eventGroupNode": "ノード",
       "eventOutboundDown": "ダウン",
       "eventOutboundUp": "アップ",
       "eventXrayCrash": "クラッシュ",
+      "eventNodeDown": "ダウン",
+      "eventNodeUp": "アップ",
+      "eventCPUHigh": "CPU高負荷(%)",
       "requestFailed": "リクエストに失敗しました",
-      "smtpEnable": "メール通知を有効化",
-      "smtpEnableDesc": "SMTP経由のメール通知を有効にします",
       "smtpEncryption": "暗号化",
       "smtpEncryptionDesc": "SMTP接続の暗号化方式",
       "smtpEncryptionNone": "なし(平文)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS(暗黙的)",
-      "smtpEventBusNotify": "メールイベント通知",
-      "smtpEventBusNotifyDesc": "メール通知をトリガーするイベントを選択してください",
-      "smtpHost": "SMTPホスト",
-      "smtpHostDesc": "SMTPサーバーのホスト名(例: smtp.gmail.com)",
-      "smtpHostNotConfigured": "SMTPホストが設定されていません",
-      "smtpNoRecipients": "受信者が設定されていません",
-      "smtpNotInitialized": "SMTPが初期化されていません",
-      "smtpPassword": "SMTPパスワード",
-      "smtpPasswordDesc": "SMTP認証用のパスワード",
-      "smtpPort": "SMTPポート",
-      "smtpPortDesc": "SMTPサーバーのポート(既定値: 587)",
-      "smtpSettings": "SMTP設定",
-      "smtpStageAuth": "認証",
       "smtpStageConnect": "接続",
+      "smtpStageAuth": "認証",
       "smtpStageSend": "送信",
       "smtpTestSuccess": "テストメールを正常に送信しました",
-      "smtpTo": "受信者",
-      "smtpToDesc": "受信者のメールアドレス(カンマ区切り)",
-      "smtpUsername": "SMTPユーザー名",
-      "smtpUsernameDesc": "SMTP認証用のユーザー名",
+      "smtpHostNotConfigured": "SMTPホストが設定されていません",
+      "smtpNoRecipients": "受信者が設定されていません",
+      "eventLoginAttempt": "ログイン試行",
       "telegramTokenConfigured": "設定済み。現在のトークンを維持する場合は空欄のままにしてください。",
       "telegramTokenPlaceholder": "設定済み - 置き換えるには新しいトークンを入力してください",
-      "testSmtp": "テストメールを送信",
-      "testTgBot": "テストメッセージを送信",
+      "smtpPasswordConfigured": "設定済み。現在のパスワードを維持する場合は空欄のままにしてください。",
+      "smtpPasswordPlaceholder": "設定済み - 置き換えるには新しいパスワードを入力してください",
+      "smtpNotInitialized": "SMTPが初期化されていません",
       "tgBotNotEnabled": "Telegramボットが有効になっていません",
-      "tgBotNotRunning": "Telegramボットが実行されていません",
-      "tgEventBusNotify": "Telegramイベント通知",
-      "tgEventBusNotifyDesc": "Telegram通知をトリガーするイベントを選択してください",
       "tgTestFailed": "Telegramのテストに失敗しました",
       "tgTestSuccess": "Telegramにテストメッセージを送信しました",
+      "tgBotNotRunning": "Telegramボットが実行されていません",
       "smtpErrorAuth": "認証に失敗しました — ユーザー名とパスワードを確認してください",
       "smtpErrorStarttls": "サーバーはSTARTTLSを要求しています — 暗号化方式を変更してください",
       "smtpErrorTls": "サーバーはTLSを要求しています — 暗号化方式を変更してください",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "接続がタイムアウトしました — ホストに到達できません",
       "smtpErrorRelay": "サーバーはこのアドレスからの送信を拒否しています",
       "smtpErrorEof": "サーバーによって接続が閉じられました",
-      "smtpErrorUnknown": "SMTPエラー: {{ .Error }}",
-      "eventGroupNode": "ノード",
-      "eventNodeDown": "ダウン",
-      "eventNodeUp": "アップ",
-      "smtpPasswordConfigured": "設定済み。現在のパスワードを維持する場合は空欄のままにしてください。",
-      "smtpPasswordPlaceholder": "設定済み - 置き換えるには新しいパスワードを入力してください"
+      "smtpErrorUnknown": "SMTPエラー: {{ .Error }}"
     },
     "xray": {
       "title": "Xray 設定",
@@ -1319,6 +1338,8 @@
       "Inbounds": "インバウンド",
       "InboundsDesc": "特定のクライアントからのトラフィックを受け入れる",
       "Outbounds": "アウトバウンド",
+      "OutboundSubscriptions": "アウトバウンドサブスクリプション",
+      "OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
       "Balancers": "負荷分散",
       "balancerTagRequired": "タグは必須です",
       "balancerSelectorRequired": "アウトバウンドを少なくとも1つ選んでください",
@@ -1496,8 +1517,6 @@
         "privateKey": "秘密鍵",
         "load": "負荷"
       },
-      "OutboundSubscriptions": "アウトバウンドサブスクリプション",
-      "OutboundSubscriptionsDesc": "リモートのサブスクリプションURL(vmess/vless/trojan/ss/...)からアウトバウンドをインポートします。タグはバランサーやルーティングルールで使えるように安定して保持されます。更新は自動的に行われます。",
       "outboundSub": {
         "manage": "サブスクリプション",
         "title": "アウトバウンドサブスクリプション",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
       "FailedResetTraffic": "📧 メール: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ エラー: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 すべてのクライアントのトラフィックリセットが完了しました。",
-      "eventCPUHigh": "CPU高負荷",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "遅延: {{ .Delay }}ms",
-      "eventErrorDetail": "エラー: {{ .Error }}",
-      "eventLoginFallback": "{{ .Source }} からのログインに失敗しました",
       "eventOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
       "eventOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
+      "eventErrorDetail": "エラー: {{ .Error }}",
+      "eventDelayDetail": "遅延: {{ .Delay }}ms",
       "eventXrayCrash": "Xrayがクラッシュしました",
       "eventXrayCrashError": "エラー: {{ .Error }}",
       "eventNodeDown": "ノード {{ .Name }} がダウンしています",
-      "eventNodeUp": "ノード {{ .Name }} が復旧しました"
+      "eventNodeUp": "ノード {{ .Name }} が復旧しました",
+      "eventCPUHigh": "CPU高負荷",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "{{ .Source }} からのログインに失敗しました"
     },
     "buttons": {
       "closeKeyboard": "❌ キーボードを閉じる",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
+    "subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
+    "subjectXrayCrash": "Xrayがクラッシュしました",
+    "subjectCPUHigh": "CPU高負荷",
+    "subjectLoginSuccess": "ログイン成功",
+    "subjectLoginFailed": "ログイン失敗",
+    "titleOutboundDown": "アウトバウンド ダウン",
+    "titleOutboundUp": "アウトバウンド 復旧",
+    "titleXrayCrash": "Xrayがクラッシュしました",
+    "titleCPUHigh": "CPU高負荷",
+    "titleLoginSuccess": "ログイン成功",
+    "titleLoginFailed": "ログイン失敗",
+    "labelStatus": "ステータス",
+    "labelOutbound": "アウトバウンド",
+    "labelNode": "ノード",
+    "labelError": "エラー",
     "labelDelay": "遅延",
     "labelDetail": "詳細",
-    "labelError": "エラー",
+    "labelUsername": "ユーザー名",
     "labelIP": "IP",
-    "labelOutbound": "アウトバウンド",
     "labelReason": "理由",
     "labelSource": "送信元",
-    "labelStatus": "ステータス",
     "labelTime": "時刻",
-    "labelUsername": "ユーザー名",
-    "statusBanned": "BANNED",
     "statusCrashed": "クラッシュ",
-    "statusDown": "ダウン",
-    "statusFailed": "失敗",
-    "statusFull": "FULL",
-    "statusHigh": "高負荷",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "実行中",
+    "statusHigh": "高負荷",
     "statusSuccess": "成功",
-    "statusUp": "アップ",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU高負荷",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "ログイン失敗",
-    "subjectLoginSuccess": "ログイン成功",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "アウトバウンド {{ .Tag }} がダウンしています",
-    "subjectOutboundUp": "アウトバウンド {{ .Tag }} が復旧しました",
-    "subjectXrayCrash": "Xrayがクラッシュしました",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU高負荷",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "ログイン失敗",
-    "titleLoginSuccess": "ログイン成功",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "アウトバウンド ダウン",
-    "titleOutboundUp": "アウトバウンド 復旧",
-    "titleXrayCrash": "Xrayがクラッシュしました",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "ノード"
+    "statusFailed": "失敗",
+    "statusDown": "ダウン",
+    "statusUp": "アップ"
   }
 }

+ 84 - 85
internal/web/translation/pt-BR.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Cliente(s) de entrada adicionado(s)",
         "inboundClientDeleteSuccess": "Cliente de entrada excluído",
         "inboundClientUpdateSuccess": "Cliente de entrada atualizado",
+        "savedNodeOfflineWillSync": "Salvo localmente. Um nó de apoio está offline ou desativado — a alteração será sincronizada assim que reconectar.",
         "delDepletedClientsSuccess": "Todos os clientes esgotados foram excluídos",
         "resetAllClientTrafficSuccess": "Todo o tráfego do cliente foi reiniciado",
         "resetAllTrafficSuccess": "Todo o tráfego foi reiniciado",
@@ -912,6 +913,8 @@
       "status": "Status",
       "cpu": "CPU",
       "mem": "Memória",
+      "netUp": "Subida de rede (KB/s)",
+      "netDown": "Descida de rede (KB/s)",
       "uptime": "Tempo ativo",
       "latency": "Latência",
       "lastHeartbeat": "Último heartbeat",
@@ -953,13 +956,29 @@
         "probeFailed": "Falha na sondagem",
         "updateStarted": "Atualização do painel iniciada",
         "updateResult": "Atualização iniciada em {ok} nó(s), {failed} falharam",
-        "updateNoneEligible": "Selecione pelo menos um nó online e ativo"
+        "updateNoneEligible": "Selecione pelo menos um nó online e ativo",
+        "saveMtls": "Salvar mTLS do nó"
       },
       "tlsVerifyMode": "Verificação TLS",
       "tlsVerifyModeHint": "Como o painel valida o certificado HTTPS do nó. Fixar ou Ignorar são para certificados autoassinados (apenas nós https).",
       "tlsVerify": "Verificar (CA padrão)",
       "tlsPin": "Fixar certificado (SHA-256)",
       "tlsSkip": "Ignorar verificação",
+      "tlsMtls": "TLS mútuo (certificado de cliente)",
+      "mtlsFormHint": "Este nó autentica o painel com um certificado de cliente. Copie o CA deste painel na seção mTLS do nó para o nó, defina o CA confiável dele e reinicie-o.",
+      "mtls": {
+        "title": "mTLS do nó",
+        "intro": "O TLS mútuo adiciona um fator de certificado de cliente além do token de API nas chamadas entre nós. É opcional: deixe vazio para manter apenas a autenticação por token.",
+        "copyCa": "Copiar o CA deste painel",
+        "copyCaHint": "Entregue este CA aos nós gerenciados por este painel e defina a verificação TLS deles como TLS mútuo.",
+        "caCopied": "Certificado CA copiado para a área de transferência",
+        "caFailed": "Falha ao obter o certificado CA",
+        "trustLabel": "CA confiável (painel superior)",
+        "trustHint": "Quando este painel também é um nó, cole aqui o CA do painel que o gerencia para exigir seu certificado de cliente. Reinicie o painel para aplicar.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Salvar CA confiável",
+        "saved": "CA confiável salvo — reinicie o painel para aplicar"
+      },
       "tlsSkipWarning": "Ignorar a verificação remove a proteção contra ataques man-in-the-middle — o token de API pode ser interceptado. Prefira fixar o certificado.",
       "pinnedCert": "SHA-256 do certificado fixado",
       "pinnedCertHint": "SHA-256 do certificado do nó em base64 ou hex. Use Obter para lê-lo do nó agora.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "Erro ao obter tráfego de saída",
         "resetOutboundTrafficError": "Erro ao redefinir tráfego de saída"
       },
-      "emailNotifications": "Notificações",
+      "smtpSettings": "Configurações SMTP",
+      "smtpEnable": "Ativar notificações por e-mail",
+      "smtpEnableDesc": "Ativar notificações por e-mail via SMTP",
+      "smtpHost": "Servidor SMTP",
+      "smtpHostDesc": "Nome do host do servidor SMTP (ex.: smtp.gmail.com)",
+      "smtpPort": "Porta SMTP",
+      "smtpPortDesc": "Porta do servidor SMTP (padrão: 587)",
+      "smtpUsername": "Usuário SMTP",
+      "smtpUsernameDesc": "Nome de usuário para autenticação SMTP",
+      "smtpPassword": "Senha SMTP",
+      "smtpPasswordDesc": "Senha para autenticação SMTP",
+      "smtpTo": "Destinatários",
+      "smtpToDesc": "Endereços de e-mail dos destinatários separados por vírgula",
       "emailSettings": "E-mail",
-      "eventCPUHigh": "CPU alta (%)",
+      "emailNotifications": "Notificações",
+      "smtpEventBusNotify": "Notificações de eventos por e-mail",
+      "smtpEventBusNotifyDesc": "Selecione quais eventos disparam notificações por e-mail",
+      "tgEventBusNotify": "Notificações de eventos no Telegram",
+      "tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram",
+      "testSmtp": "Enviar e-mail de teste",
+      "testTgBot": "Enviar mensagem de teste",
       "eventGroupOutbound": "Outbound",
-      "eventGroupSecurity": "Segurança",
-      "eventGroupSystem": "Sistema",
       "eventGroupXray": "Núcleo Xray",
-      "eventLoginAttempt": "Tentativa de login",
+      "eventGroupSystem": "Sistema",
+      "eventGroupSecurity": "Segurança",
+      "eventGroupNode": "Nós",
       "eventOutboundDown": "Inativo",
       "eventOutboundUp": "Ativo",
       "eventXrayCrash": "Falha",
+      "eventNodeDown": "Inativo",
+      "eventNodeUp": "Ativo",
+      "eventCPUHigh": "CPU alta (%)",
       "requestFailed": "Falha na requisição",
-      "smtpEnable": "Ativar notificações por e-mail",
-      "smtpEnableDesc": "Ativar notificações por e-mail via SMTP",
       "smtpEncryption": "Criptografia",
       "smtpEncryptionDesc": "Método de criptografia da conexão SMTP",
       "smtpEncryptionNone": "Nenhuma (texto puro)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (implícito)",
-      "smtpEventBusNotify": "Notificações de eventos por e-mail",
-      "smtpEventBusNotifyDesc": "Selecione quais eventos disparam notificações por e-mail",
-      "smtpHost": "Servidor SMTP",
-      "smtpHostDesc": "Nome do host do servidor SMTP (ex.: smtp.gmail.com)",
-      "smtpHostNotConfigured": "Servidor SMTP não configurado",
-      "smtpNoRecipients": "Nenhum destinatário configurado",
-      "smtpNotInitialized": "SMTP não inicializado",
-      "smtpPassword": "Senha SMTP",
-      "smtpPasswordDesc": "Senha para autenticação SMTP",
-      "smtpPort": "Porta SMTP",
-      "smtpPortDesc": "Porta do servidor SMTP (padrão: 587)",
-      "smtpSettings": "Configurações SMTP",
-      "smtpStageAuth": "Autenticação",
       "smtpStageConnect": "Conexão",
+      "smtpStageAuth": "Autenticação",
       "smtpStageSend": "Envio",
       "smtpTestSuccess": "E-mail de teste enviado com sucesso",
-      "smtpTo": "Destinatários",
-      "smtpToDesc": "Endereços de e-mail dos destinatários separados por vírgula",
-      "smtpUsername": "Usuário SMTP",
-      "smtpUsernameDesc": "Nome de usuário para autenticação SMTP",
+      "smtpHostNotConfigured": "Servidor SMTP não configurado",
+      "smtpNoRecipients": "Nenhum destinatário configurado",
+      "eventLoginAttempt": "Tentativa de login",
       "telegramTokenConfigured": "Configurado; deixe em branco para manter o token atual.",
       "telegramTokenPlaceholder": "Configurado - insira um novo token para substituir",
-      "testSmtp": "Enviar e-mail de teste",
-      "testTgBot": "Enviar mensagem de teste",
+      "smtpPasswordConfigured": "Configurada; deixe em branco para manter a senha atual.",
+      "smtpPasswordPlaceholder": "Configurada - insira uma nova senha para substituir",
+      "smtpNotInitialized": "SMTP não inicializado",
       "tgBotNotEnabled": "O bot do Telegram não está ativado",
-      "tgBotNotRunning": "O bot do Telegram não está em execução",
-      "tgEventBusNotify": "Notificações de eventos no Telegram",
-      "tgEventBusNotifyDesc": "Selecione quais eventos disparam notificações no Telegram",
       "tgTestFailed": "Falha no teste do Telegram",
       "tgTestSuccess": "Mensagem de teste enviada ao Telegram",
+      "tgBotNotRunning": "O bot do Telegram não está em execução",
       "smtpErrorAuth": "Falha na autenticação — verifique o nome de usuário e a senha",
       "smtpErrorStarttls": "O servidor requer STARTTLS — altere o tipo de criptografia",
       "smtpErrorTls": "O servidor requer TLS — altere o tipo de criptografia",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Tempo de conexão esgotado — host inacessível",
       "smtpErrorRelay": "O servidor rejeita o envio a partir deste endereço",
       "smtpErrorEof": "Conexão encerrada pelo servidor",
-      "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}",
-      "eventGroupNode": "Nós",
-      "eventNodeDown": "Inativo",
-      "eventNodeUp": "Ativo",
-      "smtpPasswordConfigured": "Configurada; deixe em branco para manter a senha atual.",
-      "smtpPasswordPlaceholder": "Configurada - insira uma nova senha para substituir"
+      "smtpErrorUnknown": "Erro de SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Configurações Xray",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Entradas",
       "InboundsDesc": "Aceitar clientes específicos.",
       "Outbounds": "Saídas",
+      "OutboundSubscriptions": "Assinaturas de Saída",
+      "OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
       "Balancers": "Balanceadores",
       "balancerTagRequired": "A tag é obrigatória",
       "balancerSelectorRequired": "Selecione pelo menos uma saída",
@@ -1496,8 +1517,6 @@
         "privateKey": "Chave Privada",
         "load": "Carga"
       },
-      "OutboundSubscriptions": "Assinaturas de Saída",
-      "OutboundSubscriptionsDesc": "Importe saídas a partir de URLs de assinatura remotas (vmess/vless/trojan/ss/...). As tags são mantidas estáveis para uso em balanceadores e regras de roteamento. As atualizações são automáticas.",
       "outboundSub": {
         "manage": "Assinaturas",
         "title": "Assinaturas de Saída",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ✅ Sucesso",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Resultado: ❌ Falhou \n\n🛠️ Erro: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Processo de redefinição de tráfego concluído para todos os clientes.",
-      "eventCPUHigh": "CPU alta",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Latência: {{ .Delay }}ms",
-      "eventErrorDetail": "Erro: {{ .Error }}",
-      "eventLoginFallback": "Falha de login a partir de {{ .Source }}",
       "eventOutboundDown": "O outbound {{ .Tag }} está INATIVO",
       "eventOutboundUp": "O outbound {{ .Tag }} está ATIVO",
+      "eventErrorDetail": "Erro: {{ .Error }}",
+      "eventDelayDetail": "Latência: {{ .Delay }}ms",
       "eventXrayCrash": "O Xray FALHOU",
       "eventXrayCrashError": "Erro: {{ .Error }}",
       "eventNodeDown": "O nó {{ .Name }} está INATIVO",
-      "eventNodeUp": "O nó {{ .Name }} está ATIVO"
+      "eventNodeUp": "O nó {{ .Name }} está ATIVO",
+      "eventCPUHigh": "CPU alta",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Falha de login a partir de {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Fechar teclado",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
+    "subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
+    "subjectXrayCrash": "O Xray FALHOU",
+    "subjectCPUHigh": "CPU alta",
+    "subjectLoginSuccess": "Login bem-sucedido",
+    "subjectLoginFailed": "Falha de login",
+    "titleOutboundDown": "Outbound INATIVO",
+    "titleOutboundUp": "Outbound ATIVO",
+    "titleXrayCrash": "O Xray FALHOU",
+    "titleCPUHigh": "CPU alta",
+    "titleLoginSuccess": "Login bem-sucedido",
+    "titleLoginFailed": "Falha de login",
+    "labelStatus": "Status",
+    "labelOutbound": "Outbound",
+    "labelNode": "Nó",
+    "labelError": "Erro",
     "labelDelay": "Latência",
     "labelDetail": "Detalhe",
-    "labelError": "Erro",
+    "labelUsername": "Nome de usuário",
     "labelIP": "IP",
-    "labelOutbound": "Outbound",
     "labelReason": "Motivo",
     "labelSource": "Origem",
-    "labelStatus": "Status",
     "labelTime": "Horário",
-    "labelUsername": "Nome de usuário",
-    "statusBanned": "BANNED",
     "statusCrashed": "FALHOU",
-    "statusDown": "INATIVO",
-    "statusFailed": "FALHOU",
-    "statusFull": "FULL",
-    "statusHigh": "ALTA",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "Em execução",
+    "statusHigh": "ALTA",
     "statusSuccess": "SUCESSO",
-    "statusUp": "ATIVO",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU alta",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Falha de login",
-    "subjectLoginSuccess": "Login bem-sucedido",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "O outbound {{ .Tag }} está INATIVO",
-    "subjectOutboundUp": "O outbound {{ .Tag }} está ATIVO",
-    "subjectXrayCrash": "O Xray FALHOU",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU alta",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Falha de login",
-    "titleLoginSuccess": "Login bem-sucedido",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Outbound INATIVO",
-    "titleOutboundUp": "Outbound ATIVO",
-    "titleXrayCrash": "O Xray FALHOU",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Nó"
+    "statusFailed": "FALHOU",
+    "statusDown": "INATIVO",
+    "statusUp": "ATIVO"
   }
 }

+ 35 - 16
internal/web/translation/ru-RU.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Клиент(ы) подключения добавлен(ы)",
         "inboundClientDeleteSuccess": "Клиент подключения удалён",
         "inboundClientUpdateSuccess": "Клиент подключения обновлён",
+        "savedNodeOfflineWillSync": "Сохранено локально. Опорный узел отключён или недоступен — изменение синхронизируется после повторного подключения.",
         "delDepletedClientsSuccess": "Все исчерпанные клиенты удалены",
         "resetAllClientTrafficSuccess": "Весь трафик клиента сброшен",
         "resetAllTrafficSuccess": "Весь трафик сброшен",
@@ -912,6 +913,8 @@
       "status": "Статус",
       "cpu": "CPU",
       "mem": "Память",
+      "netUp": "Отдача (KB/s)",
+      "netDown": "Приём (KB/s)",
       "uptime": "Аптайм",
       "latency": "Задержка",
       "lastHeartbeat": "Последний пинг",
@@ -953,13 +956,29 @@
         "probeFailed": "Проверка не удалась",
         "updateStarted": "Обновление панели запущено",
         "updateResult": "Обновление запущено на {ok} узлах, {failed} не удалось",
-        "updateNoneEligible": "Выберите хотя бы один включённый узел в сети"
+        "updateNoneEligible": "Выберите хотя бы один включённый узел в сети",
+        "saveMtls": "Сохранить mTLS узла"
       },
       "tlsVerifyMode": "Проверка TLS",
       "tlsVerifyModeHint": "Как панель проверяет HTTPS-сертификат узла. Закрепление или Пропуск — для самоподписанных сертификатов (только https-узлы).",
       "tlsVerify": "Проверять (стандартный CA)",
       "tlsPin": "Закрепить сертификат (SHA-256)",
       "tlsSkip": "Пропустить проверку",
+      "tlsMtls": "Взаимный TLS (клиентский сертификат)",
+      "mtlsFormHint": "Этот узел аутентифицирует панель с помощью клиентского сертификата. Скопируйте CA этой панели из раздела «mTLS узла» на узел, задайте его доверенный CA и перезапустите узел.",
+      "mtls": {
+        "title": "mTLS узла",
+        "intro": "Взаимный TLS добавляет фактор клиентского сертификата поверх API-токена для вызовов между узлами. Это необязательно: оставьте пустым, чтобы использовать только аутентификацию по токену.",
+        "copyCa": "Скопировать CA этой панели",
+        "copyCaHint": "Передайте этот CA узлам, которыми управляет эта панель, затем установите их режим проверки TLS на «Взаимный TLS».",
+        "caCopied": "Сертификат CA скопирован в буфер обмена",
+        "caFailed": "Не удалось получить сертификат CA",
+        "trustLabel": "Доверенный CA (родительская панель)",
+        "trustHint": "Если эта панель сама является узлом, вставьте сюда CA управляющей панели, чтобы требовать её клиентский сертификат. Перезапустите панель для применения.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Сохранить доверенный CA",
+        "saved": "Доверенный CA сохранён — перезапустите панель для применения"
+      },
       "tlsSkipWarning": "Пропуск проверки убирает защиту от атак «человек посередине» — токен API может быть перехвачен. Лучше закрепить сертификат.",
       "pinnedCert": "SHA-256 закреплённого сертификата",
       "pinnedCertHint": "SHA-256 сертификата узла в base64 или hex. Нажмите «Получить», чтобы считать его с узла сейчас.",
@@ -1235,9 +1254,12 @@
       "eventGroupXray": "Ядро Xray",
       "eventGroupSystem": "Система",
       "eventGroupSecurity": "Безопасность",
+      "eventGroupNode": "Узлы",
       "eventOutboundDown": "Недоступен",
       "eventOutboundUp": "Работает",
       "eventXrayCrash": "Сбой",
+      "eventNodeDown": "Недоступен",
+      "eventNodeUp": "В сети",
       "eventCPUHigh": "Превышение порога CPU (%)",
       "requestFailed": "Запрос не удался",
       "smtpEncryption": "Шифрование",
@@ -1254,6 +1276,8 @@
       "eventLoginAttempt": "Попытка входа",
       "telegramTokenConfigured": "Настроен; оставьте пустым для сохранения текущего токена.",
       "telegramTokenPlaceholder": "Настроен - введите новый токен для замены",
+      "smtpPasswordConfigured": "Настроен; оставьте пустым для сохранения текущего пароля.",
+      "smtpPasswordPlaceholder": "Настроен - введите новый пароль для замены",
       "smtpNotInitialized": "SMTP не инициализирован",
       "tgBotNotEnabled": "Telegram бот не включен",
       "tgTestFailed": "Тест Telegram не удался",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Таймаут соединения — хост недоступен",
       "smtpErrorRelay": "Сервер отклоняет отправку с этого адреса",
       "smtpErrorEof": "Соединение закрыто сервером",
-      "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}",
-      "eventGroupNode": "Узлы",
-      "eventNodeDown": "Недоступен",
-      "eventNodeUp": "В сети",
-      "smtpPasswordConfigured": "Настроен; оставьте пустым для сохранения текущего пароля.",
-      "smtpPasswordPlaceholder": "Настроен - введите новый пароль для замены"
+      "smtpErrorUnknown": "Ошибка SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Настройки Xray",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Входящие",
       "InboundsDesc": "Изменение шаблона конфигурации для подключения определенных клиентов",
       "Outbounds": "Исходящие",
+      "OutboundSubscriptions": "Подписки исходящих",
+      "OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
       "Balancers": "Балансировщик",
       "balancerTagRequired": "Тег обязателен",
       "balancerSelectorRequired": "Выберите хотя бы одно исходящее",
@@ -1496,8 +1517,6 @@
         "privateKey": "Приватный ключ",
         "load": "Нагрузка"
       },
-      "OutboundSubscriptions": "Подписки исходящих",
-      "OutboundSubscriptionsDesc": "Импорт исходящих из удалённых URL подписок (vmess/vless/trojan/ss/...). Теги остаются неизменными для использования в балансировщиках и правилах маршрутизации. Обновление выполняется автоматически.",
       "outboundSub": {
         "manage": "Подписки",
         "title": "Подписки исходящих",
@@ -1777,15 +1796,15 @@
       "FinishProcess": "🔚 Сброс трафика завершён для всех клиентов.",
       "eventOutboundDown": "Исходящее подключение {{ .Tag }} НЕДОСТУПНО",
       "eventOutboundUp": "Исходящее подключение {{ .Tag }} РАБОТАЕТ",
+      "eventErrorDetail": "Ошибка: {{ .Error }}",
+      "eventDelayDetail": "Задержка: {{ .Delay }} мс",
       "eventXrayCrash": "Сбой Xray",
       "eventXrayCrashError": "Ошибка: {{ .Error }}",
+      "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
+      "eventNodeUp": "Узел {{ .Name }} В СЕТИ",
       "eventCPUHigh": "Высокая загрузка CPU",
       "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventLoginFallback": "Неудачный вход с {{ .Source }}",
-      "eventDelayDetail": "Задержка: {{ .Delay }} мс",
-      "eventErrorDetail": "Ошибка: {{ .Error }}",
-      "eventNodeDown": "Узел {{ .Name }} НЕДОСТУПЕН",
-      "eventNodeUp": "Узел {{ .Name }} В СЕТИ"
+      "eventLoginFallback": "Неудачный вход с {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Закрыть клавиатуру",
@@ -1871,6 +1890,7 @@
     "titleLoginFailed": "Неудачный вход",
     "labelStatus": "Статус",
     "labelOutbound": "Исходящее подключение",
+    "labelNode": "Узел",
     "labelError": "Ошибка",
     "labelDelay": "Задержка",
     "labelDetail": "Подробности",
@@ -1885,7 +1905,6 @@
     "statusSuccess": "УСПЕШНО",
     "statusFailed": "НЕУДАЧНО",
     "statusDown": "НЕДОСТУПЕН",
-    "statusUp": "РАБОТАЕТ",
-    "labelNode": "Узел"
+    "statusUp": "РАБОТАЕТ"
   }
 }

+ 84 - 84
internal/web/translation/tr-TR.json

@@ -913,6 +913,8 @@
       "status": "Durum",
       "cpu": "CPU",
       "mem": "Bellek",
+      "netUp": "Ağ Yükleme (KB/s)",
+      "netDown": "Ağ İndirme (KB/s)",
       "uptime": "Çalışma Süresi",
       "latency": "Gecikme",
       "lastHeartbeat": "Son Sinyal",
@@ -938,7 +940,9 @@
       "statusValues": {
         "online": "Çevrimiçi",
         "offline": "Çevrimdışı",
-        "unknown": "Bilinmiyor"
+        "unknown": "Bilinmiyor",
+        "xrayError": "Xray Hatası",
+        "xrayStopped": "Durduruldu"
       },
       "toasts": {
         "list": "Düğümler yüklenemedi",
@@ -952,13 +956,29 @@
         "probeFailed": "Test başarısız",
         "updateStarted": "Panel güncellemesi başlatıldı",
         "updateResult": "{ok} düğümde güncelleme başlatıldı, {failed} başarısız",
-        "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin"
+        "updateNoneEligible": "En az bir çevrimiçi ve etkin düğüm seçin",
+        "saveMtls": "Düğüm mTLS kaydet"
       },
       "tlsVerifyMode": "TLS Doğrulaması",
       "tlsVerifyModeHint": "Panelin düğümün HTTPS sertifikasını nasıl doğrulayacağını belirler. Sabitle veya Atla, kendinden imzalı sertifikalar içindir (yalnızca https düğümleri).",
       "tlsVerify": "Doğrula (varsayılan CA)",
       "tlsPin": "Sertifikayı Sabitle (SHA-256)",
       "tlsSkip": "Doğrulamayı Atla",
+      "tlsMtls": "Karşılıklı TLS (istemci sertifikası)",
+      "mtlsFormHint": "Bu düğüm, paneli bir istemci sertifikasıyla doğrular. Düğüm mTLS bölümünden bu panelin CA’sını düğüme kopyalayın, güvenilen CA’sını ayarlayın ve ardından düğümü yeniden başlatın.",
+      "mtls": {
+        "title": "Düğüm mTLS",
+        "intro": "Karşılıklı TLS, düğümler arası çağrılarda API belirtecinin yanına bir istemci sertifikası faktörü ekler. İsteğe bağlıdır: yalnızca belirteçle kimlik doğrulamayı sürdürmek için boş bırakın.",
+        "copyCa": "Bu panelin CA’sını kopyala",
+        "copyCaHint": "Bu CA’yı bu panelin yönettiği düğümlere verin ve ardından TLS doğrulamalarını Karşılıklı TLS olarak ayarlayın.",
+        "caCopied": "CA sertifikası panoya kopyalandı",
+        "caFailed": "CA sertifikası alınamadı",
+        "trustLabel": "Güvenilen üst CA",
+        "trustHint": "Bu panel kendisi bir düğümse, istemci sertifikasını zorunlu kılmak için onu yöneten panelin CA’sını buraya yapıştırın. Uygulamak için paneli yeniden başlatın.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Güvenilen CA’yı kaydet",
+        "saved": "Güvenilen CA kaydedildi — uygulamak için paneli yeniden başlatın"
+      },
       "tlsSkipWarning": "Doğrulamayı atlamak, ortadaki adam (MITM) saldırılarına karşı korumayı kaldırır — API anahtarı ele geçirilebilir. Bunun yerine sertifikayı sabitlemeniz önerilir.",
       "pinnedCert": "Sabitlenen Sertifika SHA-256",
       "pinnedCertHint": "Düğüm sertifikasının base64 veya hex biçiminde SHA-256 değeri. Şimdi düğümden okumak için Getir'i kullanın.",
@@ -1209,55 +1229,60 @@
         "getOutboundTrafficError": "Giden trafik alınırken hata oluştu.",
         "resetOutboundTrafficError": "Giden trafik sıfırlanırken hata oluştu."
       },
-      "emailNotifications": "Bildirimler",
+      "smtpSettings": "SMTP Ayarları",
+      "smtpEnable": "E-posta Bildirimlerini Etkinleştir",
+      "smtpEnableDesc": "SMTP üzerinden e-posta bildirimlerini etkinleştirin",
+      "smtpHost": "SMTP Sunucusu",
+      "smtpHostDesc": "SMTP sunucu ana bilgisayar adı (örn. smtp.gmail.com)",
+      "smtpPort": "SMTP Bağlantı Noktası",
+      "smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)",
+      "smtpUsername": "SMTP Kullanıcı Adı",
+      "smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı",
+      "smtpPassword": "SMTP Parolası",
+      "smtpPasswordDesc": "SMTP kimlik doğrulama parolası",
+      "smtpTo": "Alıcılar",
+      "smtpToDesc": "Virgülle ayrılmış alıcı e-posta adresleri",
       "emailSettings": "E-posta",
-      "eventCPUHigh": "Yüksek CPU (%)",
+      "emailNotifications": "Bildirimler",
+      "smtpEventBusNotify": "E-posta Olay Bildirimleri",
+      "smtpEventBusNotifyDesc": "Hangi olayların e-posta bildirimi tetikleyeceğini seçin",
+      "tgEventBusNotify": "Telegram Olay Bildirimleri",
+      "tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin",
+      "testSmtp": "Test E-postası Gönder",
+      "testTgBot": "Test Mesajı Gönder",
       "eventGroupOutbound": "Giden Bağlantı",
-      "eventGroupSecurity": "Güvenlik",
-      "eventGroupSystem": "Sistem",
       "eventGroupXray": "Xray Çekirdeği",
-      "eventLoginAttempt": "Oturum açma denemesi",
+      "eventGroupSystem": "Sistem",
+      "eventGroupSecurity": "Güvenlik",
+      "eventGroupNode": "Düğümler",
       "eventOutboundDown": "Çevrimdışı",
       "eventOutboundUp": "Çevrimiçi",
       "eventXrayCrash": "Çökme",
+      "eventNodeDown": "Çevrimdışı",
+      "eventNodeUp": "Çevrimiçi",
+      "eventCPUHigh": "Yüksek CPU (%)",
       "requestFailed": "İstek başarısız oldu",
-      "smtpEnable": "E-posta Bildirimlerini Etkinleştir",
-      "smtpEnableDesc": "SMTP üzerinden e-posta bildirimlerini etkinleştirin",
       "smtpEncryption": "Şifreleme",
       "smtpEncryptionDesc": "SMTP bağlantı şifreleme yöntemi",
       "smtpEncryptionNone": "Yok (düz metin)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (örtük)",
-      "smtpEventBusNotify": "E-posta Olay Bildirimleri",
-      "smtpEventBusNotifyDesc": "Hangi olayların e-posta bildirimi tetikleyeceğini seçin",
-      "smtpHost": "SMTP Sunucusu",
-      "smtpHostDesc": "SMTP sunucu ana bilgisayar adı (örn. smtp.gmail.com)",
-      "smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
-      "smtpNoRecipients": "Yapılandırılmış alıcı yok",
-      "smtpNotInitialized": "SMTP başlatılmadı",
-      "smtpPassword": "SMTP Parolası",
-      "smtpPasswordDesc": "SMTP kimlik doğrulama parolası",
-      "smtpPort": "SMTP Bağlantı Noktası",
-      "smtpPortDesc": "SMTP sunucu bağlantı noktası (varsayılan: 587)",
-      "smtpSettings": "SMTP Ayarları",
-      "smtpStageAuth": "Kimlik Doğrulama",
       "smtpStageConnect": "Bağlantı",
+      "smtpStageAuth": "Kimlik Doğrulama",
       "smtpStageSend": "Gönderim",
       "smtpTestSuccess": "Test e-postası başarıyla gönderildi",
-      "smtpTo": "Alıcılar",
-      "smtpToDesc": "Virgülle ayrılmış alıcı e-posta adresleri",
-      "smtpUsername": "SMTP Kullanıcı Adı",
-      "smtpUsernameDesc": "SMTP kimlik doğrulama kullanıcı adı",
+      "smtpHostNotConfigured": "SMTP sunucusu yapılandırılmamış",
+      "smtpNoRecipients": "Yapılandırılmış alıcı yok",
+      "eventLoginAttempt": "Oturum açma denemesi",
       "telegramTokenConfigured": "Yapılandırıldı; mevcut belirteci korumak için boş bırakın.",
       "telegramTokenPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir belirteç girin",
-      "testSmtp": "Test E-postası Gönder",
-      "testTgBot": "Test Mesajı Gönder",
+      "smtpPasswordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
+      "smtpPasswordPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir parola girin",
+      "smtpNotInitialized": "SMTP başlatılmadı",
       "tgBotNotEnabled": "Telegram botu etkin değil",
-      "tgBotNotRunning": "Telegram botu çalışmıyor",
-      "tgEventBusNotify": "Telegram Olay Bildirimleri",
-      "tgEventBusNotifyDesc": "Hangi olayların Telegram bildirimi tetikleyeceğini seçin",
       "tgTestFailed": "Telegram testi başarısız oldu",
       "tgTestSuccess": "Test mesajı Telegram'a gönderildi",
+      "tgBotNotRunning": "Telegram botu çalışmıyor",
       "smtpErrorAuth": "Kimlik doğrulama başarısız — kullanıcı adını ve parolayı kontrol edin",
       "smtpErrorStarttls": "Sunucu STARTTLS gerektiriyor — şifreleme türünü değiştirin",
       "smtpErrorTls": "Sunucu TLS gerektiriyor — şifreleme türünü değiştirin",
@@ -1265,12 +1290,7 @@
       "smtpErrorTimeout": "Bağlantı zaman aşımına uğradı — sunucuya ulaşılamıyor",
       "smtpErrorRelay": "Sunucu bu adresten gönderimi reddediyor",
       "smtpErrorEof": "Bağlantı sunucu tarafından kapatıldı",
-      "smtpErrorUnknown": "SMTP hatası: {{ .Error }}",
-      "eventGroupNode": "Düğümler",
-      "eventNodeDown": "Çevrimdışı",
-      "eventNodeUp": "Çevrimiçi",
-      "smtpPasswordConfigured": "Yapılandırıldı; mevcut parolayı korumak için boş bırakın.",
-      "smtpPasswordPlaceholder": "Yapılandırıldı - değiştirmek için yeni bir parola girin"
+      "smtpErrorUnknown": "SMTP hatası: {{ .Error }}"
     },
     "xray": {
       "title": "Xray Yapılandırmaları",
@@ -1774,17 +1794,17 @@
       "SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı",
       "FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı.",
-      "eventCPUHigh": "Yüksek CPU",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Gecikme: {{ .Delay }}ms",
-      "eventErrorDetail": "Hata: {{ .Error }}",
-      "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız",
       "eventOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
       "eventOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
+      "eventErrorDetail": "Hata: {{ .Error }}",
+      "eventDelayDetail": "Gecikme: {{ .Delay }}ms",
       "eventXrayCrash": "Xray ÇÖKTÜ",
       "eventXrayCrashError": "Hata: {{ .Error }}",
       "eventNodeDown": "{{ .Name }} düğümü ÇEVRİMDIŞI",
-      "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ"
+      "eventNodeUp": "{{ .Name }} düğümü ÇEVRİMİÇİ",
+      "eventCPUHigh": "Yüksek CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "{{ .Source }} adresinden oturum açma başarısız"
     },
     "buttons": {
       "closeKeyboard": "❌ Klavyeyi Kapat",
@@ -1856,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
+    "subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
+    "subjectXrayCrash": "Xray ÇÖKTÜ",
+    "subjectCPUHigh": "Yüksek CPU",
+    "subjectLoginSuccess": "Oturum açma başarılı",
+    "subjectLoginFailed": "Oturum açma başarısız",
+    "titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI",
+    "titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ",
+    "titleXrayCrash": "Xray ÇÖKTÜ",
+    "titleCPUHigh": "Yüksek CPU",
+    "titleLoginSuccess": "Oturum açma başarılı",
+    "titleLoginFailed": "Oturum açma başarısız",
+    "labelStatus": "Durum",
+    "labelOutbound": "Giden Bağlantı",
+    "labelNode": "Düğüm",
+    "labelError": "Hata",
     "labelDelay": "Gecikme",
     "labelDetail": "Ayrıntı",
-    "labelError": "Hata",
+    "labelUsername": "Kullanıcı Adı",
     "labelIP": "IP",
-    "labelOutbound": "Giden Bağlantı",
     "labelReason": "Neden",
     "labelSource": "Kaynak",
-    "labelStatus": "Durum",
     "labelTime": "Zaman",
-    "labelUsername": "Kullanıcı Adı",
-    "statusBanned": "BANNED",
     "statusCrashed": "ÇÖKTÜ",
-    "statusDown": "ÇEVRİMDIŞI",
-    "statusFailed": "BAŞARISIZ",
-    "statusFull": "FULL",
-    "statusHigh": "YÜKSEK",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "Çalışıyor",
+    "statusHigh": "YÜKSEK",
     "statusSuccess": "BAŞARILI",
-    "statusUp": "ÇEVRİMİÇİ",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "Yüksek CPU",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Oturum açma başarısız",
-    "subjectLoginSuccess": "Oturum açma başarılı",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "{{ .Tag }} giden bağlantısı ÇEVRİMDIŞI",
-    "subjectOutboundUp": "{{ .Tag }} giden bağlantısı ÇEVRİMİÇİ",
-    "subjectXrayCrash": "Xray ÇÖKTÜ",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "Yüksek CPU",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Oturum açma başarısız",
-    "titleLoginSuccess": "Oturum açma başarılı",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Giden Bağlantı ÇEVRİMDIŞI",
-    "titleOutboundUp": "Giden Bağlantı ÇEVRİMİÇİ",
-    "titleXrayCrash": "Xray ÇÖKTÜ",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Düğüm"
+    "statusFailed": "BAŞARISIZ",
+    "statusDown": "ÇEVRİMDIŞI",
+    "statusUp": "ÇEVRİMİÇİ"
   }
 }

+ 84 - 85
internal/web/translation/uk-UA.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Клієнт(и) вхідного підключення додано",
         "inboundClientDeleteSuccess": "Клієнта вхідного підключення видалено",
         "inboundClientUpdateSuccess": "Клієнта вхідного підключення оновлено",
+        "savedNodeOfflineWillSync": "Збережено локально. Опорний вузол вимкнено або недоступний — зміни синхронізуються після повторного підключення.",
         "delDepletedClientsSuccess": "Усі вичерпані клієнти видалені",
         "resetAllClientTrafficSuccess": "Весь трафік клієнта скинуто",
         "resetAllTrafficSuccess": "Весь трафік скинуто",
@@ -912,6 +913,8 @@
       "status": "Статус",
       "cpu": "CPU",
       "mem": "Пам'ять",
+      "netUp": "Вихідний (KB/s)",
+      "netDown": "Вхідний (KB/s)",
       "uptime": "Час роботи",
       "latency": "Затримка",
       "lastHeartbeat": "Останній пінг",
@@ -953,13 +956,29 @@
         "probeFailed": "Помилка перевірки",
         "updateStarted": "Оновлення панелі розпочато",
         "updateResult": "Оновлення запущено на {ok} вузлах, {failed} не вдалося",
-        "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі"
+        "updateNoneEligible": "Виберіть принаймні один увімкнений вузол у мережі",
+        "saveMtls": "Зберегти mTLS вузла"
       },
       "tlsVerifyMode": "Перевірка TLS",
       "tlsVerifyModeHint": "Як панель перевіряє HTTPS-сертифікат вузла. Закріплення або Пропуск — для самопідписаних сертифікатів (лише https-вузли).",
       "tlsVerify": "Перевіряти (стандартний CA)",
       "tlsPin": "Закріпити сертифікат (SHA-256)",
       "tlsSkip": "Пропустити перевірку",
+      "tlsMtls": "Взаємний TLS (клієнтський сертифікат)",
+      "mtlsFormHint": "Цей вузол автентифікує панель за допомогою клієнтського сертифіката. Скопіюйте CA цієї панелі з розділу «mTLS вузла» на вузол, задайте його довірений CA та перезапустіть вузол.",
+      "mtls": {
+        "title": "mTLS вузла",
+        "intro": "Взаємний TLS додає фактор клієнтського сертифіката поверх API-токена для викликів між вузлами. Це необов’язково: залиште порожнім, щоб використовувати лише автентифікацію за токеном.",
+        "copyCa": "Скопіювати CA цієї панелі",
+        "copyCaHint": "Передайте цей CA вузлам, якими керує ця панель, потім встановіть їхній режим перевірки TLS на «Взаємний TLS».",
+        "caCopied": "Сертифікат CA скопійовано в буфер обміну",
+        "caFailed": "Не вдалося отримати сертифікат CA",
+        "trustLabel": "Довірений CA (батьківська панель)",
+        "trustHint": "Якщо ця панель сама є вузлом, вставте сюди CA керуючої панелі, щоб вимагати її клієнтський сертифікат. Перезапустіть панель для застосування.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Зберегти довірений CA",
+        "saved": "Довірений CA збережено — перезапустіть панель для застосування"
+      },
       "tlsSkipWarning": "Пропуск перевірки прибирає захист від атак «людина посередині» — токен API можуть перехопити. Краще закріпити сертифікат.",
       "pinnedCert": "SHA-256 закріпленого сертифіката",
       "pinnedCertHint": "SHA-256 сертифіката вузла у base64 або hex. Натисніть «Отримати», щоб зчитати його з вузла зараз.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "Помилка отримання вихідного трафіку",
         "resetOutboundTrafficError": "Помилка скидання вихідного трафіку"
       },
-      "emailNotifications": "Сповіщення",
+      "smtpSettings": "Налаштування SMTP",
+      "smtpEnable": "Увімкнути сповіщення електронною поштою",
+      "smtpEnableDesc": "Увімкнути сповіщення електронною поштою через SMTP",
+      "smtpHost": "Хост SMTP",
+      "smtpHostDesc": "Ім'я хоста сервера SMTP (наприклад, smtp.gmail.com)",
+      "smtpPort": "Порт SMTP",
+      "smtpPortDesc": "Порт сервера SMTP (типово: 587)",
+      "smtpUsername": "Ім'я користувача SMTP",
+      "smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP",
+      "smtpPassword": "Пароль SMTP",
+      "smtpPasswordDesc": "Пароль для автентифікації SMTP",
+      "smtpTo": "Отримувачі",
+      "smtpToDesc": "Адреси електронної пошти отримувачів, розділені комами",
       "emailSettings": "Електронна пошта",
-      "eventCPUHigh": "Високе навантаження на CPU (%)",
+      "emailNotifications": "Сповіщення",
+      "smtpEventBusNotify": "Сповіщення про події електронною поштою",
+      "smtpEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення електронною поштою",
+      "tgEventBusNotify": "Сповіщення про події в Telegram",
+      "tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram",
+      "testSmtp": "Надіслати тестовий лист",
+      "testTgBot": "Надіслати тестове повідомлення",
       "eventGroupOutbound": "Вихідні з'єднання",
-      "eventGroupSecurity": "Безпека",
-      "eventGroupSystem": "Система",
       "eventGroupXray": "Ядро Xray",
-      "eventLoginAttempt": "Спроба входу",
+      "eventGroupSystem": "Система",
+      "eventGroupSecurity": "Безпека",
+      "eventGroupNode": "Вузли",
       "eventOutboundDown": "Недоступне",
       "eventOutboundUp": "Доступне",
       "eventXrayCrash": "Збій",
+      "eventNodeDown": "Недоступний",
+      "eventNodeUp": "Доступний",
+      "eventCPUHigh": "Високе навантаження на CPU (%)",
       "requestFailed": "Запит не вдалося виконати",
-      "smtpEnable": "Увімкнути сповіщення електронною поштою",
-      "smtpEnableDesc": "Увімкнути сповіщення електронною поштою через SMTP",
       "smtpEncryption": "Шифрування",
       "smtpEncryptionDesc": "Метод шифрування з'єднання SMTP",
       "smtpEncryptionNone": "Немає (відкритий текст)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (неявне)",
-      "smtpEventBusNotify": "Сповіщення про події електронною поштою",
-      "smtpEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення електронною поштою",
-      "smtpHost": "Хост SMTP",
-      "smtpHostDesc": "Ім'я хоста сервера SMTP (наприклад, smtp.gmail.com)",
-      "smtpHostNotConfigured": "Хост SMTP не налаштовано",
-      "smtpNoRecipients": "Отримувачів не налаштовано",
-      "smtpNotInitialized": "SMTP не ініціалізовано",
-      "smtpPassword": "Пароль SMTP",
-      "smtpPasswordDesc": "Пароль для автентифікації SMTP",
-      "smtpPort": "Порт SMTP",
-      "smtpPortDesc": "Порт сервера SMTP (типово: 587)",
-      "smtpSettings": "Налаштування SMTP",
-      "smtpStageAuth": "Автентифікація",
       "smtpStageConnect": "З'єднання",
+      "smtpStageAuth": "Автентифікація",
       "smtpStageSend": "Надсилання",
       "smtpTestSuccess": "Тестовий лист успішно надіслано",
-      "smtpTo": "Отримувачі",
-      "smtpToDesc": "Адреси електронної пошти отримувачів, розділені комами",
-      "smtpUsername": "Ім'я користувача SMTP",
-      "smtpUsernameDesc": "Ім'я користувача для автентифікації SMTP",
+      "smtpHostNotConfigured": "Хост SMTP не налаштовано",
+      "smtpNoRecipients": "Отримувачів не налаштовано",
+      "eventLoginAttempt": "Спроба входу",
       "telegramTokenConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний токен.",
       "telegramTokenPlaceholder": "Налаштовано — введіть новий токен для заміни",
-      "testSmtp": "Надіслати тестовий лист",
-      "testTgBot": "Надіслати тестове повідомлення",
+      "smtpPasswordConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний пароль.",
+      "smtpPasswordPlaceholder": "Налаштовано — введіть новий пароль для заміни",
+      "smtpNotInitialized": "SMTP не ініціалізовано",
       "tgBotNotEnabled": "Бот Telegram не увімкнено",
-      "tgBotNotRunning": "Бот Telegram не запущено",
-      "tgEventBusNotify": "Сповіщення про події в Telegram",
-      "tgEventBusNotifyDesc": "Виберіть, які події спричиняють сповіщення в Telegram",
       "tgTestFailed": "Тест Telegram не вдався",
       "tgTestSuccess": "Тестове повідомлення надіслано в Telegram",
+      "tgBotNotRunning": "Бот Telegram не запущено",
       "smtpErrorAuth": "Помилка автентифікації — перевірте ім'я користувача та пароль",
       "smtpErrorStarttls": "Сервер вимагає STARTTLS — змініть тип шифрування",
       "smtpErrorTls": "Сервер вимагає TLS — змініть тип шифрування",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Час очікування з'єднання вичерпано — хост недоступний",
       "smtpErrorRelay": "Сервер відхиляє надсилання з цієї адреси",
       "smtpErrorEof": "З'єднання закрито сервером",
-      "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}",
-      "eventGroupNode": "Вузли",
-      "eventNodeDown": "Недоступний",
-      "eventNodeUp": "Доступний",
-      "smtpPasswordConfigured": "Налаштовано; залиште порожнім, щоб зберегти поточний пароль.",
-      "smtpPasswordPlaceholder": "Налаштовано — введіть новий пароль для заміни"
+      "smtpErrorUnknown": "Помилка SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Xray конфігурації",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Вхідні",
       "InboundsDesc": "Прийняття певних клієнтів.",
       "Outbounds": "Вихідні",
+      "OutboundSubscriptions": "Підписки вихідних",
+      "OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
       "Balancers": "Балансери",
       "balancerTagRequired": "Тег обов'язковий",
       "balancerSelectorRequired": "Виберіть принаймні один вихідний",
@@ -1496,8 +1517,6 @@
         "privateKey": "Приватний ключ",
         "load": "Навантаження"
       },
-      "OutboundSubscriptions": "Підписки вихідних",
-      "OutboundSubscriptionsDesc": "Імпортуйте вихідні з віддалених URL підписок (vmess/vless/trojan/ss/...). Теги залишаються стабільними для використання в балансувальниках і правилах маршрутизації. Оновлення відбувається автоматично.",
       "outboundSub": {
         "manage": "Підписки",
         "title": "Підписки вихідних",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ✅ Успішно",
       "FailedResetTraffic": "📧 Електронна пошта: {{ .ClientEmail }}\n🏁 Результат: ❌ Невдача \n\n🛠️ Помилка: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Процес скидання трафіку завершено для всіх клієнтів.",
-      "eventCPUHigh": "Високе навантаження на CPU",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Затримка: {{ .Delay }} мс",
-      "eventErrorDetail": "Помилка: {{ .Error }}",
-      "eventLoginFallback": "Невдала спроба входу з {{ .Source }}",
       "eventOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
       "eventOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
+      "eventErrorDetail": "Помилка: {{ .Error }}",
+      "eventDelayDetail": "Затримка: {{ .Delay }} мс",
       "eventXrayCrash": "Стався збій Xray",
       "eventXrayCrashError": "Помилка: {{ .Error }}",
       "eventNodeDown": "Вузол {{ .Name }} НЕДОСТУПНИЙ",
-      "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ"
+      "eventNodeUp": "Вузол {{ .Name }} ДОСТУПНИЙ",
+      "eventCPUHigh": "Високе навантаження на CPU",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Невдала спроба входу з {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Закрити клавіатуру",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
+    "subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
+    "subjectXrayCrash": "Стався збій Xray",
+    "subjectCPUHigh": "Високе навантаження на CPU",
+    "subjectLoginSuccess": "Успішний вхід",
+    "subjectLoginFailed": "Невдалий вхід",
+    "titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ",
+    "titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ",
+    "titleXrayCrash": "Стався збій Xray",
+    "titleCPUHigh": "Високе навантаження на CPU",
+    "titleLoginSuccess": "Успішний вхід",
+    "titleLoginFailed": "Невдалий вхід",
+    "labelStatus": "Статус",
+    "labelOutbound": "Вихідне з'єднання",
+    "labelNode": "Вузол",
+    "labelError": "Помилка",
     "labelDelay": "Затримка",
     "labelDetail": "Деталі",
-    "labelError": "Помилка",
+    "labelUsername": "Ім'я користувача",
     "labelIP": "IP",
-    "labelOutbound": "Вихідне з'єднання",
     "labelReason": "Причина",
     "labelSource": "Джерело",
-    "labelStatus": "Статус",
     "labelTime": "Час",
-    "labelUsername": "Ім'я користувача",
-    "statusBanned": "BANNED",
     "statusCrashed": "ЗБІЙ",
-    "statusDown": "НЕДОСТУПНО",
-    "statusFailed": "НЕВДАЛО",
-    "statusFull": "FULL",
-    "statusHigh": "ВИСОКЕ",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "Працює",
+    "statusHigh": "ВИСОКЕ",
     "statusSuccess": "УСПІШНО",
-    "statusUp": "ДОСТУПНО",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "Високе навантаження на CPU",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Невдалий вхід",
-    "subjectLoginSuccess": "Успішний вхід",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "Вихідне з'єднання {{ .Tag }} НЕДОСТУПНЕ",
-    "subjectOutboundUp": "Вихідне з'єднання {{ .Tag }} ДОСТУПНЕ",
-    "subjectXrayCrash": "Стався збій Xray",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "Високе навантаження на CPU",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Невдалий вхід",
-    "titleLoginSuccess": "Успішний вхід",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Вихідне з'єднання НЕДОСТУПНЕ",
-    "titleOutboundUp": "Вихідне з'єднання ДОСТУПНЕ",
-    "titleXrayCrash": "Стався збій Xray",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Вузол"
+    "statusFailed": "НЕВДАЛО",
+    "statusDown": "НЕДОСТУПНО",
+    "statusUp": "ДОСТУПНО"
   }
 }

+ 84 - 85
internal/web/translation/vi-VN.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "Đã thêm client inbound",
         "inboundClientDeleteSuccess": "Đã xóa client inbound",
         "inboundClientUpdateSuccess": "Đã cập nhật client inbound",
+        "savedNodeOfflineWillSync": "Đã lưu cục bộ. Một nút hỗ trợ đang ngoại tuyến hoặc bị tắt — thay đổi sẽ được đồng bộ khi kết nối lại.",
         "delDepletedClientsSuccess": "Đã xóa tất cả client hết hạn",
         "resetAllClientTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng client",
         "resetAllTrafficSuccess": "Đã đặt lại toàn bộ lưu lượng",
@@ -912,6 +913,8 @@
       "status": "Trạng thái",
       "cpu": "CPU",
       "mem": "Bộ nhớ",
+      "netUp": "Mạng lên (KB/s)",
+      "netDown": "Mạng xuống (KB/s)",
       "uptime": "Thời gian hoạt động",
       "latency": "Độ trễ",
       "lastHeartbeat": "Heartbeat gần nhất",
@@ -953,13 +956,29 @@
         "probeFailed": "Kiểm tra thất bại",
         "updateStarted": "Đã bắt đầu cập nhật bảng điều khiển",
         "updateResult": "Đã kích hoạt cập nhật trên {ok} node, {failed} thất bại",
-        "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật"
+        "updateNoneEligible": "Chọn ít nhất một node trực tuyến và đang bật",
+        "saveMtls": "Lưu mTLS nút"
       },
       "tlsVerifyMode": "Xác minh TLS",
       "tlsVerifyModeHint": "Cách panel xác thực chứng chỉ HTTPS của node. Ghim hoặc Bỏ qua dành cho chứng chỉ tự ký (chỉ node https).",
       "tlsVerify": "Xác minh (CA mặc định)",
       "tlsPin": "Ghim chứng chỉ (SHA-256)",
       "tlsSkip": "Bỏ qua xác minh",
+      "tlsMtls": "TLS song phương (chứng chỉ máy khách)",
+      "mtlsFormHint": "Nút này xác thực bảng điều khiển bằng chứng chỉ máy khách. Sao chép CA của bảng điều khiển này từ mục mTLS nút sang nút, đặt CA tin cậy của nó, rồi khởi động lại.",
+      "mtls": {
+        "title": "mTLS nút",
+        "intro": "TLS song phương bổ sung yếu tố chứng chỉ máy khách bên cạnh token API cho các lệnh gọi giữa các nút. Đây là tùy chọn: để trống để chỉ dùng xác thực bằng token.",
+        "copyCa": "Sao chép CA của bảng điều khiển này",
+        "copyCaHint": "Cấp CA này cho các nút mà bảng điều khiển này quản lý, sau đó đặt chế độ xác minh TLS của chúng thành TLS song phương.",
+        "caCopied": "Đã sao chép chứng chỉ CA vào bộ nhớ tạm",
+        "caFailed": "Không lấy được chứng chỉ CA",
+        "trustLabel": "CA tin cậy (bảng điều khiển cha)",
+        "trustHint": "Khi bảng điều khiển này bản thân là một nút, hãy dán CA của bảng điều khiển quản lý vào đây để yêu cầu chứng chỉ máy khách của nó. Khởi động lại bảng điều khiển để áp dụng.",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "Lưu CA tin cậy",
+        "saved": "Đã lưu CA tin cậy — khởi động lại bảng điều khiển để áp dụng"
+      },
       "tlsSkipWarning": "Bỏ qua xác minh sẽ loại bỏ bảo vệ trước tấn công xen giữa — token API có thể bị chặn bắt. Nên ghim chứng chỉ thay vì vậy.",
       "pinnedCert": "SHA-256 của chứng chỉ đã ghim",
       "pinnedCertHint": "SHA-256 của chứng chỉ node ở dạng base64 hoặc hex. Dùng Lấy để đọc trực tiếp từ node.",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "Lỗi khi lấy lưu lượng truy cập đi",
         "resetOutboundTrafficError": "Lỗi khi đặt lại lưu lượng truy cập đi"
       },
-      "emailNotifications": "Thông báo",
+      "smtpSettings": "Cài đặt SMTP",
+      "smtpEnable": "Bật thông báo qua email",
+      "smtpEnableDesc": "Bật thông báo qua email bằng SMTP",
+      "smtpHost": "Máy chủ SMTP",
+      "smtpHostDesc": "Tên máy chủ SMTP (ví dụ: smtp.gmail.com)",
+      "smtpPort": "Cổng SMTP",
+      "smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)",
+      "smtpUsername": "Tên đăng nhập SMTP",
+      "smtpUsernameDesc": "Tên đăng nhập xác thực SMTP",
+      "smtpPassword": "Mật khẩu SMTP",
+      "smtpPasswordDesc": "Mật khẩu xác thực SMTP",
+      "smtpTo": "Người nhận",
+      "smtpToDesc": "Các địa chỉ email người nhận, phân cách bằng dấu phẩy",
       "emailSettings": "Email",
-      "eventCPUHigh": "CPU cao (%)",
+      "emailNotifications": "Thông báo",
+      "smtpEventBusNotify": "Thông báo sự kiện qua email",
+      "smtpEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua email",
+      "tgEventBusNotify": "Thông báo sự kiện qua Telegram",
+      "tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram",
+      "testSmtp": "Gửi email thử nghiệm",
+      "testTgBot": "Gửi tin nhắn thử nghiệm",
       "eventGroupOutbound": "Outbound",
-      "eventGroupSecurity": "Bảo mật",
-      "eventGroupSystem": "Hệ thống",
       "eventGroupXray": "Xray Core",
-      "eventLoginAttempt": "Lần thử đăng nhập",
+      "eventGroupSystem": "Hệ thống",
+      "eventGroupSecurity": "Bảo mật",
+      "eventGroupNode": "Node",
       "eventOutboundDown": "Ngừng hoạt động",
       "eventOutboundUp": "Hoạt động",
       "eventXrayCrash": "Sự cố",
+      "eventNodeDown": "Ngừng hoạt động",
+      "eventNodeUp": "Hoạt động",
+      "eventCPUHigh": "CPU cao (%)",
       "requestFailed": "Yêu cầu thất bại",
-      "smtpEnable": "Bật thông báo qua email",
-      "smtpEnableDesc": "Bật thông báo qua email bằng SMTP",
       "smtpEncryption": "Mã hóa",
       "smtpEncryptionDesc": "Phương thức mã hóa kết nối SMTP",
       "smtpEncryptionNone": "Không (văn bản thuần)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS (ngầm định)",
-      "smtpEventBusNotify": "Thông báo sự kiện qua email",
-      "smtpEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua email",
-      "smtpHost": "Máy chủ SMTP",
-      "smtpHostDesc": "Tên máy chủ SMTP (ví dụ: smtp.gmail.com)",
-      "smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
-      "smtpNoRecipients": "Chưa cấu hình người nhận",
-      "smtpNotInitialized": "SMTP chưa được khởi tạo",
-      "smtpPassword": "Mật khẩu SMTP",
-      "smtpPasswordDesc": "Mật khẩu xác thực SMTP",
-      "smtpPort": "Cổng SMTP",
-      "smtpPortDesc": "Cổng máy chủ SMTP (mặc định: 587)",
-      "smtpSettings": "Cài đặt SMTP",
-      "smtpStageAuth": "Xác thực",
       "smtpStageConnect": "Kết nối",
+      "smtpStageAuth": "Xác thực",
       "smtpStageSend": "Gửi",
       "smtpTestSuccess": "Đã gửi email thử nghiệm thành công",
-      "smtpTo": "Người nhận",
-      "smtpToDesc": "Các địa chỉ email người nhận, phân cách bằng dấu phẩy",
-      "smtpUsername": "Tên đăng nhập SMTP",
-      "smtpUsernameDesc": "Tên đăng nhập xác thực SMTP",
+      "smtpHostNotConfigured": "Chưa cấu hình máy chủ SMTP",
+      "smtpNoRecipients": "Chưa cấu hình người nhận",
+      "eventLoginAttempt": "Lần thử đăng nhập",
       "telegramTokenConfigured": "Đã cấu hình; để trống để giữ token hiện tại.",
       "telegramTokenPlaceholder": "Đã cấu hình - nhập token mới để thay thế",
-      "testSmtp": "Gửi email thử nghiệm",
-      "testTgBot": "Gửi tin nhắn thử nghiệm",
+      "smtpPasswordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
+      "smtpPasswordPlaceholder": "Đã cấu hình - nhập mật khẩu mới để thay thế",
+      "smtpNotInitialized": "SMTP chưa được khởi tạo",
       "tgBotNotEnabled": "Bot Telegram chưa được bật",
-      "tgBotNotRunning": "Bot Telegram không hoạt động",
-      "tgEventBusNotify": "Thông báo sự kiện qua Telegram",
-      "tgEventBusNotifyDesc": "Chọn những sự kiện nào sẽ kích hoạt thông báo qua Telegram",
       "tgTestFailed": "Thử nghiệm Telegram thất bại",
       "tgTestSuccess": "Đã gửi tin nhắn thử nghiệm tới Telegram",
+      "tgBotNotRunning": "Bot Telegram không hoạt động",
       "smtpErrorAuth": "Xác thực thất bại — kiểm tra tên đăng nhập và mật khẩu",
       "smtpErrorStarttls": "Máy chủ yêu cầu STARTTLS — thay đổi kiểu mã hóa",
       "smtpErrorTls": "Máy chủ yêu cầu TLS — thay đổi kiểu mã hóa",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "Hết thời gian kết nối — không thể truy cập máy chủ",
       "smtpErrorRelay": "Máy chủ từ chối gửi từ địa chỉ này",
       "smtpErrorEof": "Kết nối đã bị máy chủ đóng",
-      "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}",
-      "eventGroupNode": "Node",
-      "eventNodeDown": "Ngừng hoạt động",
-      "eventNodeUp": "Hoạt động",
-      "smtpPasswordConfigured": "Đã cấu hình; để trống để giữ mật khẩu hiện tại.",
-      "smtpPasswordPlaceholder": "Đã cấu hình - nhập mật khẩu mới để thay thế"
+      "smtpErrorUnknown": "Lỗi SMTP: {{ .Error }}"
     },
     "xray": {
       "title": "Cài đặt Xray",
@@ -1319,6 +1338,8 @@
       "Inbounds": "Inbound",
       "InboundsDesc": "Thay đổi mẫu cấu hình để chấp nhận các máy khách cụ thể.",
       "Outbounds": "Outbound",
+      "OutboundSubscriptions": "Đăng ký Outbound",
+      "OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
       "Balancers": "Cân bằng",
       "balancerTagRequired": "Tag là bắt buộc",
       "balancerSelectorRequired": "Chọn ít nhất một outbound",
@@ -1496,8 +1517,6 @@
         "privateKey": "Khóa riêng",
         "load": "Tải"
       },
-      "OutboundSubscriptions": "Đăng ký Outbound",
-      "OutboundSubscriptionsDesc": "Nhập các outbound từ URL đăng ký từ xa (vmess/vless/trojan/ss/...). Tag được giữ ổn định để dùng trong bộ cân bằng tải và quy tắc định tuyến. Cập nhật diễn ra tự động.",
       "outboundSub": {
         "manage": "Đăng ký",
         "title": "Đăng ký Outbound",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ✅ Thành công",
       "FailedResetTraffic": "📧 Email: {{ .ClientEmail }}\n🏁 Kết quả: ❌ Thất bại \n\n🛠️ Lỗi: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 Quá trình đặt lại lưu lượng đã hoàn tất cho tất cả khách hàng.",
-      "eventCPUHigh": "CPU cao",
-      "eventCPUHighDetail": "CPU: {{ .Detail }}",
-      "eventDelayDetail": "Độ trễ: {{ .Delay }}ms",
-      "eventErrorDetail": "Lỗi: {{ .Error }}",
-      "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}",
       "eventOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
       "eventOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
+      "eventErrorDetail": "Lỗi: {{ .Error }}",
+      "eventDelayDetail": "Độ trễ: {{ .Delay }}ms",
       "eventXrayCrash": "Xray GẶP SỰ CỐ",
       "eventXrayCrashError": "Lỗi: {{ .Error }}",
       "eventNodeDown": "Node {{ .Name }} đã NGỪNG HOẠT ĐỘNG",
-      "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG"
+      "eventNodeUp": "Node {{ .Name }} đã HOẠT ĐỘNG",
+      "eventCPUHigh": "CPU cao",
+      "eventCPUHighDetail": "CPU: {{ .Detail }}",
+      "eventLoginFallback": "Đăng nhập thất bại từ {{ .Source }}"
     },
     "buttons": {
       "closeKeyboard": "❌ Đóng Bàn Phím",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
+    "subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
+    "subjectXrayCrash": "Xray GẶP SỰ CỐ",
+    "subjectCPUHigh": "CPU cao",
+    "subjectLoginSuccess": "Đăng nhập thành công",
+    "subjectLoginFailed": "Đăng nhập thất bại",
+    "titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG",
+    "titleOutboundUp": "Outbound HOẠT ĐỘNG",
+    "titleXrayCrash": "Xray GẶP SỰ CỐ",
+    "titleCPUHigh": "CPU cao",
+    "titleLoginSuccess": "Đăng nhập thành công",
+    "titleLoginFailed": "Đăng nhập thất bại",
+    "labelStatus": "Trạng thái",
+    "labelOutbound": "Outbound",
+    "labelNode": "Node",
+    "labelError": "Lỗi",
     "labelDelay": "Độ trễ",
     "labelDetail": "Chi tiết",
-    "labelError": "Lỗi",
+    "labelUsername": "Tên đăng nhập",
     "labelIP": "IP",
-    "labelOutbound": "Outbound",
     "labelReason": "Lý do",
     "labelSource": "Nguồn",
-    "labelStatus": "Trạng thái",
     "labelTime": "Thời gian",
-    "labelUsername": "Tên đăng nhập",
-    "statusBanned": "BANNED",
     "statusCrashed": "GẶP SỰ CỐ",
-    "statusDown": "NGỪNG HOẠT ĐỘNG",
-    "statusFailed": "THẤT BẠI",
-    "statusFull": "FULL",
-    "statusHigh": "CAO",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "Đang chạy",
+    "statusHigh": "CAO",
     "statusSuccess": "THÀNH CÔNG",
-    "statusUp": "HOẠT ĐỘNG",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU cao",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "Đăng nhập thất bại",
-    "subjectLoginSuccess": "Đăng nhập thành công",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "Outbound {{ .Tag }} đã NGỪNG HOẠT ĐỘNG",
-    "subjectOutboundUp": "Outbound {{ .Tag }} đã HOẠT ĐỘNG",
-    "subjectXrayCrash": "Xray GẶP SỰ CỐ",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU cao",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "Đăng nhập thất bại",
-    "titleLoginSuccess": "Đăng nhập thành công",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "Outbound NGỪNG HOẠT ĐỘNG",
-    "titleOutboundUp": "Outbound HOẠT ĐỘNG",
-    "titleXrayCrash": "Xray GẶP SỰ CỐ",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "Node"
+    "statusFailed": "THẤT BẠI",
+    "statusDown": "NGỪNG HOẠT ĐỘNG",
+    "statusUp": "HOẠT ĐỘNG"
   }
 }

+ 84 - 85
internal/web/translation/zh-CN.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "已添加入站客户端",
         "inboundClientDeleteSuccess": "入站客户端已删除",
         "inboundClientUpdateSuccess": "入站客户端已更新",
+        "savedNodeOfflineWillSync": "已在本地保存。某个支撑节点离线或已禁用——重新连接后将同步此更改。",
         "delDepletedClientsSuccess": "所有耗尽客户端已删除",
         "resetAllClientTrafficSuccess": "客户端所有流量已重置",
         "resetAllTrafficSuccess": "所有流量已重置",
@@ -912,6 +913,8 @@
       "status": "状态",
       "cpu": "CPU",
       "mem": "内存",
+      "netUp": "网络上行 (KB/s)",
+      "netDown": "网络下行 (KB/s)",
       "uptime": "运行时长",
       "latency": "延迟",
       "lastHeartbeat": "上次心跳",
@@ -953,13 +956,29 @@
         "probeFailed": "探测失败",
         "updateStarted": "已开始更新面板",
         "updateResult": "已在 {ok} 个节点上触发更新,{failed} 个失败",
-        "updateNoneEligible": "请至少选择一个在线且已启用的节点"
+        "updateNoneEligible": "请至少选择一个在线且已启用的节点",
+        "saveMtls": "保存节点 mTLS"
       },
       "tlsVerifyMode": "TLS 校验",
       "tlsVerifyModeHint": "面板如何校验节点的 HTTPS 证书。固定或跳过用于自签名证书(仅 https 节点)。",
       "tlsVerify": "校验(默认 CA)",
       "tlsPin": "固定证书(SHA-256)",
       "tlsSkip": "跳过校验",
+      "tlsMtls": "双向 TLS(客户端证书)",
+      "mtlsFormHint": "此节点使用客户端证书对面板进行认证。请从“节点 mTLS”区域复制本面板的 CA 到该节点,设置其受信任的 CA,然后重启该节点。",
+      "mtls": {
+        "title": "节点 mTLS",
+        "intro": "双向 TLS 在节点间调用的 API 令牌之上增加客户端证书认证。此为可选项:留空则仅使用令牌认证。",
+        "copyCa": "复制此面板的 CA",
+        "copyCaHint": "将此 CA 提供给本面板管理的节点,然后将它们的 TLS 校验设置为双向 TLS。",
+        "caCopied": "CA 证书已复制到剪贴板",
+        "caFailed": "获取 CA 证书失败",
+        "trustLabel": "受信任的上级 CA",
+        "trustHint": "当本面板自身作为节点时,将管理它的面板的 CA 粘贴到此处以要求其客户端证书。重启面板后生效。",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "保存受信任的 CA",
+        "saved": "受信任的 CA 已保存 — 重启面板后生效"
+      },
       "tlsSkipWarning": "跳过校验会失去对中间人攻击的防护,API 令牌可能被截获。建议改用固定证书。",
       "pinnedCert": "固定证书的 SHA-256",
       "pinnedCertHint": "节点证书的 SHA-256(base64 或 hex)。点击“获取”可立即从节点读取。",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "获取出站流量错误",
         "resetOutboundTrafficError": "重置出站流量错误"
       },
-      "emailNotifications": "通知",
+      "smtpSettings": "SMTP 设置",
+      "smtpEnable": "启用邮件通知",
+      "smtpEnableDesc": "通过 SMTP 启用邮件通知",
+      "smtpHost": "SMTP 主机",
+      "smtpHostDesc": "SMTP 服务器主机名(例如 smtp.gmail.com)",
+      "smtpPort": "SMTP 端口",
+      "smtpPortDesc": "SMTP 服务器端口(默认:587)",
+      "smtpUsername": "SMTP 用户名",
+      "smtpUsernameDesc": "SMTP 认证用户名",
+      "smtpPassword": "SMTP 密码",
+      "smtpPasswordDesc": "SMTP 认证密码",
+      "smtpTo": "收件人",
+      "smtpToDesc": "以逗号分隔的收件人邮箱地址",
       "emailSettings": "邮件",
-      "eventCPUHigh": "CPU 占用过高(%)",
+      "emailNotifications": "通知",
+      "smtpEventBusNotify": "邮件事件通知",
+      "smtpEventBusNotifyDesc": "选择触发邮件通知的事件",
+      "tgEventBusNotify": "Telegram 事件通知",
+      "tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件",
+      "testSmtp": "发送测试邮件",
+      "testTgBot": "发送测试消息",
       "eventGroupOutbound": "出站",
-      "eventGroupSecurity": "安全",
-      "eventGroupSystem": "系统",
       "eventGroupXray": "Xray 核心",
-      "eventLoginAttempt": "登录尝试",
+      "eventGroupSystem": "系统",
+      "eventGroupSecurity": "安全",
+      "eventGroupNode": "节点",
       "eventOutboundDown": "断开",
       "eventOutboundUp": "恢复",
       "eventXrayCrash": "崩溃",
+      "eventNodeDown": "离线",
+      "eventNodeUp": "上线",
+      "eventCPUHigh": "CPU 占用过高(%)",
       "requestFailed": "请求失败",
-      "smtpEnable": "启用邮件通知",
-      "smtpEnableDesc": "通过 SMTP 启用邮件通知",
       "smtpEncryption": "加密",
       "smtpEncryptionDesc": "SMTP 连接加密方式",
       "smtpEncryptionNone": "无(明文)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS(隐式)",
-      "smtpEventBusNotify": "邮件事件通知",
-      "smtpEventBusNotifyDesc": "选择触发邮件通知的事件",
-      "smtpHost": "SMTP 主机",
-      "smtpHostDesc": "SMTP 服务器主机名(例如 smtp.gmail.com)",
-      "smtpHostNotConfigured": "尚未配置 SMTP 主机",
-      "smtpNoRecipients": "尚未配置收件人",
-      "smtpNotInitialized": "SMTP 尚未初始化",
-      "smtpPassword": "SMTP 密码",
-      "smtpPasswordDesc": "SMTP 认证密码",
-      "smtpPort": "SMTP 端口",
-      "smtpPortDesc": "SMTP 服务器端口(默认:587)",
-      "smtpSettings": "SMTP 设置",
-      "smtpStageAuth": "认证",
       "smtpStageConnect": "连接",
+      "smtpStageAuth": "认证",
       "smtpStageSend": "发送",
       "smtpTestSuccess": "测试邮件发送成功",
-      "smtpTo": "收件人",
-      "smtpToDesc": "以逗号分隔的收件人邮箱地址",
-      "smtpUsername": "SMTP 用户名",
-      "smtpUsernameDesc": "SMTP 认证用户名",
+      "smtpHostNotConfigured": "尚未配置 SMTP 主机",
+      "smtpNoRecipients": "尚未配置收件人",
+      "eventLoginAttempt": "登录尝试",
       "telegramTokenConfigured": "已配置;留空则保留当前令牌。",
       "telegramTokenPlaceholder": "已配置——输入新令牌以替换",
-      "testSmtp": "发送测试邮件",
-      "testTgBot": "发送测试消息",
+      "smtpPasswordConfigured": "已配置;留空则保留当前密码。",
+      "smtpPasswordPlaceholder": "已配置——输入新密码以替换",
+      "smtpNotInitialized": "SMTP 尚未初始化",
       "tgBotNotEnabled": "Telegram 机器人未启用",
-      "tgBotNotRunning": "Telegram 机器人未运行",
-      "tgEventBusNotify": "Telegram 事件通知",
-      "tgEventBusNotifyDesc": "选择触发 Telegram 通知的事件",
       "tgTestFailed": "Telegram 测试失败",
       "tgTestSuccess": "测试消息已发送至 Telegram",
+      "tgBotNotRunning": "Telegram 机器人未运行",
       "smtpErrorAuth": "认证失败——请检查用户名和密码",
       "smtpErrorStarttls": "服务器要求 STARTTLS——请更改加密类型",
       "smtpErrorTls": "服务器要求 TLS——请更改加密类型",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "连接超时——主机无法访问",
       "smtpErrorRelay": "服务器拒绝从此地址发送",
       "smtpErrorEof": "连接被服务器关闭",
-      "smtpErrorUnknown": "SMTP 错误:{{ .Error }}",
-      "eventGroupNode": "节点",
-      "eventNodeDown": "离线",
-      "eventNodeUp": "上线",
-      "smtpPasswordConfigured": "已配置;留空则保留当前密码。",
-      "smtpPasswordPlaceholder": "已配置——输入新密码以替换"
+      "smtpErrorUnknown": "SMTP 错误:{{ .Error }}"
     },
     "xray": {
       "title": "Xray 配置",
@@ -1319,6 +1338,8 @@
       "Inbounds": "入站",
       "InboundsDesc": "接受来自特定客户端的流量",
       "Outbounds": "出站",
+      "OutboundSubscriptions": "出站订阅",
+      "OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
       "Balancers": "负载均衡",
       "balancerTagRequired": "标签为必填项",
       "balancerSelectorRequired": "至少选择一个出站",
@@ -1496,8 +1517,6 @@
         "privateKey": "私钥",
         "load": "负载"
       },
-      "OutboundSubscriptions": "出站订阅",
-      "OutboundSubscriptionsDesc": "从远程订阅 URL(vmess/vless/trojan/ss/…)导入出站。标签保持稳定,可用于负载均衡器和路由规则。更新会自动进行。",
       "outboundSub": {
         "manage": "订阅",
         "title": "出站订阅",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ✅ 成功",
       "FailedResetTraffic": "📧 邮箱: {{ .ClientEmail }}\n🏁 结果: ❌ 失败 \n\n🛠️ 错误: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 所有客户的流量重置已完成。",
-      "eventCPUHigh": "CPU 占用过高",
-      "eventCPUHighDetail": "CPU:{{ .Detail }}",
-      "eventDelayDetail": "延迟:{{ .Delay }} 毫秒",
-      "eventErrorDetail": "错误:{{ .Error }}",
-      "eventLoginFallback": "来自 {{ .Source }} 的登录失败",
       "eventOutboundDown": "出站 {{ .Tag }} 已断开",
       "eventOutboundUp": "出站 {{ .Tag }} 已恢复",
+      "eventErrorDetail": "错误:{{ .Error }}",
+      "eventDelayDetail": "延迟:{{ .Delay }} 毫秒",
       "eventXrayCrash": "Xray 已崩溃",
       "eventXrayCrashError": "错误:{{ .Error }}",
       "eventNodeDown": "节点 {{ .Name }} 已离线",
-      "eventNodeUp": "节点 {{ .Name }} 已上线"
+      "eventNodeUp": "节点 {{ .Name }} 已上线",
+      "eventCPUHigh": "CPU 占用过高",
+      "eventCPUHighDetail": "CPU:{{ .Detail }}",
+      "eventLoginFallback": "来自 {{ .Source }} 的登录失败"
     },
     "buttons": {
       "closeKeyboard": "❌ 关闭键盘",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "出站 {{ .Tag }} 已断开",
+    "subjectOutboundUp": "出站 {{ .Tag }} 已恢复",
+    "subjectXrayCrash": "Xray 已崩溃",
+    "subjectCPUHigh": "CPU 占用过高",
+    "subjectLoginSuccess": "登录成功",
+    "subjectLoginFailed": "登录失败",
+    "titleOutboundDown": "出站断开",
+    "titleOutboundUp": "出站恢复",
+    "titleXrayCrash": "Xray 已崩溃",
+    "titleCPUHigh": "CPU 占用过高",
+    "titleLoginSuccess": "登录成功",
+    "titleLoginFailed": "登录失败",
+    "labelStatus": "状态",
+    "labelOutbound": "出站",
+    "labelNode": "节点",
+    "labelError": "错误",
     "labelDelay": "延迟",
     "labelDetail": "详情",
-    "labelError": "错误",
+    "labelUsername": "用户名",
     "labelIP": "IP",
-    "labelOutbound": "出站",
     "labelReason": "原因",
     "labelSource": "来源",
-    "labelStatus": "状态",
     "labelTime": "时间",
-    "labelUsername": "用户名",
-    "statusBanned": "BANNED",
     "statusCrashed": "已崩溃",
-    "statusDown": "断开",
-    "statusFailed": "失败",
-    "statusFull": "FULL",
-    "statusHigh": "过高",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "运行中",
+    "statusHigh": "过高",
     "statusSuccess": "成功",
-    "statusUp": "恢复",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU 占用过高",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "登录失败",
-    "subjectLoginSuccess": "登录成功",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "出站 {{ .Tag }} 已断开",
-    "subjectOutboundUp": "出站 {{ .Tag }} 已恢复",
-    "subjectXrayCrash": "Xray 已崩溃",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU 占用过高",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "登录失败",
-    "titleLoginSuccess": "登录成功",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "出站断开",
-    "titleOutboundUp": "出站恢复",
-    "titleXrayCrash": "Xray 已崩溃",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "节点"
+    "statusFailed": "失败",
+    "statusDown": "断开",
+    "statusUp": "恢复"
   }
 }

+ 84 - 85
internal/web/translation/zh-TW.json

@@ -446,6 +446,7 @@
         "inboundClientAddSuccess": "已新增入站客戶端",
         "inboundClientDeleteSuccess": "入站客戶端已刪除",
         "inboundClientUpdateSuccess": "入站客戶端已更新",
+        "savedNodeOfflineWillSync": "已在本機儲存。某個支撐節點離線或已停用——重新連線後將同步此變更。",
         "delDepletedClientsSuccess": "所有耗盡客戶端已刪除",
         "resetAllClientTrafficSuccess": "客戶端所有流量已重置",
         "resetAllTrafficSuccess": "所有流量已重置",
@@ -912,6 +913,8 @@
       "status": "狀態",
       "cpu": "CPU",
       "mem": "記憶體",
+      "netUp": "網路上行 (KB/s)",
+      "netDown": "網路下行 (KB/s)",
       "uptime": "運行時間",
       "latency": "延遲",
       "lastHeartbeat": "上次心跳",
@@ -953,13 +956,29 @@
         "probeFailed": "探測失敗",
         "updateStarted": "已開始更新面板",
         "updateResult": "已在 {ok} 個節點上觸發更新,{failed} 個失敗",
-        "updateNoneEligible": "請至少選擇一個在線且已啟用的節點"
+        "updateNoneEligible": "請至少選擇一個在線且已啟用的節點",
+        "saveMtls": "儲存節點 mTLS"
       },
       "tlsVerifyMode": "TLS 驗證",
       "tlsVerifyModeHint": "面板如何驗證節點的 HTTPS 憑證。釘選或略過用於自簽憑證(僅 https 節點)。",
       "tlsVerify": "驗證(預設 CA)",
       "tlsPin": "釘選憑證(SHA-256)",
       "tlsSkip": "略過驗證",
+      "tlsMtls": "雙向 TLS(用戶端憑證)",
+      "mtlsFormHint": "此節點使用用戶端憑證對面板進行驗證。請從「節點 mTLS」區域複製本面板的 CA 到該節點,設定其受信任的 CA,然後重新啟動該節點。",
+      "mtls": {
+        "title": "節點 mTLS",
+        "intro": "雙向 TLS 在節點間呼叫的 API 權杖之上增加用戶端憑證驗證。此為選用項目:留空則僅使用權杖驗證。",
+        "copyCa": "複製此面板的 CA",
+        "copyCaHint": "將此 CA 提供給本面板管理的節點,然後將它們的 TLS 驗證設定為雙向 TLS。",
+        "caCopied": "CA 憑證已複製到剪貼簿",
+        "caFailed": "取得 CA 憑證失敗",
+        "trustLabel": "受信任的上層 CA",
+        "trustHint": "當本面板自身作為節點時,將管理它的面板的 CA 貼到此處以要求其用戶端憑證。重新啟動面板後生效。",
+        "trustPlaceholder": "-----BEGIN CERTIFICATE-----",
+        "save": "儲存受信任的 CA",
+        "saved": "受信任的 CA 已儲存 — 重新啟動面板後生效"
+      },
       "tlsSkipWarning": "略過驗證會失去對中間人攻擊的防護,API 權杖可能被攔截。建議改用釘選憑證。",
       "pinnedCert": "釘選憑證的 SHA-256",
       "pinnedCertHint": "節點憑證的 SHA-256(base64 或 hex)。點選「取得」可立即從節點讀取。",
@@ -1210,55 +1229,60 @@
         "getOutboundTrafficError": "取得出站流量錯誤",
         "resetOutboundTrafficError": "重設出站流量錯誤"
       },
-      "emailNotifications": "通知",
+      "smtpSettings": "SMTP 設定",
+      "smtpEnable": "啟用電子郵件通知",
+      "smtpEnableDesc": "透過 SMTP 啟用電子郵件通知",
+      "smtpHost": "SMTP 主機",
+      "smtpHostDesc": "SMTP 伺服器主機名稱(例如 smtp.gmail.com)",
+      "smtpPort": "SMTP 連接埠",
+      "smtpPortDesc": "SMTP 伺服器連接埠(預設:587)",
+      "smtpUsername": "SMTP 使用者名稱",
+      "smtpUsernameDesc": "SMTP 驗證使用者名稱",
+      "smtpPassword": "SMTP 密碼",
+      "smtpPasswordDesc": "SMTP 驗證密碼",
+      "smtpTo": "收件人",
+      "smtpToDesc": "以逗號分隔的收件人電子郵件地址",
       "emailSettings": "電子郵件",
-      "eventCPUHigh": "CPU 偏高(%)",
+      "emailNotifications": "通知",
+      "smtpEventBusNotify": "電子郵件事件通知",
+      "smtpEventBusNotifyDesc": "選擇觸發電子郵件通知的事件",
+      "tgEventBusNotify": "Telegram 事件通知",
+      "tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件",
+      "testSmtp": "傳送測試郵件",
+      "testTgBot": "傳送測試訊息",
       "eventGroupOutbound": "出站",
-      "eventGroupSecurity": "安全性",
-      "eventGroupSystem": "系統",
       "eventGroupXray": "Xray 核心",
-      "eventLoginAttempt": "登入嘗試",
+      "eventGroupSystem": "系統",
+      "eventGroupSecurity": "安全性",
+      "eventGroupNode": "節點",
       "eventOutboundDown": "中斷",
       "eventOutboundUp": "恢復",
       "eventXrayCrash": "當機",
+      "eventNodeDown": "離線",
+      "eventNodeUp": "上線",
+      "eventCPUHigh": "CPU 偏高(%)",
       "requestFailed": "請求失敗",
-      "smtpEnable": "啟用電子郵件通知",
-      "smtpEnableDesc": "透過 SMTP 啟用電子郵件通知",
       "smtpEncryption": "加密",
       "smtpEncryptionDesc": "SMTP 連線加密方式",
       "smtpEncryptionNone": "無(純文字)",
       "smtpEncryptionStartTLS": "STARTTLS",
       "smtpEncryptionTLS": "TLS(隱含)",
-      "smtpEventBusNotify": "電子郵件事件通知",
-      "smtpEventBusNotifyDesc": "選擇觸發電子郵件通知的事件",
-      "smtpHost": "SMTP 主機",
-      "smtpHostDesc": "SMTP 伺服器主機名稱(例如 smtp.gmail.com)",
-      "smtpHostNotConfigured": "尚未設定 SMTP 主機",
-      "smtpNoRecipients": "尚未設定收件人",
-      "smtpNotInitialized": "SMTP 尚未初始化",
-      "smtpPassword": "SMTP 密碼",
-      "smtpPasswordDesc": "SMTP 驗證密碼",
-      "smtpPort": "SMTP 連接埠",
-      "smtpPortDesc": "SMTP 伺服器連接埠(預設:587)",
-      "smtpSettings": "SMTP 設定",
-      "smtpStageAuth": "驗證",
       "smtpStageConnect": "連線",
+      "smtpStageAuth": "驗證",
       "smtpStageSend": "傳送",
       "smtpTestSuccess": "測試郵件已成功傳送",
-      "smtpTo": "收件人",
-      "smtpToDesc": "以逗號分隔的收件人電子郵件地址",
-      "smtpUsername": "SMTP 使用者名稱",
-      "smtpUsernameDesc": "SMTP 驗證使用者名稱",
+      "smtpHostNotConfigured": "尚未設定 SMTP 主機",
+      "smtpNoRecipients": "尚未設定收件人",
+      "eventLoginAttempt": "登入嘗試",
       "telegramTokenConfigured": "已設定;留空以保留目前的權杖。",
       "telegramTokenPlaceholder": "已設定 - 輸入新權杖以取代",
-      "testSmtp": "傳送測試郵件",
-      "testTgBot": "傳送測試訊息",
+      "smtpPasswordConfigured": "已設定;留空以保留目前的密碼。",
+      "smtpPasswordPlaceholder": "已設定 - 輸入新密碼以取代",
+      "smtpNotInitialized": "SMTP 尚未初始化",
       "tgBotNotEnabled": "Telegram 機器人未啟用",
-      "tgBotNotRunning": "Telegram 機器人未執行",
-      "tgEventBusNotify": "Telegram 事件通知",
-      "tgEventBusNotifyDesc": "選擇觸發 Telegram 通知的事件",
       "tgTestFailed": "Telegram 測試失敗",
       "tgTestSuccess": "測試訊息已傳送至 Telegram",
+      "tgBotNotRunning": "Telegram 機器人未執行",
       "smtpErrorAuth": "驗證失敗 — 請檢查使用者名稱和密碼",
       "smtpErrorStarttls": "伺服器需要 STARTTLS — 請變更加密類型",
       "smtpErrorTls": "伺服器需要 TLS — 請變更加密類型",
@@ -1266,12 +1290,7 @@
       "smtpErrorTimeout": "連線逾時 — 無法連線至主機",
       "smtpErrorRelay": "伺服器拒絕從此地址傳送",
       "smtpErrorEof": "連線已被伺服器關閉",
-      "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}",
-      "eventGroupNode": "節點",
-      "eventNodeDown": "離線",
-      "eventNodeUp": "上線",
-      "smtpPasswordConfigured": "已設定;留空以保留目前的密碼。",
-      "smtpPasswordPlaceholder": "已設定 - 輸入新密碼以取代"
+      "smtpErrorUnknown": "SMTP 錯誤:{{ .Error }}"
     },
     "xray": {
       "title": "Xray 配置",
@@ -1319,6 +1338,8 @@
       "Inbounds": "入站",
       "InboundsDesc": "接受來自特定客戶端的流量",
       "Outbounds": "出站",
+      "OutboundSubscriptions": "出站訂閱",
+      "OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
       "Balancers": "負載均衡",
       "balancerTagRequired": "標籤為必填",
       "balancerSelectorRequired": "至少選擇一個出站",
@@ -1496,8 +1517,6 @@
         "privateKey": "私密金鑰",
         "load": "負載"
       },
-      "OutboundSubscriptions": "出站訂閱",
-      "OutboundSubscriptionsDesc": "從遠端訂閱 URL(vmess/vless/trojan/ss/...)匯入出站。標籤會保持穩定,以便在負載均衡與路由規則中使用。系統會自動更新。",
       "outboundSub": {
         "manage": "訂閱",
         "title": "出站訂閱",
@@ -1775,17 +1794,17 @@
       "SuccessResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ✅ 成功",
       "FailedResetTraffic": "📧 電子郵件: {{ .ClientEmail }}\n🏁 結果: ❌ 失敗 \n\n🛠️ 錯誤: [ {{ .ErrorMessage }} ]",
       "FinishProcess": "🔚 所有客戶的流量重置已完成。",
-      "eventCPUHigh": "CPU 偏高",
-      "eventCPUHighDetail": "CPU:{{ .Detail }}",
-      "eventDelayDetail": "延遲:{{ .Delay }} 毫秒",
-      "eventErrorDetail": "錯誤:{{ .Error }}",
-      "eventLoginFallback": "來自 {{ .Source }} 的登入失敗",
       "eventOutboundDown": "出站 {{ .Tag }} 已中斷",
       "eventOutboundUp": "出站 {{ .Tag }} 已恢復",
+      "eventErrorDetail": "錯誤:{{ .Error }}",
+      "eventDelayDetail": "延遲:{{ .Delay }} 毫秒",
       "eventXrayCrash": "Xray 已當機",
       "eventXrayCrashError": "錯誤:{{ .Error }}",
       "eventNodeDown": "節點 {{ .Name }} 已離線",
-      "eventNodeUp": "節點 {{ .Name }} 已上線"
+      "eventNodeUp": "節點 {{ .Name }} 已上線",
+      "eventCPUHigh": "CPU 偏高",
+      "eventCPUHighDetail": "CPU:{{ .Detail }}",
+      "eventLoginFallback": "來自 {{ .Source }} 的登入失敗"
     },
     "buttons": {
       "closeKeyboard": "❌ 關閉鍵盤",
@@ -1857,55 +1876,35 @@
     }
   },
   "email": {
+    "subjectOutboundDown": "出站 {{ .Tag }} 已中斷",
+    "subjectOutboundUp": "出站 {{ .Tag }} 已恢復",
+    "subjectXrayCrash": "Xray 已當機",
+    "subjectCPUHigh": "CPU 偏高",
+    "subjectLoginSuccess": "登入成功",
+    "subjectLoginFailed": "登入失敗",
+    "titleOutboundDown": "出站中斷",
+    "titleOutboundUp": "出站恢復",
+    "titleXrayCrash": "Xray 已當機",
+    "titleCPUHigh": "CPU 偏高",
+    "titleLoginSuccess": "登入成功",
+    "titleLoginFailed": "登入失敗",
+    "labelStatus": "狀態",
+    "labelOutbound": "出站",
+    "labelNode": "節點",
+    "labelError": "錯誤",
     "labelDelay": "延遲",
     "labelDetail": "詳細資訊",
-    "labelError": "錯誤",
+    "labelUsername": "使用者名稱",
     "labelIP": "IP",
-    "labelOutbound": "出站",
     "labelReason": "原因",
     "labelSource": "來源",
-    "labelStatus": "狀態",
     "labelTime": "時間",
-    "labelUsername": "使用者名稱",
-    "statusBanned": "BANNED",
     "statusCrashed": "已當機",
-    "statusDown": "中斷",
-    "statusFailed": "失敗",
-    "statusFull": "FULL",
-    "statusHigh": "偏高",
-    "statusOffline": "OFFLINE",
-    "statusOnline": "ONLINE",
     "statusRunning": "執行中",
+    "statusHigh": "偏高",
     "statusSuccess": "成功",
-    "statusUp": "恢復",
-    "statusXrayDown": "Xray DOWN",
-    "statusXrayUp": "Xray UP",
-    "subjectCPUHigh": "CPU 偏高",
-    "subjectDiskFull": "Disk full",
-    "subjectIPBanned": "IP banned: {{ .IP }}",
-    "subjectLoginFailed": "登入失敗",
-    "subjectLoginSuccess": "登入成功",
-    "subjectNodeOffline": "Node {{ .Node }} is OFFLINE",
-    "subjectNodeOnline": "Node {{ .Node }} is ONLINE",
-    "subjectNodeXrayDown": "Node {{ .Node }} Xray is DOWN",
-    "subjectNodeXrayUp": "Node {{ .Node }} Xray is UP",
-    "subjectOutboundDown": "出站 {{ .Tag }} 已中斷",
-    "subjectOutboundUp": "出站 {{ .Tag }} 已恢復",
-    "subjectXrayCrash": "Xray 已當機",
-    "subjectXrayUp": "Xray is UP",
-    "titleCPUHigh": "CPU 偏高",
-    "titleDiskFull": "Disk full",
-    "titleIPBanned": "IP banned",
-    "titleLoginFailed": "登入失敗",
-    "titleLoginSuccess": "登入成功",
-    "titleNodeOffline": "Node OFFLINE",
-    "titleNodeOnline": "Node ONLINE",
-    "titleNodeXrayDown": "Node Xray DOWN",
-    "titleNodeXrayUp": "Node Xray UP",
-    "titleOutboundDown": "出站中斷",
-    "titleOutboundUp": "出站恢復",
-    "titleXrayCrash": "Xray 已當機",
-    "titleXrayUp": "Xray UP",
-    "labelNode": "節點"
+    "statusFailed": "失敗",
+    "statusDown": "中斷",
+    "statusUp": "恢復"
   }
 }

+ 18 - 0
internal/web/web.go

@@ -447,6 +447,15 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
 	}))
 	runtime.GetManager().SetNodeEgressResolver(&s.settingService)
+	// Supply the master client certificate for nodes in mtls mode. Issued lazily
+	// from the node CA on first use; runtime stays free of a service import.
+	runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) {
+		ck, err := s.settingService.EnsureMasterClientCert()
+		if err != nil {
+			return tls.Certificate{}, err
+		}
+		return tls.X509KeyPair(ck.CertPEM, ck.KeyPEM)
+	})
 
 	engine, err := s.initRouter()
 	if err != nil {
@@ -488,6 +497,15 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 			c := &tls.Config{
 				Certificates: []tls.Certificate{cert},
 			}
+			// Opt-in node mTLS: when a trust CA is configured, request and verify
+			// client certs (VerifyClientCertIfGiven keeps browsers working). With
+			// no CA the listener is unchanged.
+			if pool, perr := s.settingService.NodeMtlsClientCAPool(); perr != nil {
+				logger.Warning("node mTLS: failed to build client CA trust pool:", perr)
+			} else if pool != nil {
+				applyNodeMtls(c, pool)
+				logger.Info("Node mTLS enabled: verifying client certificates for the node API")
+			}
 			listener = network.NewAutoHttpsListener(listener)
 			listener = tls.NewListener(listener, c)
 			logger.Info("Web server running HTTPS on", listener.Addr())

+ 19 - 0
internal/web/web_mtls.go

@@ -0,0 +1,19 @@
+package web
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+)
+
+// applyNodeMtls configures the panel listener to request and verify client
+// certificates against pool. It uses VerifyClientCertIfGiven so browsers (which
+// present no client cert) keep working; a presented cert that fails to verify
+// aborts the handshake. With a nil pool the config is left untouched, so the
+// no-mTLS listener is byte-identical to before.
+func applyNodeMtls(cfg *tls.Config, pool *x509.CertPool) {
+	if pool == nil {
+		return
+	}
+	cfg.ClientAuth = tls.VerifyClientCertIfGiven
+	cfg.ClientCAs = pool
+}

+ 154 - 0
internal/web/web_mtls_test.go

@@ -0,0 +1,154 @@
+package web
+
+import (
+	"crypto/tls"
+	"crypto/x509"
+	"net/http"
+	"net/http/httptest"
+	"strconv"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
+)
+
+// TestPanelTLSAcceptsClientWithoutClientCert characterizes the invariant the
+// mTLS work must preserve: the panel's HTTPS listener — configured today with a
+// server certificate and NO ClientAuth — completes the TLS handshake for a
+// client that presents no client certificate (i.e. every browser). When mTLS is
+// wired into web.go, the no-CA path must keep this behavior byte-for-byte. P1.6
+// extends this file with the VerifyClientCertIfGiven + ClientCAs cases.
+func TestPanelTLSAcceptsClientWithoutClientCert(t *testing.T) {
+	srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusOK)
+	}))
+	defer srv.Close()
+
+	// Precondition: like web.go today, the listener requests no client cert.
+	if srv.TLS.ClientAuth != tls.NoClientCert {
+		t.Fatalf("precondition: ClientAuth = %v, want NoClientCert", srv.TLS.ClientAuth)
+	}
+
+	// srv.Client() trusts the server's self-signed cert and presents NO client cert.
+	resp, err := srv.Client().Get(srv.URL)
+	if err != nil {
+		t.Fatalf("request without a client certificate failed: %v", err)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusOK {
+		t.Fatalf("status = %d, want 200", resp.StatusCode)
+	}
+}
+
+// TestApplyNodeMtls exercises the listener policy applied by web.go: a nil pool
+// leaves the listener unchanged (no client auth, browsers work); a set pool is
+// request-but-don't-require, so no-cert clients still handshake while a
+// CA-signed client cert is verified and a foreign cert is rejected.
+func TestApplyNodeMtls(t *testing.T) {
+	ca, err := crypto.GenerateNodeCA("test ca")
+	if err != nil {
+		t.Fatalf("GenerateNodeCA: %v", err)
+	}
+	clientPEM, err := crypto.IssueClientCert(ca, "master")
+	if err != nil {
+		t.Fatalf("IssueClientCert: %v", err)
+	}
+	clientCert, err := tls.X509KeyPair(clientPEM.CertPEM, clientPEM.KeyPEM)
+	if err != nil {
+		t.Fatalf("client X509KeyPair: %v", err)
+	}
+	caPool := x509.NewCertPool()
+	if !caPool.AppendCertsFromPEM(ca.CertPEM) {
+		t.Fatal("append CA to pool")
+	}
+
+	otherCA, err := crypto.GenerateNodeCA("other ca")
+	if err != nil {
+		t.Fatalf("GenerateNodeCA(other): %v", err)
+	}
+	foreignPEM, err := crypto.IssueClientCert(otherCA, "intruder")
+	if err != nil {
+		t.Fatalf("IssueClientCert(foreign): %v", err)
+	}
+	foreignCert, err := tls.X509KeyPair(foreignPEM.CertPEM, foreignPEM.KeyPEM)
+	if err != nil {
+		t.Fatalf("foreign X509KeyPair: %v", err)
+	}
+
+	newServer := func(pool *x509.CertPool) *httptest.Server {
+		srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			n := 0
+			if r.TLS != nil {
+				n = len(r.TLS.VerifiedChains)
+			}
+			w.Header().Set("X-Verified-Chains", strconv.Itoa(n))
+			w.WriteHeader(http.StatusOK)
+		}))
+		srv.TLS = &tls.Config{}
+		applyNodeMtls(srv.TLS, pool)
+		srv.StartTLS()
+		return srv
+	}
+	// clientFor forces the client to present cert via GetClientCertificate so the
+	// server's verification is what's under test (the default Certificates path
+	// would let the Go client silently withhold a cert whose CA the server didn't
+	// advertise, masking the reject behavior).
+	clientFor := func(srv *httptest.Server, cert *tls.Certificate) *http.Client {
+		roots := x509.NewCertPool()
+		roots.AddCert(srv.Certificate())
+		cfg := &tls.Config{RootCAs: roots}
+		if cert != nil {
+			c := *cert
+			cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
+				return &c, nil
+			}
+		}
+		return &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}}
+	}
+
+	t.Run("nil pool leaves the listener without client auth", func(t *testing.T) {
+		srv := newServer(nil)
+		defer srv.Close()
+		if srv.TLS.ClientAuth != tls.NoClientCert {
+			t.Fatalf("nil pool must not set ClientAuth, got %v", srv.TLS.ClientAuth)
+		}
+		resp, err := clientFor(srv, nil).Get(srv.URL)
+		if err != nil {
+			t.Fatalf("no-cert client failed: %v", err)
+		}
+		resp.Body.Close()
+	})
+
+	t.Run("pool set still accepts a no-cert client", func(t *testing.T) {
+		srv := newServer(caPool)
+		defer srv.Close()
+		resp, err := clientFor(srv, nil).Get(srv.URL)
+		if err != nil {
+			t.Fatalf("no-cert client must still handshake under VerifyClientCertIfGiven: %v", err)
+		}
+		defer resp.Body.Close()
+		if got := resp.Header.Get("X-Verified-Chains"); got != "0" {
+			t.Fatalf("no-cert client verified chains = %s, want 0", got)
+		}
+	})
+
+	t.Run("pool set verifies the master client cert", func(t *testing.T) {
+		srv := newServer(caPool)
+		defer srv.Close()
+		resp, err := clientFor(srv, &clientCert).Get(srv.URL)
+		if err != nil {
+			t.Fatalf("master client cert must be accepted: %v", err)
+		}
+		defer resp.Body.Close()
+		if got := resp.Header.Get("X-Verified-Chains"); got != "1" {
+			t.Fatalf("master cert verified chains = %s, want 1 (cert was not verified)", got)
+		}
+	})
+
+	t.Run("pool set rejects a foreign-CA client cert", func(t *testing.T) {
+		srv := newServer(caPool)
+		defer srv.Close()
+		if _, err := clientFor(srv, &foreignCert).Get(srv.URL); err == nil {
+			t.Fatal("a client cert from an untrusted CA must fail the handshake")
+		}
+	})
+}