9 İşlemeler 5819a01cdc ... 49fbcdc09c

Yazar SHA1 Mesaj Tarih
  MHSanaei 49fbcdc09c fix(frontend): wrap overview modal action buttons on long labels 1 gün önce
  Egor aacfaebab8 fix(tuic): client speed display and certificate button layout (#6723) 1 gün önce
  冰 e897b0957a fix(sub): append host serverDescription to hysteria links (#6740) 1 gün önce
  Chester Fishmans b42a1c0ba1 fix(systemd): harden shipped x-ui unit files (#6718) 1 gün önce
  Mr. Nickson a8d65a55b0 fix(update): run the database migration before starting the service (#6729) 1 gün önce
  MHSanaei d7da64f2f0 fix(qr): hide the QR only for links carrying post-quantum keys 1 gün önce
  Yuri Khachaturyan 2c5fc8e72c fix(node): don't delete clients when a node reports an empty snapshot (#6734) 1 gün önce
  Younes Beriane d4a7086c4e fix(inbounds): list clients in the detach/attach modals when they mount open (#6736) 1 gün önce
  MHSanaei d1b60799ec fix(frontend): show toasts through message.useMessage, never the static API 1 gün önce
42 değiştirilmiş dosya ile 1452 ekleme ve 89 silme
  1. 1 1
      README.md
  2. 1 1
      README.ru_RU.md
  3. 6 4
      docs/content/docs/en/config/tuic.mdx
  4. 6 4
      docs/content/docs/ru/config/tuic.mdx
  5. 25 8
      docs/public/openapi.json
  6. 25 8
      frontend/public/openapi.json
  7. 9 6
      frontend/src/components/command-palette/CommandPalette.tsx
  8. 60 4
      frontend/src/hooks/useClients.ts
  9. 9 4
      frontend/src/lib/xray/inbound-link.ts
  10. 24 3
      frontend/src/pages/api-docs/websocket-events.ts
  11. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  12. 1 1
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  13. 8 6
      frontend/src/pages/inbounds/form/protocols/tuic.tsx
  14. 2 0
      frontend/src/pages/index/PanelUpdateModal.css
  15. 2 0
      frontend/src/pages/index/VersionModal.css
  16. 4 2
      frontend/src/pages/settings/HappSettingsContent.tsx
  17. 4 2
      frontend/src/pages/xray/routing/RoutingTab.tsx
  18. 43 0
      frontend/src/test/attach-clients-modal.test.tsx
  19. 67 0
      frontend/src/test/clients-summary.test.tsx
  20. 36 0
      frontend/src/test/detach-clients-modal.test.tsx
  21. 16 1
      frontend/src/test/happ-routing-editor.test.tsx
  22. 0 3
      frontend/src/test/happ-settings-presets.test.tsx
  23. 44 0
      frontend/src/test/inbound-link.test.ts
  24. 31 0
      frontend/src/test/no-static-message.test.ts
  25. 168 0
      install.sh
  26. 57 0
      internal/sub/hysteria_server_description_test.go
  27. 5 1
      internal/sub/service.go
  28. 37 1
      internal/web/job/tuic_job.go
  29. 11 0
      internal/web/job/tuic_job_test.go
  30. 1 0
      internal/web/node_contract_test.go
  31. 11 0
      internal/web/runtime/remote.go
  32. 21 12
      internal/web/service/client_sync_orphan_test.go
  33. 3 0
      internal/web/service/inbound_amneziawg_test.go
  34. 43 1
      internal/web/service/inbound_node.go
  35. 205 0
      internal/web/service/node_degraded_snapshot_test.go
  36. 3 1
      internal/web/websocket/hub.go
  37. 43 14
      internal/web/websocket/hub_test.go
  38. 185 0
      update.sh
  39. 76 0
      x-ui.service.arch
  40. 76 0
      x-ui.service.debian
  41. 76 0
      x-ui.service.rhel
  42. 6 0
      x-ui.sh

+ 1 - 1
README.md

@@ -29,7 +29,7 @@ Built as an enhanced fork of the original X-UI project, 3X-UI adds broader proto
 - **Multi-protocol inbounds** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel, and TUN.
 - **Modern transports & security** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade, and XHTTP, secured with TLS, XTLS, and REALITY.
 - **AmneziaWG built in** — DPI-resistant WireGuard runs inside the panel on a userspace network stack, with no kernel module, DKMS, or extra packages to install.
-- **TUIC v5 sidecar** — High-performance QUIC-based proxy with native UDP relay traffic metering, 0-RTT handshakes, and BBR congestion control.
+- **Native TUIC v5 server** — In-process Go QUIC server with Xray routing and per-client traffic accounting; BBR and New Reno are available server-side. CUBIC is preserved in the client profile but currently falls back to New Reno on the server.
 - **MTProto proxies** — per-client FakeTLS secrets, ad-tags, and quotas, applied live without dropping existing connections.
 - **Fallbacks** — serve multiple protocols on a single port (e.g. VLESS and Trojan on 443) using Xray's fallback support.
 - **Per-client management** — traffic quotas, expiry dates, IP limits with trusted-address exemptions, HWID device limits, scheduled renewal cycles, live online status, and one-click share links, QR codes, and subscriptions.

+ 1 - 1
README.ru_RU.md

@@ -29,7 +29,7 @@
 - **Многопротокольные входящие подключения** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, AmneziaWG, TUIC v5, Hysteria2, MTProto, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel и TUN.
 - **Современные транспорты и безопасность** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade и XHTTP, защищённые с помощью TLS, XTLS и REALITY.
 - **Встроенный AmneziaWG** — устойчивый к DPI WireGuard работает прямо в панели на сетевом стеке в пространстве пользователя: без модуля ядра, DKMS и дополнительных пакетов.
-- **Встроенный TUIC v5** — высокопроизводительный прокси на базе QUIC с нативным учётом трафика через UDP-релей, 0-RTT рукопожатиями и контролем перегрузок BBR.
+- **Нативный TUIC v5** — Go QUIC-сервер работает внутри процесса панели. Трафик маршрутизируется через Xray, а учёт ведётся по клиентам. На сервере доступны BBR и New Reno; CUBIC сохраняется в профиле клиента, но на сервере пока использует New Reno.
 - **MTProto-прокси** — секреты FakeTLS, ad-tag и квоты для каждого клиента применяются на лету, не разрывая существующие соединения.
 - **Fallback** — обслуживание нескольких протоколов на одном порту (например, VLESS и Trojan на 443) с помощью функции fallback в Xray.
 - **Управление по каждому клиенту** — квоты трафика, даты истечения, лимиты IP с исключениями для доверенных адресов, лимиты устройств (HWID), запланированные циклы продления, статус «онлайн» в реальном времени, а также ссылки для общего доступа, QR-коды и подписки в один клик.

+ 6 - 4
docs/content/docs/en/config/tuic.mdx

@@ -15,16 +15,16 @@ unstable networks.
 
 ## Key settings
 
-### Server & QUIC parameters
+### Server, QUIC & client-profile parameters
 
 | Field | Description |
 | --- | --- |
 | **Port** | UDP port for incoming client QUIC connections. |
 | **Certificate & Key** | Full TLS certificate chain and private key. QUIC mandates TLS encryption; self-signed certificates or valid Let's Encrypt / ACME certs are supported. |
-| **SNI** | Server Name Indication matching your TLS certificate domain name. |
-| **Congestion Control** | QUIC congestion control algorithm: `bbr` (recommended for high throughput), `cubic`, or `new_reno`. The server runs `bbr` or `new_reno`; `cubic` is sent to clients but served as `new_reno`. |
+| **SNI** | Client-profile Server Name Indication. Set it to the domain covered by the server certificate; this field does not configure the listener certificate. |
+| **Congestion Control** | QUIC congestion control algorithm used in the server setting and exported client profile: `bbr`, `cubic`, or `new_reno`. The server runs BBR or New Reno; when CUBIC is selected, the client profile keeps CUBIC while this server currently falls back to New Reno. |
 | **ALPN** | Application-Layer Protocol Negotiation tokens (default: `h3`). |
-| **UDP Relay Mode** | Packet encapsulation mode: `native` (QUIC datagrams, recommended) or `quic`. |
+| **UDP Relay Mode** | Client-profile packet mode: `native` (QUIC datagrams) or `quic` (unidirectional streams). The server accepts both modes regardless of this exported preference. |
 | **Zero-RTT Handshake** | Enables 0-RTT connection resumption to eliminate initial handshake round-trips for returning clients. |
 | **Authentication Timeout** | Maximum time (seconds) allowed for client authentication before disconnecting (default: `3s`). |
 | **Max Idle Time** | Inactivity timeout (seconds) before closing idle QUIC connections (default: `15s`). |
@@ -105,6 +105,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
   - **Native in-process Go engine**: TUIC v5 runs 100% natively in Go within the 3x-ui process. No external binaries or sidecars to download or maintain.
   - **Full Xray routing & cascading**: Decrypted traffic passes directly through Xray's routing engine. Inbound tags (`in-<port>-udp`) work seamlessly with routing rules, domain/IP blocks, and cascading to any outbound proxy (VLESS, Shadowsocks, WARP, etc.).
   - **Per-client traffic limits & expiration**: Individual traffic quotas (`totalGB`) and expiration timestamps (`expiryTime`) are tracked and enforced for each client.
+  - **Live speed & traffic totals**: Native TUIC client counters are sampled every 10 seconds and sent to the panel for per-client live speed. Xray meters inbound totals through the loopback relay; TUIC's client-speed event does not add inbound totals again.
+  - **UDP resource bounds**: Each QUIC connection can hold up to 256 active UDP associations. Idle associations are closed after five minutes. This is a per-connection limit, not a node-wide association cap. TUIC uses a reserved loopback SOCKS relay port in `64001–65000`; 3x-ui checks it against managed inbound and relay ports.
   - **Zero-downtime client updates**: Adding, modifying, or disabling clients updates the in-memory user registry instantly without restarting the UDP port or interrupting existing client sessions.
   - **Deployment**: A TUIC inbound can be created on, or cloned to, a sub-node. The node's own panel runs the TUIC server, so the node must run panel v3.8.0 or newer; the master refuses an older node.
 </Callout>

+ 6 - 4
docs/content/docs/ru/config/tuic.mdx

@@ -14,16 +14,16 @@ icon: Zap
 
 ## Ключевые параметры
 
-### Параметры сервера и QUIC
+### Параметры сервера, QUIC и клиентского профиля
 
 | Поле | Описание |
 | --- | --- |
 | **Порт** | UDP-порт для входящих QUIC-соединений клиентов. |
 | **Сертификат и ключ** | Полная цепочка SSL-сертификата и приватный ключ. Протокол QUIC требует обязательного шифрования TLS; поддерживаются сертификаты Let's Encrypt / ACME или самоподписанные. |
-| **SNI** | Имя сервера (Server Name Indication), совпадающее с доменным именем в сертификате. |
-| **Контроль перегрузок** | Алгоритм контроля перегрузок QUIC: `bbr` (рекомендуется для максимальной скорости), `cubic` или `new_reno`. Сервер работает с `bbr` или `new_reno`; `cubic` передаётся клиентам, но на сервере применяется как `new_reno`. |
+| **SNI** | Server Name Indication для профиля клиента. Укажите домен, покрытый сертификатом сервера; это поле не настраивает сертификат listener'а. |
+| **Контроль перегрузок** | Алгоритм QUIC в настройках сервера и экспортируемом профиле: `bbr`, `cubic` или `new_reno`. Сервер использует BBR или New Reno; при выборе CUBIC клиентский профиль сохраняет CUBIC, а сервер пока применяет New Reno. |
 | **ALPN** | Токены протоколов уровня приложений (по умолчанию: `h3`). |
-| **Режим UDP Relay** | Режим инкапсуляции пакетов: `native` (QUIC datagrams, рекомендуется) или `quic`. |
+| **Режим UDP Relay** | Режим UDP в профиле клиента: `native` (QUIC datagrams) или `quic` (однонаправленные потоки). Сервер принимает оба режима независимо от этого значения. |
 | **Zero-RTT Handshake** | Включает 0-RTT возобновление сессий для мгновенного повторного подключения клиентов без ожидания завершения рукопожатия. |
 | **Таймаут аутентификации** | Максимальное время (в секундах) на прохождение аутентификации клиентом (по умолчанию: `3s`). |
 | **Максимальный простой** | Таймаут бездействия (в секундах) перед закрытием неактивных QUIC-соединений (по умолчанию: `15s`). |
@@ -104,6 +104,8 @@ tuic://<uuid>:<password>@<host>:<port>?congestion_control=bbr&alpn=h3&sni=vpn.ex
   - **Нативный Go-движок**: TUIC v5 работает на 100% нативно на Go внутри процесса 3x-ui. Никаких внешних сторонних бинарников скачивать не требуется.
   - **Маршрутизация и каскады в Xray**: Трафик проходит через движок маршрутизации Xray. Теги инбаундов (`in-<port>-udp`) полноценно участвуют в правилах маршрутизации (Routing Rules), блокировках geosite/geoip и перенаправлении в любые аутбаунды (VLESS, Shadowsocks, WARP и др.).
   - **Персональные квоты трафика**: Лимиты трафика (`totalGB`) и сроки действия (`expiryTime`) учитываются и применяются индивидуально для каждого клиента.
+  - **Скорость и общий трафик**: Нативные счётчики клиентов TUIC опрашиваются раз в 10 секунд и передаются в панель для отображения скорости. Xray отдельно считает общий трафик инбаунда через локальный relay; событие скорости TUIC повторно его не начисляет.
+  - **Ограничения UDP**: На одно QUIC-соединение допускается до 256 активных UDP-ассоциаций. Неактивные ассоциации закрываются через пять минут. Это лимит на соединение, а не общий лимит узла. TUIC использует выделенный локальный SOCKS-порт из диапазона `64001–65000`; 3x-ui проверяет его конфликты с управляемыми инбаундами и relay-портами.
   - **Горячее обновление без обрыва связи**: Добавление, редактирование или отключение клиентов обновляет реестр пользователей в памяти без перезапуска порта и без сброса активных сессий других пользователей.
   - **Развёртывание**: Инбаунд TUIC можно создать на дочернем узле или клонировать туда. TUIC-сервер запускает панель самого узла, поэтому на узле нужна панель v3.8.0 или новее; более старый узел главная панель отклоняет.
 </Callout>

+ 25 - 8
docs/public/openapi.json

@@ -16027,15 +16027,9 @@
           },
           {
             "type": "traffic",
-            "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
+            "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
             "payloadSchema": {
               "type": "object",
-              "required": [
-                "onlineClients",
-                "onlineByGuid",
-                "activeInbounds",
-                "lastOnlineMap"
-              ],
               "properties": {
                 "traffics": {
                   "type": "array",
@@ -16049,6 +16043,18 @@
                     "$ref": "#/components/schemas/ClientTraffic"
                   }
                 },
+                "clientTrafficSource": {
+                  "type": "string",
+                  "enum": [
+                    "xray",
+                    "tuic"
+                  ],
+                  "description": "Present for native TUIC samples; omitted Xray samples default to xray."
+                },
+                "clientTrafficIntervalMs": {
+                  "type": "integer",
+                  "description": "Sampling interval used to calculate client speed, in milliseconds."
+                },
                 "nodeTraffics": {
                   "type": "array",
                   "nullable": true,
@@ -16092,13 +16098,24 @@
                 {
                   "required": [
                     "traffics",
-                    "clientTraffics"
+                    "clientTraffics",
+                    "onlineClients",
+                    "onlineByGuid",
+                    "activeInbounds",
+                    "lastOnlineMap"
                   ]
                 },
                 {
                   "required": [
                     "nodeTraffics"
                   ]
+                },
+                {
+                  "required": [
+                    "clientTraffics",
+                    "clientTrafficSource",
+                    "clientTrafficIntervalMs"
+                  ]
                 }
               ]
             },

+ 25 - 8
frontend/public/openapi.json

@@ -16027,15 +16027,9 @@
           },
           {
             "type": "traffic",
-            "summary": "Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.",
+            "summary": "Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.",
             "payloadSchema": {
               "type": "object",
-              "required": [
-                "onlineClients",
-                "onlineByGuid",
-                "activeInbounds",
-                "lastOnlineMap"
-              ],
               "properties": {
                 "traffics": {
                   "type": "array",
@@ -16049,6 +16043,18 @@
                     "$ref": "#/components/schemas/ClientTraffic"
                   }
                 },
+                "clientTrafficSource": {
+                  "type": "string",
+                  "enum": [
+                    "xray",
+                    "tuic"
+                  ],
+                  "description": "Present for native TUIC samples; omitted Xray samples default to xray."
+                },
+                "clientTrafficIntervalMs": {
+                  "type": "integer",
+                  "description": "Sampling interval used to calculate client speed, in milliseconds."
+                },
                 "nodeTraffics": {
                   "type": "array",
                   "nullable": true,
@@ -16092,13 +16098,24 @@
                 {
                   "required": [
                     "traffics",
-                    "clientTraffics"
+                    "clientTraffics",
+                    "onlineClients",
+                    "onlineByGuid",
+                    "activeInbounds",
+                    "lastOnlineMap"
                   ]
                 },
                 {
                   "required": [
                     "nodeTraffics"
                   ]
+                },
+                {
+                  "required": [
+                    "clientTraffics",
+                    "clientTrafficSource",
+                    "clientTrafficIntervalMs"
+                  ]
                 }
               ]
             },

+ 9 - 6
frontend/src/components/command-palette/CommandPalette.tsx

@@ -64,6 +64,7 @@ interface PaletteItem {
 
 export default function CommandPalette() {
   const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
   const navigate = useNavigate();
   const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
   const { isOpen, close } = useCommandPalette();
@@ -194,14 +195,14 @@ export default function CommandPalette() {
   const copySubscription = useCallback(
     async (client: ClientRecord) => {
       if (!client.subId || !allSetting.subURI) {
-        message.warning(t('pages.clients.noSubId'));
+        messageApi.warning(t('pages.clients.noSubId'));
         return;
       }
       const link = `${allSetting.subURI}${client.subId}`;
       const ok = await ClipboardManager.copyText(link);
-      if (ok) message.success(t('copied'));
+      if (ok) messageApi.success(t('copied'));
     },
-    [allSetting.subURI, t],
+    [allSetting.subURI, messageApi, t],
   );
 
   const restartXray = useCallback(async () => {
@@ -210,9 +211,9 @@ export default function CommandPalette() {
       silentSuccess: true,
     });
     if (msg?.success) {
-      message.success(t('commandPalette.restartXraySuccess'));
+      messageApi.success(t('commandPalette.restartXraySuccess'));
     }
-  }, [close, t]);
+  }, [close, messageApi, t]);
 
   const cycleTheme = useCallback(() => {
     if (!isDark) {
@@ -679,13 +680,15 @@ export default function CommandPalette() {
     }
   };
 
-  if (!isOpen) return null;
+  // Kept mounted while closed: restartXray closes the palette before its toast.
+  if (!isOpen) return messageContextHolder;
 
   let lastCategory = '';
   const themeModeClass = isUltra ? 'ultra' : isDark ? 'dark' : 'light';
 
   return (
     <ConfigProvider theme={antdThemeConfig}>
+      {messageContextHolder}
       <div
         className={`command-palette-backdrop ${themeModeClass}`}
         role="presentation"

+ 60 - 4
frontend/src/hooks/useClients.ts

@@ -98,6 +98,8 @@ export interface ClientSpeedEntry {
   down: number;
 }
 
+type ClientSpeedSource = 'xray' | 'tuic';
+
 type ClientStatRow = ClientTraffic & { email?: string };
 
 export function sameSpeedMap(
@@ -299,7 +301,31 @@ export function useClients(options: UseClientsOptions = {}) {
   // settings request still lets the page fall back and render.
   const settingsReady = defaultsQuery.isFetched;
 
-  const [clientSpeed, setClientSpeed] = useState<Record<string, ClientSpeedEntry>>({});
+  const [clientSpeedBySource, setClientSpeedBySource] = useState<
+    Partial<Record<ClientSpeedSource, Record<string, ClientSpeedEntry>>>
+  >({});
+  const clientSpeedExpiryTimers = useRef<Partial<Record<ClientSpeedSource, number>>>({});
+  const clientSpeedSourceVersions = useRef<Record<ClientSpeedSource, number>>({ xray: 0, tuic: 0 });
+  const clientSpeed = useMemo(() => {
+    const combined: Record<string, ClientSpeedEntry> = {};
+    for (const source of Object.values(clientSpeedBySource)) {
+      if (!source) continue;
+      for (const [email, speed] of Object.entries(source)) {
+        const current = combined[email] ?? { up: 0, down: 0 };
+        combined[email] = { up: current.up + speed.up, down: current.down + speed.down };
+      }
+    }
+    return combined;
+  }, [clientSpeedBySource]);
+
+  useEffect(
+    () => () => {
+      for (const timer of Object.values(clientSpeedExpiryTimers.current)) {
+        if (timer !== undefined) window.clearTimeout(timer);
+      }
+    },
+    [],
+  );
   const summary = listQuery.data?.summary ?? DEFAULT_SUMMARY;
 
   const invalidateAll = useCallback(() => {
@@ -725,6 +751,8 @@ export function useClients(options: UseClientsOptions = {}) {
       const p = payload as {
         onlineClients?: string[];
         clientTraffics?: { email: string; up: number; down: number }[];
+        clientTrafficSource?: 'xray' | 'tuic';
+        clientTrafficIntervalMs?: number;
       };
       if (Array.isArray(p.onlineClients)) {
         queryClient.setQueryData(keys.clients.onlines(), p.onlineClients);
@@ -736,17 +764,45 @@ export function useClients(options: UseClientsOptions = {}) {
         // dropped and an unchanged result returns the previous object — which lets
         // React bail out of the update instead of re-rendering the table.
         const next: Record<string, ClientSpeedEntry> = {};
+        const source = p.clientTrafficSource === 'tuic' ? 'tuic' : 'xray';
+        const sampleIntervalMs =
+          typeof p.clientTrafficIntervalMs === 'number' &&
+          Number.isFinite(p.clientTrafficIntervalMs) &&
+          p.clientTrafficIntervalMs > 0
+            ? p.clientTrafficIntervalMs
+            : TRAFFIC_POLL_INTERVAL_S * 1000;
+        const sampleIntervalSeconds = sampleIntervalMs / 1000;
         for (const ct of p.clientTraffics) {
           if (!ct || !ct.email) continue;
           const up = ct.up || 0;
           const down = ct.down || 0;
           if (up === 0 && down === 0) continue;
+          const current = next[ct.email] ?? { up: 0, down: 0 };
           next[ct.email] = {
-            up: up / TRAFFIC_POLL_INTERVAL_S,
-            down: down / TRAFFIC_POLL_INTERVAL_S,
+            up: current.up + up / sampleIntervalSeconds,
+            down: current.down + down / sampleIntervalSeconds,
           };
         }
-        setClientSpeed((prev) => (sameSpeedMap(prev, next) ? prev : next));
+        setClientSpeedBySource((prev) =>
+          sameSpeedMap(prev[source] ?? {}, next) ? prev : { ...prev, [source]: next },
+        );
+
+        const version = ++clientSpeedSourceVersions.current[source];
+        const previousTimer = clientSpeedExpiryTimers.current[source];
+        if (previousTimer !== undefined) window.clearTimeout(previousTimer);
+        clientSpeedExpiryTimers.current[source] = window.setTimeout(
+          () => {
+            if (clientSpeedSourceVersions.current[source] !== version) return;
+            delete clientSpeedExpiryTimers.current[source];
+            setClientSpeedBySource((prev) => {
+              if (!prev[source]) return prev;
+              const nextSources = { ...prev };
+              delete nextSources[source];
+              return nextSources;
+            });
+          },
+          Math.min(sampleIntervalMs * 2, 120_000),
+        );
       }
     },
     [queryClient],

+ 9 - 4
frontend/src/lib/xray/inbound-link.ts

@@ -17,6 +17,7 @@ import { parseGeckoPacketSize } from '@/lib/xray/forms/transport/FinalMaskForm';
 import { getHeaderValue } from './headers';
 import { canEnableTlsFlow } from './protocol-capabilities';
 import { deriveSpiderX } from './spider-x';
+import { vlessEncryptionAuthKind } from './vless-encryption';
 import { normalizeTuicCongestionController, resolveTuicServerSettings } from '@/lib/tuic';
 
 // Share-link generators. Each per-protocol fn takes a typed inbound plus
@@ -1723,9 +1724,13 @@ function wgPeerCommentSuffix(peer: unknown): string {
   return typeof comment === 'string' && comment.trim() !== '' ? ` (${comment.trim()})` : '';
 }
 
+// Only the post-quantum key payloads outgrow a QR; the REALITY ML-KEM hint and the
+// mlkem768x25519plus prefix of an X25519-authenticated encryption do not (#6730).
 export function isPostQuantumLink(link: string): boolean {
-  if (/[?&]pqv=/.test(link)) return true;
-  if (link.includes('mlkem768') || link.includes('mldsa65')) return true;
-  if (link.includes('ML-KEM-768')) return true;
-  return false;
+  const withoutRemark = link.split('#', 1)[0];
+  const queryStart = withoutRemark.indexOf('?');
+  if (queryStart < 0) return false;
+  const params = new URLSearchParams(withoutRemark.slice(queryStart + 1));
+  if (params.get('pqv')) return true;
+  return vlessEncryptionAuthKind(params.get('encryption') ?? '')?.startsWith('mlkem768') ?? false;
 }

+ 24 - 3
frontend/src/pages/api-docs/websocket-events.ts

@@ -120,10 +120,18 @@ const statusPayloadSchema = {
 
 const trafficPayloadSchema = {
   type: 'object',
-  required: ['onlineClients', 'onlineByGuid', 'activeInbounds', 'lastOnlineMap'],
   properties: {
     traffics: { type: 'array', items: { $ref: '#/components/schemas/Traffic' } },
     clientTraffics: { type: 'array', items: { $ref: '#/components/schemas/ClientTraffic' } },
+    clientTrafficSource: {
+      type: 'string',
+      enum: ['xray', 'tuic'],
+      description: 'Present for native TUIC samples; omitted Xray samples default to xray.',
+    },
+    clientTrafficIntervalMs: {
+      type: 'integer',
+      description: 'Sampling interval used to calculate client speed, in milliseconds.',
+    },
     nodeTraffics: {
       type: 'array',
       nullable: true,
@@ -134,7 +142,20 @@ const trafficPayloadSchema = {
     activeInbounds: stringArrayMap,
     lastOnlineMap: timestampMap,
   },
-  oneOf: [{ required: ['traffics', 'clientTraffics'] }, { required: ['nodeTraffics'] }],
+  oneOf: [
+    {
+      required: [
+        'traffics',
+        'clientTraffics',
+        'onlineClients',
+        'onlineByGuid',
+        'activeInbounds',
+        'lastOnlineMap',
+      ],
+    },
+    { required: ['nodeTraffics'] },
+    { required: ['clientTraffics', 'clientTrafficSource', 'clientTrafficIntervalMs'] },
+  ],
 };
 
 const clientStatsPayloadSchema = {
@@ -205,7 +226,7 @@ export function buildWebSocketEvents(
     {
       type: 'traffic',
       summary:
-        'Live traffic deltas plus online, per-node and last-online maps. Local polls send traffics/clientTraffics; node polls send nodeTraffics.',
+        'Live traffic deltas plus online, per-node and last-online maps. TUIC also sends source-tagged client deltas with their sampling interval for live speed.',
       payloadSchema: trafficPayloadSchema,
       example: {
         type: 'traffic',

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -60,7 +60,7 @@ export default function AttachClientsModal({
   // React resets this during render rather than in an effect so the modal's
   // first open frame already shows cleared fields.
   const openSource = open ? source : null;
-  const [syncedSource, setSyncedSource] = useState(openSource);
+  const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
   if (openSource !== syncedSource) {
     setSyncedSource(openSource);
     if (openSource) {

+ 1 - 1
frontend/src/pages/inbounds/clients/DetachClientsModal.tsx

@@ -54,7 +54,7 @@ export default function DetachClientsModal({
 
   // Reset during render, not in an effect, so the first frame is already clean.
   const openSource = open ? source : null;
-  const [syncedSource, setSyncedSource] = useState(openSource);
+  const [syncedSource, setSyncedSource] = useState<DBInbound | null>(null);
   if (openSource !== syncedSource) {
     setSyncedSource(openSource);
     if (openSource) {

+ 8 - 6
frontend/src/pages/inbounds/form/protocols/tuic.tsx

@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
 
 export default function TuicFields() {
   const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
   const { control, setValue } = useFormContext();
   const [loadingPanelCert, setLoadingPanelCert] = useState(false);
 
@@ -36,7 +37,7 @@ export default function TuicFields() {
   const autofillFromSni = () => {
     const cleanSni = (sni || '').trim();
     if (!cleanSni) {
-      message.warning(t('pages.xray.tuic.sniRequired'));
+      messageApi.warning(t('pages.xray.tuic.sniRequired'));
       return;
     }
     setValue('settings.server.certificate', `/root/cert/${cleanSni}/fullchain.pem`);
@@ -51,12 +52,12 @@ export default function TuicFields() {
           ? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
           : await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
       if (!msg?.success) {
-        message.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
+        messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
         return;
       }
       const obj = msg.obj as { webCertFile?: string; webKeyFile?: string };
       if (!obj?.webCertFile && !obj?.webKeyFile) {
-        message.warning(t('pages.inbounds.setDefaultCertEmpty'));
+        messageApi.warning(t('pages.inbounds.setDefaultCertEmpty'));
         return;
       }
       if (obj.webCertFile) {
@@ -65,9 +66,9 @@ export default function TuicFields() {
       if (obj.webKeyFile) {
         setValue('settings.server.private_key', obj.webKeyFile);
       }
-      message.success(t('pages.inbounds.setSuccess'));
+      messageApi.success(t('pages.inbounds.setSuccess'));
     } catch {
-      message.error(t('somethingWentWrong'));
+      messageApi.error(t('somethingWentWrong'));
     } finally {
       setLoadingPanelCert(false);
     }
@@ -145,6 +146,7 @@ export default function TuicFields() {
 
   return (
     <>
+      {messageContextHolder}
       <Form.Item label={t('pages.inbounds.publicKey')}>
         <AutoComplete
           value={certificate}
@@ -164,7 +166,7 @@ export default function TuicFields() {
       </Form.Item>
 
       <Form.Item label=" ">
-        <Space>
+        <Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
           <Button
             type="primary"
             icon={<CloudDownloadOutlined />}

+ 2 - 0
frontend/src/pages/index/PanelUpdateModal.css

@@ -19,6 +19,8 @@
 
 .actions-row {
   display: flex;
+  flex-wrap: wrap;
   justify-content: flex-end;
+  gap: 8px;
   margin-top: 12px;
 }

+ 2 - 0
frontend/src/pages/index/VersionModal.css

@@ -25,6 +25,8 @@
 
 .actions-row {
   display: flex;
+  flex-wrap: wrap;
   justify-content: flex-end;
+  gap: 8px;
   margin-top: 12px;
 }

+ 4 - 2
frontend/src/pages/settings/HappSettingsContent.tsx

@@ -28,6 +28,7 @@ export default function HappSettingsContent({
   remoteSourceBadge,
 }: HappSettingsContentProps) {
   const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
   // Generator choices stay local until Apply updates the draft; page Save persists it.
   const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
   const [includeAdblock, setIncludeAdblock] = useState(false);
@@ -37,18 +38,19 @@ export default function HappSettingsContent({
     const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
     if (payload) {
       updateSetting({ subRoutingRules: payload });
-      message.success(t('pages.settings.subHappPresetApplied'));
+      messageApi.success(t('pages.settings.subHappPresetApplied'));
     }
   };
 
   const handleBuildDeeplink = (deeplink: string) => {
     updateSetting({ subRoutingRules: deeplink });
     setIsModalOpen(false);
-    message.success(t('pages.settings.subHappDeeplinkGenerated'));
+    messageApi.success(t('pages.settings.subHappDeeplinkGenerated'));
   };
 
   return (
     <>
+      {messageContextHolder}
       <SettingListItem
         paddings="small"
         title={t('pages.settings.subHappAutoDetect')}

+ 4 - 2
frontend/src/pages/xray/routing/RoutingTab.tsx

@@ -45,6 +45,7 @@ export default function RoutingTab({
   isMobile,
 }: RoutingTabProps) {
   const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
   const [modal, modalContextHolder] = Modal.useModal();
   const [ruleModalOpen, setRuleModalOpen] = useState(false);
   const [editingRule, setEditingRule] = useState<RoutingRule | null>(null);
@@ -179,7 +180,7 @@ export default function RoutingTab({
     try {
       parsed = JSON.parse(value);
     } catch {
-      message.error(t('pages.xray.importInvalidJson'));
+      messageApi.error(t('pages.xray.importInvalidJson'));
       return;
     }
     const obj = parsed as { rules?: unknown; routing?: { rules?: unknown } };
@@ -191,7 +192,7 @@ export default function RoutingTab({
           ? obj.routing!.rules
           : null;
     if (!list) {
-      message.error(t('pages.xray.importInvalidJson'));
+      messageApi.error(t('pages.xray.importInvalidJson'));
       return;
     }
     mutate((tt) => {
@@ -347,6 +348,7 @@ export default function RoutingTab({
   return (
     <>
       {modalContextHolder}
+      {messageContextHolder}
       <Tabs
         defaultActiveKey="basic"
         items={[

+ 43 - 0
frontend/src/test/attach-clients-modal.test.tsx

@@ -0,0 +1,43 @@
+import { describe, expect, it } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import AttachClientsModal from '@/pages/inbounds/clients/AttachClientsModal';
+import { DBInbound } from '@/models/dbinbound';
+
+import { renderWithProviders } from './test-utils';
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({
+      clients: [
+        { id: 'uuid-1', email: 'alice@test' },
+        { id: 'uuid-2', email: 'bob@test' },
+      ],
+      decryption: 'none',
+    }),
+    streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
+    sniffing: '',
+  });
+}
+
+describe('AttachClientsModal', () => {
+  it('lists the source clients, selected, when first mounted already open', async () => {
+    renderWithProviders(
+      <AttachClientsModal open source={sourceInbound()} dbInbounds={[]} onClose={() => {}} />,
+    );
+
+    expect(await screen.findByText('alice@test')).toBeTruthy();
+    expect(screen.getByText('bob@test')).toBeTruthy();
+
+    const boxes = screen.getAllByRole('checkbox') as HTMLInputElement[];
+    const rowBoxes = boxes.slice(1);
+    expect(rowBoxes).toHaveLength(2);
+    expect(rowBoxes.every((b) => b.checked)).toBe(true);
+  });
+});

+ 67 - 0
frontend/src/test/clients-summary.test.tsx

@@ -115,4 +115,71 @@ describe('client summary always reflects the server, never a client_stats recomp
 
     expect(result.current.summary).toEqual(serverSummary);
   });
+
+  it('combines independently sampled TUIC and Xray speeds and replaces each source snapshot', async () => {
+    const result = await loadedHook();
+
+    act(() => {
+      result.current.applyTrafficEvent({
+        clientTraffics: [{ email: '[email protected]', up: 100, down: 150 }],
+      });
+    });
+    expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 20, down: 30 });
+
+    act(() => {
+      result.current.applyTrafficEvent({
+        clientTrafficSource: 'tuic',
+        clientTrafficIntervalMs: 10_000,
+        clientTraffics: [
+          { email: '[email protected]', up: 300, down: 100 },
+          { email: '[email protected]', up: 100, down: 100 },
+        ],
+      });
+    });
+    expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 60, down: 50 });
+
+    act(() => {
+      result.current.applyTrafficEvent({
+        clientTraffics: [{ email: '[email protected]', up: 50, down: 25 }],
+      });
+    });
+    expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 50, down: 25 });
+
+    act(() => {
+      result.current.applyTrafficEvent({
+        clientTrafficSource: 'tuic',
+        clientTrafficIntervalMs: 10_000,
+        clientTraffics: [],
+      });
+    });
+    expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 10, down: 5 });
+  });
+
+  it('expires stale per-source speeds without clearing the other source', async () => {
+    const result = await loadedHook();
+    vi.useFakeTimers();
+    try {
+      act(() => {
+        result.current.applyTrafficEvent({
+          clientTrafficSource: 'xray',
+          clientTrafficIntervalMs: 1_000,
+          clientTraffics: [{ email: '[email protected]', up: 100, down: 200 }],
+        });
+        result.current.applyTrafficEvent({
+          clientTrafficSource: 'tuic',
+          clientTrafficIntervalMs: 2_000,
+          clientTraffics: [{ email: '[email protected]', up: 300, down: 400 }],
+        });
+      });
+      expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 250, down: 400 });
+
+      act(() => vi.advanceTimersByTime(2_000));
+      expect(result.current.clientSpeed['[email protected]']).toEqual({ up: 150, down: 200 });
+
+      act(() => vi.advanceTimersByTime(2_000));
+      expect(result.current.clientSpeed).toEqual({});
+    } finally {
+      vi.useRealTimers();
+    }
+  });
 });

+ 36 - 0
frontend/src/test/detach-clients-modal.test.tsx

@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { screen } from '@testing-library/react';
+
+import DetachClientsModal from '@/pages/inbounds/clients/DetachClientsModal';
+import { DBInbound } from '@/models/dbinbound';
+
+import { renderWithProviders } from './test-utils';
+
+function sourceInbound() {
+  return new DBInbound({
+    id: 7,
+    port: 443,
+    listen: '',
+    protocol: 'vless',
+    remark: 'edge',
+    enable: true,
+    settings: JSON.stringify({
+      clients: [
+        { id: 'uuid-1', email: 'alice@test' },
+        { id: 'uuid-2', email: 'bob@test' },
+      ],
+      decryption: 'none',
+    }),
+    streamSettings: JSON.stringify({ network: 'tcp', security: 'none' }),
+    sniffing: '',
+  });
+}
+
+describe('DetachClientsModal', () => {
+  it('lists the attached clients when first mounted already open', async () => {
+    renderWithProviders(<DetachClientsModal open source={sourceInbound()} onClose={() => {}} />);
+
+    expect(await screen.findByText('alice@test')).toBeTruthy();
+    expect(screen.getByText('bob@test')).toBeTruthy();
+  });
+});

+ 16 - 1
frontend/src/test/happ-routing-editor.test.tsx

@@ -1,6 +1,6 @@
 import { useState } from 'react';
 import { describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, screen, within } from '@testing-library/react';
+import { act, cleanup, fireEvent, screen, within } from '@testing-library/react';
 import { EditorView } from 'codemirror';
 
 import { AllSetting } from '@/models/setting';
@@ -319,4 +319,19 @@ describe('Happ routing editor', () => {
     fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
     expect(generatedProfile()).toEqual(minimal);
   });
+
+  // The static message API outlived the test file and logged act() warnings
+  // after teardown, failing CI with "Closing rpc while onUserConsoleLog was pending".
+  it('takes its toast down with it when unmounted', async () => {
+    renderSettings();
+    openEditor();
+    fireEvent.click(screen.getByRole('button', { name: 'Generate Deeplink' }));
+    await screen.findByText('Deeplink generated and applied to routing rules');
+
+    cleanup();
+
+    expect(document.body.textContent).not.toContain(
+      'Deeplink generated and applied to routing rules',
+    );
+  });
 });

+ 0 - 3
frontend/src/test/happ-settings-presets.test.tsx

@@ -1,15 +1,12 @@
 import { useState } from 'react';
 import { describe, expect, it, vi } from 'vitest';
 import { fireEvent, screen } from '@testing-library/react';
-import { message } from 'antd';
 
 import { AllSetting } from '@/models/setting';
 import HappSettingsContent from '@/pages/settings/HappSettingsContent';
 
 import { renderWithProviders } from './test-utils';
 
-vi.spyOn(message, 'success').mockImplementation(() => undefined as never);
-
 const chinaProfile = {
   Name: 'Bypass-CN',
   GlobalProxy: 'true',

+ 44 - 0
frontend/src/test/inbound-link.test.ts

@@ -16,6 +16,7 @@ import {
   genVmessLink,
   genWireguardConfig,
   genWireguardLink,
+  isPostQuantumLink,
   preferPublicHost,
   resolveAddr,
 } from '@/lib/xray/inbound-link';
@@ -1415,3 +1416,46 @@ describe('genTuicLink', () => {
     expect(link).not.toContain('#TUIC-Node-US-US');
   });
 });
+
+describe('isPostQuantumLink', () => {
+  type RealityFixture = {
+    settings: { clients: Array<{ id: string }>; encryption?: string };
+    streamSettings: { realitySettings: { settings: { mldsa65Verify?: string } } };
+  };
+  const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-tcp-reality')!;
+  const clientId = (raw as RealityFixture).settings.clients[0].id;
+  const x25519Key = 'G3cdPSd1-NnlpTbWNSM5vHsT5VNzWfFzYSKwbUMnV1Y';
+  const mlkem768Key = 'A'.repeat(1579);
+
+  function realityLink(edit: (inbound: RealityFixture) => void = () => {}): string {
+    const copy = structuredClone(raw) as RealityFixture;
+    edit(copy);
+    return genVlessLink({ inbound: InboundSchema.parse(copy), address: 'example.test', clientId });
+  }
+
+  // #6730: the REALITY ML-KEM support hint is a short flag, not a large PQ payload.
+  it('keeps the QR for a plain REALITY link', () => {
+    expect(isPostQuantumLink(realityLink())).toBe(false);
+  });
+
+  it('keeps the QR for VLESS encryption authenticated by an X25519 key', () => {
+    const link = realityLink((ib) => {
+      ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${x25519Key}`;
+    });
+    expect(isPostQuantumLink(link)).toBe(false);
+  });
+
+  it('hides the QR for VLESS encryption authenticated by an ML-KEM-768 key', () => {
+    const link = realityLink((ib) => {
+      ib.settings.encryption = `mlkem768x25519plus.native.0rtt.${mlkem768Key}`;
+    });
+    expect(isPostQuantumLink(link)).toBe(true);
+  });
+
+  it('hides the QR for a REALITY link carrying an ML-DSA-65 verify key', () => {
+    const link = realityLink((ib) => {
+      ib.streamSettings.realitySettings.settings.mldsa65Verify = 'B'.repeat(2603);
+    });
+    expect(isPostQuantumLink(link)).toBe(true);
+  });
+});

+ 31 - 0
frontend/src/test/no-static-message.test.ts

@@ -0,0 +1,31 @@
+import { readFileSync, readdirSync, statSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { join, relative, resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+const srcRoot = resolve(fileURLToPath(import.meta.url), '../..');
+const staticCall = /(?<![\w.$])message\.(success|error|warning|info|loading|open)\(/;
+
+function sourceFiles(dir: string): string[] {
+  return readdirSync(dir).flatMap((name) => {
+    const path = join(dir, name);
+    if (statSync(path).isDirectory()) return name === 'test' ? [] : sourceFiles(path);
+    return /\.tsx?$/.test(name) ? [path] : [];
+  });
+}
+
+// antd's static message renders outside React: it ignores the theme and its
+// timers outlive the component, which broke CI after the Happ tests tore down.
+describe('antd message', () => {
+  it('is only used through message.useMessage()', () => {
+    const offenders = sourceFiles(srcRoot).flatMap((file) =>
+      readFileSync(file, 'utf8')
+        .split('\n')
+        .flatMap((line, i) =>
+          staticCall.test(line) ? [`${relative(srcRoot, file)}:${i + 1}: ${line.trim()}`] : [],
+        ),
+    );
+    expect(offenders).toEqual([]);
+  });
+});

+ 168 - 0
install.sh

@@ -1384,6 +1384,169 @@ setup_fail2ban() {
     return 0
 }
 
+# Major version of the local systemd, 0 when it cannot be determined. The
+# SystemCallFilter=@system-service group only exists from systemd 239 on (other
+# @-named groups exist since 231); on older versions an unknown group is not
+# ignored safely, the filter stays in force and leaves a whitelist the panel
+# cannot run under.
+_xui_systemd_major_version() {
+    local version=""
+    if command -v systemctl > /dev/null 2>&1; then
+        version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
+    fi
+    if [[ ! "$version" =~ ^[0-9]+$ ]]; then
+        echo 0
+        return 0
+    fi
+    echo "$version"
+}
+
+# The shipped units list hardening that older systemd does not know: the
+# directive is logged and ignored at load time rather than rejected, so the
+# panel still starts, only without that protection. Each entry is the systemd
+# release that introduced the directive (systemd.exec(5)); everything else in
+# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
+# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
+_xui_warn_unsupported_hardening() {
+    local version entry missing=""
+    version="$(_xui_systemd_major_version)"
+    [[ "$version" -gt 0 ]] || return 0
+    for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
+        ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
+        SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
+        ProtectKernelLogs:244 ProtectClock:245; do
+        if [[ "$version" -lt "${entry##*:}" ]]; then
+            missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
+        fi
+    done
+    [[ -n "$missing" ]] || return 0
+    echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
+    echo "      Not applied, needs a newer systemd: ${missing}."
+    if [[ "$version" -lt 231 ]]; then
+        echo "      The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
+    fi
+    echo "      The rest of the hardening is in force. Upgrade systemd to apply the above."
+    return 0
+}
+
+# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
+# strict would make the whole hierarchy read-only (only the kernel API
+# filesystems stay as they are), and that would break the panel's own use of
+# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
+# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
+# the files in -- the unit's WorkingDirectory on a stock install, and what a
+# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
+# either misses a relocated store -- the panel then cannot write its own SQLite
+# database and sits in a Restart=on-failure loop -- or forces the operator to
+# edit a file that every install/update overwrites from the release tarball.
+# install.sh and update.sh therefore regenerate the drop-in from the folders
+# actually in use, and the unit's own ReadWritePaths only carry the
+# plain-install defaults. A relocated store means re-running install or update:
+# the drop-in is only written here.
+_xui_service_write_paths_dropin() {
+    # $1 is the env file to resolve the XUI_* folders from; callers pass nothing
+    # and get the OS-specific path the unit itself uses.
+    local env_file="${1:-}"
+    local dropin_dir dropin temp_file
+    local db_folder log_folder bin_folder main_folder
+    local path line="" whitespace_paths="" seen_paths="" escaped_path
+
+    if [[ -z "$env_file" ]]; then
+        case "${release}" in
+            ubuntu | debian | armbian)
+                env_file="/etc/default/x-ui"
+                ;;
+            arch | manjaro | parch | alpine)
+                env_file="/etc/conf.d/x-ui"
+                ;;
+            *)
+                env_file="/etc/sysconfig/x-ui"
+                ;;
+        esac
+    fi
+    if [[ -r "$env_file" ]]; then
+        set -a
+        # shellcheck disable=SC1090
+        source "$env_file"
+        set +a
+    fi
+
+    # XUI_* wins over the script's own default: the unit hands that same env
+    # file to the panel through EnvironmentFile=, so these are the folders it
+    # will actually use.
+    main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
+    db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
+    log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
+    # An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
+    # directory, which the unit sets to the main folder.
+    bin_folder="${XUI_BIN_FOLDER:-bin}"
+    if [[ "$bin_folder" != /* ]]; then
+        bin_folder="${main_folder%/}/${bin_folder#./}"
+    fi
+
+    for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
+        [[ "$path" == /* ]] || continue
+        # ReadWritePaths= is a whitespace-separated list, and a folder whose
+        # name contains whitespace cannot be written into it without relying on
+        # quoting. A wrong entry makes systemd reject the whole drop-in and the
+        # panel would not start, so leave such a folder out and say so instead.
+        if [[ "$path" != "${path//[[:space:]]/}" ]]; then
+            whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
+            continue
+        fi
+        case " $seen_paths " in
+            *" $path "*) continue ;;
+        esac
+        seen_paths="${seen_paths}${seen_paths:+ }$path"
+        # systemd expands %-specifiers in unit files, so a folder name carrying
+        # a literal % has to be written as %%, or the entry stops naming the
+        # folder systemd is meant to keep writable.
+        escaped_path="${path//%/%%}"
+        line="${line} -${escaped_path}"
+    done
+    if [[ -n "$whitespace_paths" ]]; then
+        echo "Warning: these folders contain whitespace and were left out of" >&2
+        echo "         10-xui-sandbox.conf: $whitespace_paths" >&2
+        echo "         The panel cannot write to them under the unit's sandbox." >&2
+    fi
+    line="${line# }"
+    [[ -n "$line" ]] || return 1
+
+    dropin_dir="${xui_service}/x-ui.service.d"
+    dropin="${dropin_dir}/10-xui-sandbox.conf"
+    temp_file="${dropin}.tmp.$$"
+
+    mkdir -p "$dropin_dir" || return 1
+    cat > "$temp_file" << EOF
+# Regenerated by install.sh/update.sh on every install and update: edits here
+# are lost, and the list only reflects the XUI_* variables read from
+# ${env_file} at that moment. Re-run install/update after moving a store.
+# It lists the folders the panel writes to. Put local additions in their own
+# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
+[Service]
+ReadWritePaths=${line}
+ReadWriteDirectories=${line}
+EOF
+    if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
+        cat >> "$temp_file" << 'EOF'
+# @system-service needs systemd >= 239; on older versions the unknown group
+# would leave the panel with a filter it cannot start under (x-ui.service.*).
+SystemCallFilter=@system-service
+SystemCallErrorNumber=EPERM
+EOF
+    fi
+    if [[ ! -s "$temp_file" ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    chmod 644 "$temp_file"
+    mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
+    if command -v systemctl > /dev/null 2>&1; then
+        systemctl daemon-reload > /dev/null 2>&1 || true
+    fi
+    return 0
+}
+
 # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
 # atomic mv, so a failed cp/curl or an interrupted mv never leaves a
 # truncated unit file at the live path -- systemd would then fail to parse
@@ -1415,6 +1578,11 @@ _install_xui_service_unit() {
         rm -f "$temp_file"
         return 1
     fi
+    if ! _xui_service_write_paths_dropin; then
+        echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
+        echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
+    fi
+    _xui_warn_unsupported_hardening
     return 0
 }
 

+ 57 - 0
internal/sub/hysteria_server_description_test.go

@@ -0,0 +1,57 @@
+package sub
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// #6738: without the host's Description on hysteria(2):// links, Happ falls back
+// to its own "Hysteria | hysteria | TLS" caption on a host that also serves VLESS.
+func TestGenHysteriaLinkAppendsHostServerDescription(t *testing.T) {
+	tests := map[string]struct {
+		version int
+		scheme  string
+	}{
+		"hysteria v1": {version: 1, scheme: "hysteria://"},
+		"hysteria v2": {version: 2, scheme: "hysteria2://"},
+	}
+	for name, tc := range tests {
+		t.Run(name, func(t *testing.T) {
+			host := &model.Host{
+				Address: "hy.example.com", Port: 443,
+				Remark: "Poland", ServerDescription: "Wi-Fi",
+			}
+			stream := map[string]any{
+				"security":      "tls",
+				"externalProxy": []any{hostToExternalProxyMap(host, "hy.example.com", 443)},
+			}
+			rawStream, err := json.Marshal(stream)
+			if err != nil {
+				t.Fatalf("marshal stream settings: %v", err)
+			}
+			// The inbound's own `version` picks the hysteria vs hysteria2 scheme.
+			rawSettings, err := json.Marshal(map[string]any{
+				"version": tc.version,
+				"clients": []any{map[string]any{"auth": "secret", "email": "user"}},
+			})
+			if err != nil {
+				t.Fatalf("marshal inbound settings: %v", err)
+			}
+			in := &model.Inbound{
+				Id: 920010, Listen: "203.0.113.1", Port: 443, Protocol: model.Hysteria,
+				Remark: "hy", StreamSettings: string(rawStream), Settings: string(rawSettings),
+			}
+			got := (&SubService{}).genHysteriaLink(in, "user")
+			if !strings.HasPrefix(got, tc.scheme) {
+				t.Fatalf("link scheme changed.\n got: %s\nwant prefix: %s", got, tc.scheme)
+			}
+			// base64("Wi-Fi"), matching the reporter's subscription output.
+			if !strings.HasSuffix(got, "?serverDescription=V2ktRmk=") {
+				t.Fatalf("host serverDescription missing from fragment.\n got: %s\nwant suffix: ?serverDescription=V2ktRmk=", got)
+			}
+		})
+	}
+}

+ 5 - 1
internal/sub/service.go

@@ -1535,7 +1535,11 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
 			applyExternalProxyHysteriaParams(ep, epParams)
 
 			link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF)))
-			links = append(links, buildLinkWithParams(link, epParams, s.endpointRemark(inbound, email, ep, "quic")))
+			// VLESS/Trojan/SS get the host's description through buildEndpointLinks;
+			// this loop renders the fragment itself, so add it here too (#6738).
+			remark := s.endpointRemark(inbound, email, ep, "quic")
+			remark = appendHappServerDescription(remark, externalProxyToEndpoint(ep).ServerDescription)
+			links = append(links, buildLinkWithParams(link, epParams, remark))
 		}
 		return strings.Join(links, "\n")
 	}

+ 37 - 1
internal/web/job/tuic_job.go

@@ -2,16 +2,22 @@ package job
 
 import (
 	"fmt"
+	"sync"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/tuic"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 )
 
+const defaultTuicSpeedSampleInterval = 10 * time.Second
+
 type TuicJob struct {
-	inboundService service.InboundService
+	inboundService  service.InboundService
+	runMu           sync.Mutex
+	lastSpeedSample time.Time
 }
 
 func NewTuicJob() *TuicJob {
@@ -19,6 +25,9 @@ func NewTuicJob() *TuicJob {
 }
 
 func (j *TuicJob) Run() {
+	j.runMu.Lock()
+	defer j.runMu.Unlock()
+
 	tuicJournalMu.Lock()
 	journalErr := j.replayTuicJournal()
 	tuicJournalMu.Unlock()
@@ -44,20 +53,28 @@ func (j *TuicJob) Run() {
 	onlineEmails, _ := mgr.GetActiveClients(30 * time.Second)
 
 	clientTraffics := aggregateTuicClientTraffic(clientDeltas, onlineEmails)
+	sampledAt := time.Now()
+	sampleInterval := tuicSpeedSampleInterval(j.lastSpeedSample, sampledAt)
 
 	// Inbound total traffic is already metered through the loopback SOCKS relay
 	// by xray_traffic_job (matching mtproto); only per-client deltas are submitted here.
+	persisted := true
 	if len(clientTraffics) > 0 {
 		needRestart, _, err := j.inboundService.AddTraffic(nil, clientTraffics)
 		if err != nil {
 			logger.Warning("tuic job: add traffic failed:", err)
 			mgr.RequeueClientTraffic(clientDeltas)
+			persisted = false
 		} else if needRestart {
 			if desired, err := j.inboundService.DesiredTuicInstances(); err == nil {
 				mgr.Reconcile(desired)
 			}
 		}
 	}
+	if persisted {
+		websocket.BroadcastTraffic(tuicSpeedPayload(clientTraffics, sampleInterval))
+		j.lastSpeedSample = sampledAt
+	}
 
 	if len(onlineEmails) > 0 {
 		if err := j.inboundService.BumpClientsLastOnline(onlineEmails); err != nil {
@@ -68,6 +85,25 @@ func (j *TuicJob) Run() {
 	j.inboundService.RefreshLocalOnlineClients(onlineEmails, activeTags)
 }
 
+func tuicSpeedSampleInterval(previous, current time.Time) time.Duration {
+	if previous.IsZero() || !current.After(previous) {
+		return defaultTuicSpeedSampleInterval
+	}
+	return current.Sub(previous)
+}
+
+func tuicSpeedPayload(clientTraffics []*xray.ClientTraffic, sampleInterval time.Duration) map[string]any {
+	intervalMs := sampleInterval.Milliseconds()
+	if intervalMs < 1 {
+		intervalMs = 1
+	}
+	return map[string]any{
+		"clientTraffics":          clientTraffics,
+		"clientTrafficSource":     "tuic",
+		"clientTrafficIntervalMs": intervalMs,
+	}
+}
+
 // FlushStoppedTraffic persists counters drained when the TUIC manager stops its
 // listeners. Call it after scheduled jobs have stopped and before the traffic
 // writer shuts down.

+ 11 - 0
internal/web/job/tuic_job_test.go

@@ -180,3 +180,14 @@ func TestAggregateTuicClientTrafficPreservesStableIdentityAcrossEmailRename(t *t
 		t.Fatalf("aggregate counters = (%d,%d), want (40,60)", got[0].Up, got[0].Down)
 	}
 }
+
+func TestTuicSpeedSampleIntervalUsesElapsedPollTime(t *testing.T) {
+	current := time.Date(2026, time.October, 3, 12, 0, 10, 0, time.UTC)
+	previous := current.Add(-12 * time.Second)
+	if got := tuicSpeedSampleInterval(previous, current); got != 12*time.Second {
+		t.Fatalf("sample interval = %s, want 12s", got)
+	}
+	if got := tuicSpeedSampleInterval(time.Time{}, current); got != defaultTuicSpeedSampleInterval {
+		t.Fatalf("initial sample interval = %s, want %s", got, defaultTuicSpeedSampleInterval)
+	}
+}

+ 1 - 0
internal/web/node_contract_test.go

@@ -362,6 +362,7 @@ var remoteMethodsOutsideContract = map[string]string{
 	"AdoptInboundAlias":     "local alias bookkeeping",
 	"AdoptedInboundAliases": "local alias bookkeeping",
 	"AdvancePushedInbound":  "local fingerprint bookkeeping",
+	"ForgetPushedInbound":   "local fingerprint bookkeeping",
 	"UpdatePanel":           "replaces the node binary; node-sync is denied it on purpose (#6201)",
 }
 

+ 11 - 0
internal/web/runtime/remote.go

@@ -528,6 +528,17 @@ func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
 	r.recordPushedInbound(ib)
 }
 
+// ForgetPushedInbound drops the reconcile-skip fingerprint once the node is seen
+// without the payload it stamped, so the next reconcile re-sends the inbound.
+func (r *Remote) ForgetPushedInbound(tag string) {
+	prefix := nodeInboundTagPrefix(r.node.Id)
+	bare := strings.TrimPrefix(tag, prefix)
+	r.mu.Lock()
+	delete(r.pushedFP, bare)
+	delete(r.pushedFP, prefix+bare)
+	r.mu.Unlock()
+}
+
 // AdoptInboundAlias records a deployed alias without mutating either panel.
 // The runtime association is rediscovered after a master restart.
 func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) {

+ 21 - 12
internal/web/service/client_sync_orphan_test.go

@@ -30,8 +30,8 @@ func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) {
 	}
 }
 
-// The merge must soft-orphan, not delete: everything stays recoverable until
-// the grace period has elapsed and the reaper confirms nothing reclaimed it.
+// A partial snapshot (node alive, still serving another client) authoritatively drops one;
+// the merge soft-orphans, recoverable until the grace elapses and the reaper confirms it.
 func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
 	db := initTrafficTestDB(t)
 	svc := &InboundService{}
@@ -40,16 +40,20 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
 	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
 
 	const email = "gone@x"
-	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
-	settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
-	syncNodeWithSettings(t, svc, 1, "n1-in", settings,
+	const keep = "keep@x"
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
+	bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
+	syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
+		xray.ClientTraffic{Email: keep, Enable: true},
 		xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
 
 	if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
 		t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf)
 	}
 
-	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
+	keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
+		xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
 		t.Fatalf("orphaning merge: %v", err)
 	}
 	if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 {
@@ -84,7 +88,7 @@ func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) {
 	}
 }
 
-// A client the node reports again was never gone: clearing the mark is what
+// A client the node reports again (partial snapshot) was never gone: clearing the mark
 // turns a bad merge into a recoverable blip instead of a delayed deletion.
 func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
 	db := initTrafficTestDB(t)
@@ -94,19 +98,24 @@ func TestSyncOrphanMarkClearedOnReattach(t *testing.T) {
 	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
 
 	const email = "flaky@x"
-	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
-	settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
-	syncNodeWithSettings(t, svc, 1, "n1-in", settings,
+	const keep = "keep@x"
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, keep)
+	bothSettings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true},{"email":%q,"enable":true}]}`, keep, email)
+	syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
+		xray.ClientTraffic{Email: keep, Enable: true},
 		xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true})
 
-	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
+	keepOnly := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, keep)
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnly,
+		xray.ClientTraffic{Email: keep, Enable: true}), false, false); err != nil {
 		t.Fatalf("orphaning merge: %v", err)
 	}
 	if readOrphanMark(t, db, email) <= 0 {
 		t.Fatal("setup: expected the merge to mark the client")
 	}
 
-	syncNodeWithSettings(t, svc, 1, "n1-in", settings,
+	syncNodeWithSettings(t, svc, 1, "n1-in", bothSettings,
+		xray.ClientTraffic{Email: keep, Enable: true},
 		xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true})
 
 	if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 {

+ 3 - 0
internal/web/service/inbound_amneziawg_test.go

@@ -287,6 +287,9 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
 }
 
 func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
+	// GetAmneziaWGLogs appends peer handshake activity, which reads the DB;
+	// own a throwaway one so -shuffle can't leave us the global nil DB.
+	setupConflictDB(t)
 	logger.InitLogger(logging.DEBUG)
 	logger.Info("amneziawg: started interface awg1 for inbound 1")
 	logger.Info("xray: unrelated line that must never show up here")

+ 43 - 1
internal/web/service/inbound_node.go

@@ -427,6 +427,20 @@ func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model
 	return &a
 }
 
+// snapshotDropsEveryHubClient reports a node that lists no clients where the hub
+// still links some: a reset or half-started node, never an authoritative removal.
+func snapshotDropsEveryHubClient(tx *gorm.DB, inboundID int, wireSettings string) bool {
+	clients, err := ParseInboundSettingsClients(wireSettings)
+	if err != nil || len(clients) > 0 {
+		return false
+	}
+	var links int64
+	if err := tx.Table("client_inbounds").Where("inbound_id = ?", inboundID).Count(&links).Error; err != nil {
+		return false
+	}
+	return links > 0
+}
+
 // clientEmailsOwnedElsewhere returns the emails attached only to inbounds of
 // other nodes: email is unique, so adopting one would overwrite a client this
 // node does not serve. Attached nowhere means soft-orphaned, hence adoptable.
@@ -644,6 +658,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		wireSettings string
 	}
 	var pendingAdopts []pendingAdopt
+	degradedInbounds := map[int]string{}
 
 	newInboundIDs := make(map[int]struct{})
 
@@ -782,7 +797,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			adoptedSettings = deduped
 		}
 		updates := map[string]any{}
-		if !dirty {
+		if !dirty && snapshotDropsEveryHubClient(tx, c.Id, adoptedSettings) {
+			degradedInbounds[c.Id] = c.Tag
+		} else if !dirty {
 			// Defer lifecycle lift until after client_traffics absorbs this tick's
 			// deltas so quota stale-disable matches SQL (#6228).
 			pendingAdopts = append(pendingAdopts, pendingAdopt{
@@ -1121,6 +1138,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			if k.inboundID != c.Id {
 				continue
 			}
+			if _, degraded := degradedInbounds[c.Id]; degraded {
+				continue
+			}
 			if _, kept := snapEmails[k.email]; kept {
 				continue
 			}
@@ -1228,6 +1248,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 			applyMasterClientLifecycle(&clients[i], existing, csPtr)
 			filtered = append(filtered, clients[i])
 		}
+		// A degraded node (reset/restart/removal) reports zero clients for an inbound the
+		// hub populates; adopting it empties links and ReapSyncOrphans deletes shared clients (#6734).
+		if _, degraded := degradedInbounds[c.Id]; degraded {
+			logger.Warningf("setRemoteTraffic: node %d reported zero clients for tag %q while the hub has %d attached — keeping them and re-pushing", nodeID, snapIb.Tag, len(oldEmailsRows))
+			syncFailedInbounds[c.Id] = struct{}{}
+			continue
+		}
 		localEmails := make([]string, 0, len(filtered))
 		for i := range filtered {
 			if filtered[i].Email != "" {
@@ -1337,6 +1364,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	}
 	committed = true
 
+	if len(degradedInbounds) > 0 {
+		if mgr := runtime.GetManager(); mgr != nil {
+			if rt, rtErr := mgr.RuntimeFor(&nodeID); rtErr == nil {
+				if rem, ok := rt.(*runtime.Remote); ok {
+					for _, tag := range degradedInbounds {
+						rem.ForgetPushedInbound(tag)
+					}
+				}
+			}
+		}
+		if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil {
+			logger.Warningf("setRemoteTraffic: mark node %d dirty after an empty snapshot failed: %v", nodeID, err)
+		}
+	}
+
 	if lifecycleLifted && !dirty {
 		var already model.Node
 		if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty {

+ 205 - 0
internal/web/service/node_degraded_snapshot_test.go

@@ -0,0 +1,205 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+)
+
+// linkCount returns how many client_inbounds links a client currently has,
+// across every inbound — the value ReapSyncOrphans checks before deleting.
+func linkCount(t *testing.T, db *gorm.DB, email string) int64 {
+	t.Helper()
+	var n int64
+	if err := db.Table("client_inbounds").
+		Joins("JOIN clients ON clients.id = client_inbounds.client_id").
+		Where("clients.email = ?", email).
+		Count(&n).Error; err != nil {
+		t.Fatalf("count links for %q: %v", email, err)
+	}
+	return n
+}
+
+// A degraded node reporting zero clients for an inbound the hub populates must
+// keep its links and never orphan-mark, or SyncInbound/ReapSyncOrphans delete the row.
+func TestSetRemoteTraffic_EmptySnapshotKeepsClients(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
+
+	settings := `{"clients":[{"email":"svc@x","enable":true}]}`
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", settings,
+		xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
+		t.Fatalf("seed sync: %v", err)
+	}
+	if n := linkCount(t, db, "svc@x"); n != 1 {
+		t.Fatalf("setup: svc@x links=%d, want 1", n)
+	}
+
+	// The node returns an empty snapshot — the trigger that deleted real clients.
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
+		t.Fatalf("empty-snapshot sync: %v", err)
+	}
+
+	if rec, _ := countClientRows(t, db, "svc@x"); rec != 1 {
+		t.Fatalf("empty snapshot deleted the client row: clients=%d, want 1", rec)
+	}
+	if n := linkCount(t, db, "svc@x"); n != 1 {
+		t.Fatalf("empty snapshot stripped the client link: links=%d, want 1", n)
+	}
+	if at := readOrphanMark(t, db, "svc@x"); at != 0 {
+		t.Fatalf("empty snapshot orphan-marked a live client: sync_orphaned_at=%d, want 0", at)
+	}
+	// The hub must keep the client in the inbound's settings, or reconcile re-pushes
+	// an empty blob to the node and the clients never come back (#6734).
+	var ib model.Inbound
+	if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil {
+		t.Fatalf("read central inbound: %v", err)
+	}
+	if !strings.Contains(ib.Settings, "svc@x") {
+		t.Fatalf("empty snapshot blanked the inbound settings: %q", ib.Settings)
+	}
+}
+
+// The guard is narrow: a snapshot still carrying a client is authoritative, so a
+// client the node really dropped is unlinked and orphan-marked; only all-empty is degraded.
+func TestSetRemoteTraffic_PartialSnapshotStillPrunes(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "keep@x")
+
+	bothSettings := `{"clients":[{"email":"keep@x","enable":true},{"email":"drop@x","enable":true}]}`
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", bothSettings,
+		xray.ClientTraffic{Email: "keep@x", Enable: true},
+		xray.ClientTraffic{Email: "drop@x", Enable: true}), false, false); err != nil {
+		t.Fatalf("seed sync: %v", err)
+	}
+	if n := linkCount(t, db, "drop@x"); n != 1 {
+		t.Fatalf("setup: drop@x links=%d, want 1", n)
+	}
+
+	// Node now reports only keep@x — drop@x was genuinely removed there.
+	keepOnlySettings := `{"clients":[{"email":"keep@x","enable":true}]}`
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithClients(t, "n1-in", keepOnlySettings,
+		xray.ClientTraffic{Email: "keep@x", Enable: true}), false, false); err != nil {
+		t.Fatalf("partial-snapshot sync: %v", err)
+	}
+
+	if n := linkCount(t, db, "keep@x"); n != 1 {
+		t.Fatalf("partial snapshot dropped a reported client: keep@x links=%d, want 1", n)
+	}
+	if n := linkCount(t, db, "drop@x"); n != 0 {
+		t.Fatalf("partial snapshot kept an unreported client linked: drop@x links=%d, want 0", n)
+	}
+	if at := readOrphanMark(t, db, "drop@x"); at <= 0 {
+		t.Fatalf("partial snapshot did not orphan-mark the removed client: sync_orphaned_at=%d, want >0", at)
+	}
+}
+
+// Keeping the hub's settings is not recovery: the node is only healed once the
+// hub actually re-pushes them, which needs a dirty node and a stale fingerprint.
+func TestSetRemoteTraffic_EmptySnapshotRepushesHubClients(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	var mu sync.Mutex
+	var pushed []string
+	writeOK := func(w http.ResponseWriter, obj any) {
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj})
+	}
+	mux := http.NewServeMux()
+	mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) {
+		writeOK(w, []map[string]any{{"id": 7, "tag": "deg-in", "port": 41001, "protocol": "vless"}})
+	})
+	mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) {
+		if err := r.ParseForm(); err != nil {
+			http.Error(w, err.Error(), http.StatusBadRequest)
+			return
+		}
+		mu.Lock()
+		pushed = append(pushed, r.PostForm.Get("settings"))
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+
+	node := reconcileTestNode(t, ts, "deg-node", "all", nil)
+	settings := `{"clients":[{"email":"svc@x","enable":true,"id":"11111111-1111-1111-1111-111111111111"}]}`
+	nid := node.Id
+	if err := db.Create(&model.Inbound{UserId: 1, Tag: "deg-in", Enable: true, Port: 41001, Protocol: model.VLESS, NodeID: &nid, Settings: settings}).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	rt := runtime.NewRemote(node, nil)
+	mgr := runtime.NewManager(runtime.LocalDeps{})
+	mgr.SetRuntimeOverride(node.Id, rt)
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithClients(t, "deg-in", settings,
+		xray.ClientTraffic{Email: "svc@x", Enable: true}), false, false); err != nil {
+		t.Fatalf("seed sync: %v", err)
+	}
+	if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
+		t.Fatalf("first reconcile: %v", err)
+	}
+	mu.Lock()
+	pushed = nil
+	mu.Unlock()
+
+	if _, err := svc.setRemoteTrafficLocked(node.Id, snapshotWithoutClients(t, "deg-in"), false, false); err != nil {
+		t.Fatalf("empty-snapshot sync: %v", err)
+	}
+	var after model.Node
+	if err := db.Where("id = ?", node.Id).First(&after).Error; err != nil {
+		t.Fatalf("reload node: %v", err)
+	}
+	if !after.ConfigDirty {
+		t.Fatal("empty snapshot left the node clean: the job never reconciles it, so the node stays without its clients")
+	}
+	if err := svc.ReconcileNode(context.Background(), rt, &after); err != nil {
+		t.Fatalf("reconcile after empty snapshot: %v", err)
+	}
+	mu.Lock()
+	defer mu.Unlock()
+	if len(pushed) != 1 || !strings.Contains(pushed[0], "svc@x") {
+		t.Fatalf("reconcile after empty snapshot pushed %d settings payload(s) %q, want one carrying svc@x", len(pushed), pushed)
+	}
+}
+
+// The traffic a client used while its node reported nothing must still count
+// once the node reports it again.
+func TestSetRemoteTraffic_EmptySnapshotKeepsTrafficBaseline(t *testing.T) {
+	db := initTrafficTestDB(t)
+	svc := &InboundService{}
+
+	seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
+	createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "svc@x")
+	settings := `{"clients":[{"email":"svc@x","enable":true}]}`
+	for _, used := range []int64{100, 200} {
+		syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: used, Down: used, Enable: true})
+	}
+	before := readTraffic(t, db, "svc@x")
+
+	if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
+		t.Fatalf("empty-snapshot sync: %v", err)
+	}
+	syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: "svc@x", Up: 250, Down: 250, Enable: true})
+
+	assertUpDown(t, readTraffic(t, db, "svc@x"), before.Up+50, before.Down+50, "after the node recovered")
+}

+ 3 - 1
internal/web/websocket/hub.go

@@ -94,10 +94,12 @@ func NewHub() *Hub {
 	}
 }
 
+// Traffic messages carry independent partial updates from Xray, TUIC, and node
+// polling jobs. Throttling by message type would silently discard one source
+// when two jobs publish within the throttle window.
 var throttledMessageTypes = map[MessageType]struct{}{
 	MessageTypeInbounds:    {},
 	MessageTypeOutbounds:   {},
-	MessageTypeTraffic:     {},
 	MessageTypeClientStats: {},
 }
 

+ 43 - 14
internal/web/websocket/hub_test.go

@@ -81,21 +81,45 @@ func TestHub_BroadcastDeliversToClient(t *testing.T) {
 	waitClientCount(t, h, 1)
 
 	h.Broadcast(MessageTypeStatus, map[string]string{"k": "v"})
-
 	select {
 	case raw := <-c.Send:
-		var m Message
-		if err := json.Unmarshal(raw, &m); err != nil {
-			t.Fatalf("payload is not valid JSON: %v\n%s", err, raw)
+		var message Message
+		if err := json.Unmarshal(raw, &message); err != nil {
+			t.Fatalf("payload is not valid JSON: %v", err)
 		}
-		if m.Type != MessageTypeStatus {
-			t.Fatalf("Type = %q, want %q", m.Type, MessageTypeStatus)
+		if message.Type != MessageTypeStatus {
+			t.Fatalf("message type = %q, want %q", message.Type, MessageTypeStatus)
 		}
-		if m.Time == 0 {
+		if message.Time == 0 {
 			t.Fatal("Time should be set to a non-zero unix-millis value")
 		}
 	case <-time.After(500 * time.Millisecond):
-		t.Fatal("timed out waiting for broadcast to reach client")
+		t.Fatal("timed out waiting for status broadcast to reach client")
+	}
+
+	for _, source := range []string{"tuic", "xray"} {
+		h.Broadcast(MessageTypeTraffic, map[string]string{"source": source})
+	}
+
+	for _, wantSource := range []string{"tuic", "xray"} {
+		select {
+		case raw := <-c.Send:
+			var message struct {
+				Type    MessageType       `json:"type"`
+				Payload map[string]string `json:"payload"`
+			}
+			if err := json.Unmarshal(raw, &message); err != nil {
+				t.Fatalf("traffic event is not valid JSON: %v", err)
+			}
+			if message.Type != MessageTypeTraffic {
+				t.Fatalf("message type = %q, want %q", message.Type, MessageTypeTraffic)
+			}
+			if got := message.Payload["source"]; got != wantSource {
+				t.Fatalf("traffic source = %q, want %q", got, wantSource)
+			}
+		case <-time.After(500 * time.Millisecond):
+			t.Fatalf("timed out waiting for %q traffic event", wantSource)
+		}
 	}
 }
 
@@ -156,23 +180,28 @@ func TestHub_ShouldThrottle(t *testing.T) {
 		t.Fatal("non-gated message type should never throttle on second call")
 	}
 
-	if h.shouldThrottle(MessageTypeTraffic) {
+	if h.shouldThrottle(MessageTypeInbounds) {
 		t.Fatal("first call for gated type should not throttle")
 	}
-	if !h.shouldThrottle(MessageTypeTraffic) {
+	if !h.shouldThrottle(MessageTypeInbounds) {
 		t.Fatal("immediate second call for gated type should throttle")
 	}
+	for i := range 2 {
+		if h.shouldThrottle(MessageTypeTraffic) {
+			t.Fatalf("traffic event %d must not be throttled", i+1)
+		}
+	}
 }
 
 func TestHub_ShouldThrottle_DistinctTypesIndependent(t *testing.T) {
 	h := NewHub()
 	defer h.Stop()
 
-	if h.shouldThrottle(MessageTypeTraffic) {
-		t.Fatal("first Traffic call should not throttle")
-	}
 	if h.shouldThrottle(MessageTypeInbounds) {
-		t.Fatal("first Inbounds call should not throttle even after Traffic")
+		t.Fatal("first Inbounds call should not throttle")
+	}
+	if h.shouldThrottle(MessageTypeOutbounds) {
+		t.Fatal("first Outbounds call should not throttle even after Inbounds")
 	}
 }
 

+ 185 - 0
update.sh

@@ -940,6 +940,180 @@ setup_fail2ban() {
     return 0
 }
 
+# The hardened unit makes /usr, /boot, /efi and /etc read-only. The panel's own
+# updater is expected to escape that sandbox by running this script through a
+# transient systemd-run unit; when systemd-run is unavailable it starts this
+# script as a plain child instead, and that child inherits the sandbox and then
+# cannot write anything this update needs. Say so once, up front, instead of
+# dying partway through with "Failed to download x-ui".
+require_writable_update_paths() {
+    local dir probe
+    for dir in "${xui_folder%/*}" "/usr/bin"; do
+        [[ -n "$dir" && -d "$dir" ]] || continue
+        probe="${dir}/.x-ui-write-test.$$"
+        # A real write test rather than [[ -w ]]: this runs as root, where a
+        # permission bit means little and the test only reflects the file mode
+        # and the mount flags, not an immutable attribute or a full filesystem.
+        if ! : > "$probe" 2> /dev/null; then
+            _fail "ERROR: ${dir} is not writable for this process (read-only mount, attribute or full filesystem). The panel's fallback updater cannot run inside the hardened systemd sandbox; update from the panel UI (which uses systemd-run) or run 'x-ui update' in a shell."
+        fi
+        rm -f "$probe"
+    done
+}
+
+# Major version of the local systemd, 0 when it cannot be determined. The
+# SystemCallFilter=@system-service group only exists from systemd 239 on (other
+# @-named groups exist since 231); on older versions an unknown group is not
+# ignored safely, the filter stays in force and leaves a whitelist the panel
+# cannot run under.
+_xui_systemd_major_version() {
+    local version=""
+    if command -v systemctl > /dev/null 2>&1; then
+        version="$(systemctl --version 2>/dev/null | awk 'NR == 1 {print $2}')"
+    fi
+    if [[ ! "$version" =~ ^[0-9]+$ ]]; then
+        echo 0
+        return 0
+    fi
+    echo "$version"
+}
+
+# The shipped units list hardening that older systemd does not know: the
+# directive is logged and ignored at load time rather than rejected, so the
+# panel still starts, only without that protection. Each entry is the systemd
+# release that introduced the directive (systemd.exec(5)); everything else in
+# the unit predates the oldest systemd install.sh supports (CentOS 7 has 219).
+# SystemCallFilter= is listed because the drop-in only writes it from 239 on.
+_xui_warn_unsupported_hardening() {
+    local version entry missing=""
+    version="$(_xui_systemd_major_version)"
+    [[ "$version" -gt 0 ]] || return 0
+    for entry in RestrictRealtime:231 ReadWritePaths:231 ProtectKernelTunables:232 \
+        ProtectKernelModules:232 RestrictNamespaces:233 LockPersonality:235 \
+        SystemCallFilter:239 ProtectHostname:242 RestrictSUIDSGID:242 \
+        ProtectKernelLogs:244 ProtectClock:245; do
+        if [[ "$version" -lt "${entry##*:}" ]]; then
+            missing="${missing:+$missing, }${entry%%:*} (${entry##*:})"
+        fi
+    done
+    [[ -n "$missing" ]] || return 0
+    echo -e "${yellow}Note: systemd ${version} ignores part of the hardening in x-ui.service; the panel still starts.${plain}"
+    echo "      Not applied, needs a newer systemd: ${missing}."
+    if [[ "$version" -lt 231 ]]; then
+        echo "      The panel's folders stay writable through ReadWriteDirectories=, the alias this script installs."
+    fi
+    echo "      The rest of the hardening is in force. Upgrade systemd to apply the above."
+    return 0
+}
+
+# ProtectSystem=full makes /usr, /boot, /efi and /etc read-only. ProtectSystem=
+# strict would make the whole hierarchy read-only (only the kernel API
+# filesystems stay as they are), and that would break the panel's own use of
+# /tmp. The panel's stores are configurable (XUI_DB_FOLDER, XUI_LOG_FOLDER,
+# XUI_BIN_FOLDER), and XUI_MAIN_FOLDER is the folder install.sh/update.sh place
+# the files in -- the unit's WorkingDirectory on a stock install, and what a
+# relative XUI_BIN_FOLDER is resolved against. So a hard-coded list in the unit
+# either misses a relocated store -- the panel then cannot write its own SQLite
+# database and sits in a Restart=on-failure loop -- or forces the operator to
+# edit a file that every install/update overwrites from the release tarball.
+# install.sh and update.sh therefore regenerate the drop-in from the folders
+# actually in use, and the unit's own ReadWritePaths only carry the
+# plain-install defaults. A relocated store means re-running install or update:
+# the drop-in is only written here.
+_xui_service_write_paths_dropin() {
+    # $1 is the env file to resolve the XUI_* folders from; callers pass nothing
+    # and get the OS-specific path the unit itself uses.
+    local env_file="${1:-}"
+    local dropin_dir dropin temp_file
+    local db_folder log_folder bin_folder main_folder
+    local path line="" whitespace_paths="" seen_paths="" escaped_path
+
+    if [[ -z "$env_file" ]]; then
+        env_file="$(xui_env_file_path)"
+    fi
+    if [[ -r "$env_file" ]]; then
+        set -a
+        # shellcheck disable=SC1090
+        source "$env_file"
+        set +a
+    fi
+
+    # XUI_* wins over the script's own default: the unit hands that same env
+    # file to the panel through EnvironmentFile=, so these are the folders it
+    # will actually use.
+    main_folder="${XUI_MAIN_FOLDER:-${xui_folder}}"
+    db_folder="${XUI_DB_FOLDER:-/etc/x-ui}"
+    log_folder="${XUI_LOG_FOLDER:-/var/log/x-ui}"
+    # An empty XUI_BIN_FOLDER resolves to "bin" relative to the panel's working
+    # directory, which the unit sets to the main folder.
+    bin_folder="${XUI_BIN_FOLDER:-bin}"
+    if [[ "$bin_folder" != /* ]]; then
+        bin_folder="${main_folder%/}/${bin_folder#./}"
+    fi
+
+    for path in "$db_folder" "$log_folder" "$bin_folder" "$main_folder"; do
+        [[ "$path" == /* ]] || continue
+        # ReadWritePaths= is a whitespace-separated list, and a folder whose
+        # name contains whitespace cannot be written into it without relying on
+        # quoting. A wrong entry makes systemd reject the whole drop-in and the
+        # panel would not start, so leave such a folder out and say so instead.
+        if [[ "$path" != "${path//[[:space:]]/}" ]]; then
+            whitespace_paths="${whitespace_paths:+$whitespace_paths }$path"
+            continue
+        fi
+        case " $seen_paths " in
+            *" $path "*) continue ;;
+        esac
+        seen_paths="${seen_paths}${seen_paths:+ }$path"
+        # systemd expands %-specifiers in unit files, so a folder name carrying
+        # a literal % has to be written as %%, or the entry stops naming the
+        # folder systemd is meant to keep writable.
+        escaped_path="${path//%/%%}"
+        line="${line} -${escaped_path}"
+    done
+    if [[ -n "$whitespace_paths" ]]; then
+        echo "Warning: these folders contain whitespace and were left out of" >&2
+        echo "         10-xui-sandbox.conf: $whitespace_paths" >&2
+        echo "         The panel cannot write to them under the unit's sandbox." >&2
+    fi
+    line="${line# }"
+    [[ -n "$line" ]] || return 1
+
+    dropin_dir="${xui_service}/x-ui.service.d"
+    dropin="${dropin_dir}/10-xui-sandbox.conf"
+    temp_file="${dropin}.tmp.$$"
+
+    mkdir -p "$dropin_dir" || return 1
+    cat > "$temp_file" << EOF
+# Regenerated by install.sh/update.sh on every install and update: edits here
+# are lost, and the list only reflects the XUI_* variables read from
+# ${env_file} at that moment. Re-run install/update after moving a store.
+# It lists the folders the panel writes to. Put local additions in their own
+# drop-in, for example 20-x-ui-local.conf, which nothing here touches.
+[Service]
+ReadWritePaths=${line}
+ReadWriteDirectories=${line}
+EOF
+    if [[ "$(_xui_systemd_major_version)" -ge 239 ]]; then
+        cat >> "$temp_file" << 'EOF'
+# @system-service needs systemd >= 239; on older versions the unknown group
+# would leave the panel with a filter it cannot start under (x-ui.service.*).
+SystemCallFilter=@system-service
+SystemCallErrorNumber=EPERM
+EOF
+    fi
+    if [[ ! -s "$temp_file" ]]; then
+        rm -f "$temp_file"
+        return 1
+    fi
+    chmod 644 "$temp_file"
+    mv -f "$temp_file" "$dropin" || { rm -f "$temp_file"; return 1; }
+    if command -v systemctl > /dev/null 2>&1; then
+        systemctl daemon-reload > /dev/null 2>&1 || true
+    fi
+    return 0
+}
+
 # Lands a systemd unit file at ${xui_service}/x-ui.service via a temp file +
 # atomic mv, so a failed cp/curl or an interrupted mv never leaves a
 # truncated unit file at the live path -- systemd would then fail to parse
@@ -971,6 +1145,11 @@ _install_xui_service_unit() {
         rm -f "$temp_file"
         return 1
     fi
+    if ! _xui_service_write_paths_dropin; then
+        echo -e "${yellow}Warning: could not refresh ${xui_service}/x-ui.service.d/10-xui-sandbox.conf.${plain}"
+        echo -e "${yellow}If XUI_DB_FOLDER or XUI_LOG_FOLDER points outside /etc/x-ui and /var/log/x-ui, the panel may not be able to write to it under ProtectSystem=full.${plain}"
+    fi
+    _xui_warn_unsupported_hardening
     return 0
 }
 
@@ -1171,6 +1350,11 @@ update_x-ui() {
         chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
     fi
 
+    # Finish the schema/data migrations before the service starts, so the service and
+    # config_after_update's CLI calls never run them on the same database at once (#6728).
+    echo -e "${green}Migrating database...${plain}"
+    "${xui_folder}/x-ui" migrate
+
     if [[ $release == "alpine" ]]; then
         echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
         xui_rc_temp="/etc/init.d/x-ui.tmp.$$"
@@ -1287,5 +1471,6 @@ update_x-ui() {
 }
 
 echo -e "${green}Running...${plain}"
+require_writable_update_paths
 install_base
 update_x-ui $1

+ 76 - 0
x-ui.service.arch

@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s
 
+# The panel intentionally stays root: it supervises the Xray child processes,
+# edits netfilter state and reads TLS private keys. These settings only bound
+# what a panel-level flaw can reach.
+#
+# PrivateTmp=yes is deliberately absent: the web updater writes its script into
+# /tmp and hands the absolute path to a "systemd-run" transient unit, which
+# does not share this service's private /tmp (the download would vanish).
+NoNewPrivileges=yes
+ProtectSystem=full
+# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
+# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
+# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
+# floor, not the whole list: install.sh and update.sh regenerate a drop-in
+# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
+# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
+# the list survives an update instead of being reset to these defaults. Changing
+# one of those variables in the env file is not enough by itself: the drop-in has
+# to be refreshed as well, i.e. install or update the panel again.
+# Add local extras in your own drop-in (e.g. 20-local.conf).
+# The leading '-' keeps the unit startable if a path does not exist yet.
+# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
+# read-only, everything else stays writable. So this list matters for stores
+# under those trees -- the default main folder under /usr/local is one.
+#
+# The in-panel updater is expected to leave this sandbox: it runs update.sh
+# through a transient systemd-run unit, which does not inherit these settings.
+# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
+# here -- update.sh stages the release archive beside the main folder, replaces
+# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
+# message instead of failing halfway, and the sandbox deliberately does not
+# grant /usr or /etc to accommodate it.
+# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
+# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
+ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+# read-only rather than yes: installs keep TLS certs under /root/cert, and the
+# panel must still be able to read them.
+ProtectHome=read-only
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictNamespaces=yes
+UMask=0077
+# AF_NETLINK for interface/route lookups and the ip(8) child used by the
+# AmneziaWG IPv6-alias feature.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
+# for raw sockets and SO_BINDTODEVICE.
+#
+# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
+# subtracted from root's own privileges too: without it root can only read a
+# file when the owner/group/other bits let uid 0 through, and any TLS private
+# key belonging to another account becomes unreadable -- a certificate issued to
+# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
+# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
+# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
+# reads worked before the sandbox because the panel is root.
+# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
+# DAC_OVERRIDE, so it would only widen the set without adding anything.
+CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
+SystemCallArchitectures=native
+# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
+# (other @-named groups exist since 231), and older systemd does not ignore an
+# unknown group name gracefully: on
+# <231 the name fails to resolve and the filter stays the built-in whitelist of
+# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
+# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
+# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
+# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
+# "systemctl --version" reports 239 or newer.
+
 [Install]
 WantedBy=multi-user.target

+ 76 - 0
x-ui.service.debian

@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s
 
+# The panel intentionally stays root: it supervises the Xray child processes,
+# edits netfilter state and reads TLS private keys. These settings only bound
+# what a panel-level flaw can reach.
+#
+# PrivateTmp=yes is deliberately absent: the web updater writes its script into
+# /tmp and hands the absolute path to a "systemd-run" transient unit, which
+# does not share this service's private /tmp (the download would vanish).
+NoNewPrivileges=yes
+ProtectSystem=full
+# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
+# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
+# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
+# floor, not the whole list: install.sh and update.sh regenerate a drop-in
+# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
+# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
+# the list survives an update instead of being reset to these defaults. Changing
+# one of those variables in the env file is not enough by itself: the drop-in has
+# to be refreshed as well, i.e. install or update the panel again.
+# Add local extras in your own drop-in (e.g. 20-local.conf).
+# The leading '-' keeps the unit startable if a path does not exist yet.
+# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
+# read-only, everything else stays writable. So this list matters for stores
+# under those trees -- the default main folder under /usr/local is one.
+#
+# The in-panel updater is expected to leave this sandbox: it runs update.sh
+# through a transient systemd-run unit, which does not inherit these settings.
+# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
+# here -- update.sh stages the release archive beside the main folder, replaces
+# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
+# message instead of failing halfway, and the sandbox deliberately does not
+# grant /usr or /etc to accommodate it.
+# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
+# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
+ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+# read-only rather than yes: installs keep TLS certs under /root/cert, and the
+# panel must still be able to read them.
+ProtectHome=read-only
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictNamespaces=yes
+UMask=0077
+# AF_NETLINK for interface/route lookups and the ip(8) child used by the
+# AmneziaWG IPv6-alias feature.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
+# for raw sockets and SO_BINDTODEVICE.
+#
+# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
+# subtracted from root's own privileges too: without it root can only read a
+# file when the owner/group/other bits let uid 0 through, and any TLS private
+# key belonging to another account becomes unreadable -- a certificate issued to
+# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
+# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
+# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
+# reads worked before the sandbox because the panel is root.
+# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
+# DAC_OVERRIDE, so it would only widen the set without adding anything.
+CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
+SystemCallArchitectures=native
+# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
+# (other @-named groups exist since 231), and older systemd does not ignore an
+# unknown group name gracefully: on
+# <231 the name fails to resolve and the filter stays the built-in whitelist of
+# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
+# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
+# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
+# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
+# "systemctl --version" reports 239 or newer.
+
 [Install]
 WantedBy=multi-user.target

+ 76 - 0
x-ui.service.rhel

@@ -15,5 +15,81 @@ ExecReload=/bin/kill -USR1 $MAINPID
 Restart=on-failure
 RestartSec=5s
 
+# The panel intentionally stays root: it supervises the Xray child processes,
+# edits netfilter state and reads TLS private keys. These settings only bound
+# what a panel-level flaw can reach.
+#
+# PrivateTmp=yes is deliberately absent: the web updater writes its script into
+# /tmp and hands the absolute path to a "systemd-run" transient unit, which
+# does not share this service's private /tmp (the download would vanish).
+NoNewPrivileges=yes
+ProtectSystem=full
+# Default stores: DB/update status/node-token in XUI_DB_FOLDER (/etc/x-ui),
+# xray binary/config/geo in XUI_BIN_FOLDER under /usr/local/x-ui, logs in
+# XUI_LOG_FOLDER (/var/log/x-ui). These are the plain-install defaults and the
+# floor, not the whole list: install.sh and update.sh regenerate a drop-in
+# (x-ui.service.d/10-xui-sandbox.conf) with the folders resolved from the XUI_*
+# variables, so a relocated XUI_DB_FOLDER or XUI_LOG_FOLDER stays writable and
+# the list survives an update instead of being reset to these defaults. Changing
+# one of those variables in the env file is not enough by itself: the drop-in has
+# to be refreshed as well, i.e. install or update the panel again.
+# Add local extras in your own drop-in (e.g. 20-local.conf).
+# The leading '-' keeps the unit startable if a path does not exist yet.
+# What ProtectSystem=full covers, precisely: /usr, /boot, /efi and /etc become
+# read-only, everything else stays writable. So this list matters for stores
+# under those trees -- the default main folder under /usr/local is one.
+#
+# The in-panel updater is expected to leave this sandbox: it runs update.sh
+# through a transient systemd-run unit, which does not inherit these settings.
+# Its plain-child fallback (taken when systemd-run is unavailable) cannot work
+# here -- update.sh stages the release archive beside the main folder, replaces
+# /usr/bin/x-ui and calls the package manager -- so it stops with one clear
+# message instead of failing halfway, and the sandbox deliberately does not
+# grant /usr or /etc to accommodate it.
+# ReadWritePaths= requires systemd >= 231; ReadWriteDirectories= is the deprecated
+# alias for older systemd, needed so ProtectSystem=full keeps /etc/x-ui writable.
+ReadWritePaths=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ReadWriteDirectories=-/etc/x-ui -/usr/local/x-ui -/var/log/x-ui
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+ProtectClock=yes
+ProtectHostname=yes
+# read-only rather than yes: installs keep TLS certs under /root/cert, and the
+# panel must still be able to read them.
+ProtectHome=read-only
+LockPersonality=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+RestrictNamespaces=yes
+UMask=0077
+# AF_NETLINK for interface/route lookups and the ip(8) child used by the
+# AmneziaWG IPv6-alias feature.
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
+# NET_ADMIN for ip(8)/tproxy, NET_BIND_SERVICE for ports below 1024, NET_RAW
+# for raw sockets and SO_BINDTODEVICE.
+#
+# DAC_OVERRIDE has to stay. The panel runs as root, but a bounding set is
+# subtracted from root's own privileges too: without it root can only read a
+# file when the owner/group/other bits let uid 0 through, and any TLS private
+# key belonging to another account becomes unreadable -- a certificate issued to
+# Caddy, an acme.sh home, any 0600 file that root does not own. TLS then fails
+# quietly: the panel listener catches the tls.LoadX509KeyPair error, logs it and
+# keeps serving plain HTTP, and every Xray inbound using that key stops. Those
+# reads worked before the sandbox because the panel is root.
+# DAC_READ_SEARCH is deliberately absent: directory search is already covered by
+# DAC_OVERRIDE, so it would only widen the set without adding anything.
+CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_NET_ADMIN CAP_NET_BIND_SERVICE CAP_NET_RAW
+SystemCallArchitectures=native
+# No seccomp whitelist here on purpose. @system-service needs systemd >= 239
+# (other @-named groups exist since 231), and older systemd does not ignore an
+# unknown group name gracefully: on
+# <231 the name fails to resolve and the filter stays the built-in whitelist of
+# execve/exit/exit_group/rt_sigreturn/sigreturn, on 231..238 it degrades to
+# @default -- either way the panel then gets EPERM on read/openat/mmap/clone and
+# cannot start. install.sh and update.sh add SystemCallFilter=@system-service and
+# SystemCallErrorNumber=EPERM to the generated drop-in, but only when
+# "systemctl --version" reports 239 or newer.
+
 [Install]
 WantedBy=multi-user.target

+ 6 - 0
x-ui.sh

@@ -294,6 +294,12 @@ uninstall() {
         systemctl stop x-ui
         systemctl disable x-ui
         rm ${xui_service}/x-ui.service -f
+        # The sandbox drop-in generated by install.sh/update.sh lives beside the
+        # unit; leaving it behind would keep an empty x-ui.service.d around and
+        # silently re-apply on a later install of another unit of the same name.
+        # Local drop-ins the operator added go with it, which is what an
+        # uninstall is expected to do.
+        rm -rf -- "${xui_service}/x-ui.service.d"
         systemctl daemon-reload
         systemctl reset-failed
     fi