Bläddra i källkod

fix(xray): restart when a diff strands a client's live session (#6550)

* fix(xray): restart when a diff strands a client's live session

Disabling or deleting a client took it out of the generated config and the
hot path applied that with AlterInbound/RemoveUser, which only drops the
credential (vless, vmess, trojan and shadowsocks all keep the established
session running) -- so the panel showed a disabled client whose connection
kept passing traffic, and the core offers no API to close one session.

A diff that removes a user without re-adding the same email under the same
tag is that case: honour the operator's restart-on-client-disable setting and
let the caller replace the process, which is already how an auto-disabled
client loses its session. An edit re-adds the email and keeps the hot path.

* chore(i18n): cover manual disable and delete in the restart-setting description

The setting now also decides what happens when a client is disabled or deleted
by hand, so the description cannot keep naming only the automatic path. All 13
locales updated in the same commit to keep the wording consistent.

* fix(xray): reach the guard from the manual switch and from every protocol

Round-1 findings on this PR. The guard sat in tryHotApply, but a manual disable
or delete applies through runtime.Runtime and finishes with needRestart false,
so none of the three RestartXray schedulers fired and the predicate was never
reached: the session in #6533 kept flowing. The apply layer now asks for the
restart the setting promises when the client actually leaves the config, on the
single-client update and delete paths and on bulk disable, and only for local
inbounds so a node row cannot make the master restart its own core.

The predicate itself could not fire for shadowsocks or hysteria either, because
RemovedUsers is only produced for the protocols diffInboundUsers will diff. The
diff now also compares settings.clients of an inbound present in both configs,
which is the one shape every account list shares, so those protocols reach the
guard through the inbound instead of through nothing.

TestManualClientDisableHonoursRestartSetting fails without the apply-layer fix
("needRestart = false, want true" with the setting on) and
TestHotDiffDropsUsersOnProtocolsItCannotDiff fails without the diff fix -- both
watched red. The two three-line comments this PR added are back inside the cap.

* docs(i18n): stop scoping restartXrayOnClientDisable to auto-disable

The setting now covers a client disabled or deleted by hand as well, so its
title no longer says "Auto" in all 13 locales, and the docs callouts in en, ru,
zh and fa describe the same behaviour instead of the auto-only one.
BlindMaster24 1 dag sedan
förälder
incheckning
e790f46757

+ 2 - 2
docs/content/docs/en/config/clients.mdx

@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
 | **Comment**    | all                   | Free-text note.                                                   |
 
 <Callout type="info">
-  Reaching the **traffic** or **expiry** limit disables the client; the panel can
-  restart Xray automatically when clients are auto-disabled
+  Reaching the **traffic** or **expiry** limit disables the client, and a client
+  disabled or deleted by hand counts too; the panel restarts Xray then
   (`restartXrayOnClientDisable`, on by default).
 </Callout>
 

+ 2 - 2
docs/content/docs/fa/config/clients.mdx

@@ -27,8 +27,8 @@ icon: Users
 | **Comment**    | همه                   | یادداشت متنی آزاد.                                                 |
 
 <Callout type="info">
-  رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ پنل می‌تواند
-  هنگام غیرفعال‌شدن خودکار کلاینت‌ها، Xray را به‌صورت خودکار راه‌اندازی مجدد کند
+  رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال می‌کند؛ غیرفعال‌سازی یا
+  حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راه‌اندازی مجدد می‌کند
   (`restartXrayOnClientDisable`، به‌صورت پیش‌فرض فعال).
 </Callout>
 

+ 3 - 3
docs/content/docs/ru/config/clients.mdx

@@ -28,9 +28,9 @@ icon: Users
 | **Comment**    | все                   | Произвольная текстовая заметка.                                  |
 
 <Callout type="info">
-  Достижение лимита **трафика** или **срока действия** отключает клиента; при
-  автоматическом отключении клиентов панель может автоматически перезапускать
-  Xray (`restartXrayOnClientDisable`, включено по умолчанию).
+  Достижение лимита **трафика** или **срока действия** отключает клиента, как и
+  ручное отключение или удаление; тогда панель перезапускает Xray
+  (`restartXrayOnClientDisable`, включено по умолчанию).
 </Callout>
 
 ## Лимиты и контроль IP

+ 2 - 2
docs/content/docs/zh/config/clients.mdx

@@ -26,8 +26,8 @@ icon: Users
 | **Comment**    | 全部                  | 自由文本备注。                                                    |
 
 <Callout type="info">
-  达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
-  自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
+  达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
+  此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
 </Callout>
 
 ## 限制与 IP 控制

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

@@ -1812,6 +1812,9 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 					if err1 != nil && !strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", ch.email)) {
 						logger.Debug("Error in removing client on", rt.Name(), ":", err1)
 						res.needRestart = true
+					} else if err1 == nil && droppedClientNeedsRestart() {
+						// A removed credential does not end the session it was serving.
+						res.needRestart = true
 					}
 				}
 			}

+ 64 - 0
internal/web/service/client_disable_restart_test.go

@@ -0,0 +1,64 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// The manual switch applies through the runtime, and the core's API removal
+// drops the credential only: whether the live session ends is what the setting
+// asks for, exactly as on the auto-disable path #6533 reports from.
+func TestManualClientDisableHonoursRestartSetting(t *testing.T) {
+	const email = "[email protected]"
+
+	for _, tc := range []struct {
+		name    string
+		setting bool
+		want    bool
+	}{
+		{"setting on", true, true},
+		{"setting off", false, false},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			setRestartOnClientDisable(t, tc.setting)
+
+			mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+			mgr.SetLocalRuntimeOverride(&fakeNodeRuntime{})
+			runtime.SetManager(mgr)
+			t.Cleanup(func() { runtime.SetManager(nil) })
+
+			seedInboundConflict(t, "manual-disable", "0.0.0.0", 50055, model.VLESS, `{"network":"tcp"}`,
+				`{"clients":[{"email":"`+email+`","id":"5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a333","enable":true}]}`)
+			inbound := loadInboundByTag(t, "manual-disable")
+
+			inboundSvc := InboundService{}
+			clientSvc := ClientService{}
+			clients, err := inboundSvc.GetClients(inbound)
+			if err != nil {
+				t.Fatalf("GetClients: %v", err)
+			}
+			if err := clientSvc.SyncInbound(nil, inbound.Id, clients); err != nil {
+				t.Fatalf("SyncInbound: %v", err)
+			}
+			if err := database.GetDB().Create(&xray.ClientTraffic{InboundId: inbound.Id, Email: email, Enable: true}).Error; err != nil {
+				t.Fatalf("seed traffic: %v", err)
+			}
+
+			changed, needRestart, err := clientSvc.SetClientEnableByEmail(&inboundSvc, email, false)
+			if err != nil {
+				t.Fatalf("SetClientEnableByEmail: %v", err)
+			}
+			if !changed {
+				t.Fatal("the disable must be recorded")
+			}
+			if needRestart != tc.want {
+				t.Fatalf("needRestart = %v, want %v", needRestart, tc.want)
+			}
+		})
+	}
+}

+ 16 - 1
internal/web/service/client_inbound_apply.go

@@ -19,6 +19,16 @@ import (
 	"gorm.io/gorm"
 )
 
+// droppedClientNeedsRestart is what restartXrayOnClientDisable asks for: the core
+// API drops the credential only, so a live session needs the process replaced.
+func droppedClientNeedsRestart() bool {
+	on, err := (&SettingService{}).GetRestartXrayOnClientDisable()
+	if err != nil {
+		logger.Warning("get RestartXrayOnClientDisable failed:", err)
+	}
+	return on
+}
+
 func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
 	aa := maps.Clone(a)
 	bb := maps.Clone(b)
@@ -1011,6 +1021,11 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 					err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
 					if err1 == nil {
 						logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
+						// The API removal is enough only while the client is re-added; a
+						// dropped one ends its session only through a restart.
+						if !clients[0].Enable && droppedClientNeedsRestart() {
+							needRestart = true
+						}
 					} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
 						logger.Debug("User is already deleted. Nothing to do more...")
 					} else {
@@ -1199,7 +1214,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 					needRestart = true
 				} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
 					logger.Debug("Client deleted on", rt.Name(), ":", email)
-					needRestart = false
+					needRestart = droppedClientNeedsRestart()
 				} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
 					logger.Debug("User is already deleted. Nothing to do more...")
 				} else {

+ 20 - 0
internal/web/service/xray.go

@@ -1428,6 +1428,20 @@ func (s *XrayService) RestartXray(isForce bool) error {
 	return nil
 }
 
+// restartToDropClients reports whether a diff that strands clients must be
+// applied by restarting instead of through the API.
+func (s *XrayService) restartToDropClients(diff *xray.HotDiff) bool {
+	if diff == nil || !diff.DropsUsers() {
+		return false
+	}
+	restart, err := s.settingService.GetRestartXrayOnClientDisable()
+	if err != nil {
+		logger.Warning("get RestartXrayOnClientDisable failed:", err)
+		return false
+	}
+	return restart
+}
+
 // tryHotApply attempts to reconcile the running Xray instance with newCfg
 // through the core gRPC API (HandlerService for inbounds/outbounds,
 // RoutingService for rules/balancers). It returns true when the running
@@ -1445,6 +1459,12 @@ func (s *XrayService) tryHotApply(process *xray.Process, newCfg *xray.Config) bo
 		process.SetConfig(newCfg)
 		return true
 	}
+	// The core's RemoveUser drops the credential only, so a disabled or deleted
+	// client needs the restart this setting asks for.
+	if s.restartToDropClients(diff) {
+		logger.Info("hot apply: clients left the config, restarting to drop their live sessions")
+		return false
+	}
 
 	apiPort := process.GetAPIPort()
 	if apiPort <= 0 {

+ 73 - 0
internal/web/service/xray_drop_clients_test.go

@@ -0,0 +1,73 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func setRestartOnClientDisable(t *testing.T, value bool) {
+	t.Helper()
+	if err := (&SettingService{}).SetRestartXrayOnClientDisable(value); err != nil {
+		t.Fatalf("SetRestartXrayOnClientDisable(%v): %v", value, err)
+	}
+}
+
+// RemoveUser drops the credential only, so a dropped client needs the restart the
+// setting asks for; an edit re-adds the email and needs none.
+func TestRestartToDropClients(t *testing.T) {
+	cases := []struct {
+		name        string
+		diff        *xray.HotDiff
+		setSetting  *bool
+		wantRestart bool
+	}{
+		{
+			// The default is what makes this reachable for a plain install.
+			"disabled client, setting untouched",
+			&xray.HotDiff{RemovedUsers: []xray.UserOp{{Tag: "in-443", Email: "a@x"}}},
+			nil, true,
+		},
+		{
+			"disabled client, setting on",
+			&xray.HotDiff{RemovedUsers: []xray.UserOp{{Tag: "in-443", Email: "a@x"}}},
+			new(true), true,
+		},
+		{
+			"disabled client, setting off",
+			&xray.HotDiff{RemovedUsers: []xray.UserOp{{Tag: "in-443", Email: "a@x"}}},
+			new(false), false,
+		},
+		{
+			"edited client",
+			&xray.HotDiff{
+				RemovedUsers: []xray.UserOp{{Tag: "in-443", Email: "a@x"}},
+				AddedUsers:   []xray.UserOp{{Tag: "in-443", Email: "a@x"}},
+			},
+			new(true), false,
+		},
+		{
+			"unrelated change",
+			&xray.HotDiff{AddedInbounds: [][]byte{[]byte(`{}`)}},
+			new(true), false,
+		},
+		{
+			"nil diff",
+			nil,
+			new(true), false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupConflictDB(t)
+			if tc.setSetting != nil {
+				setRestartOnClientDisable(t, *tc.setSetting)
+			}
+			got := (&XrayService{}).restartToDropClients(tc.diff)
+			if got != tc.wantRestart {
+				t.Fatalf("restartToDropClients = %v, want %v", got, tc.wantRestart)
+			}
+		})
+	}
+}

+ 2 - 2
internal/web/translation/ar-EG.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "إخطار واجهة API خارجية بكل تحديث لحركة المرور.",
       "externalTrafficInformURI": "مسار تنبيه الترافيك الخارجي",
       "externalTrafficInformURIDesc": "تحديثات الترافيك هتتبعت للمسار ده.",
-      "restartXrayOnClientDisable": "إعادة تشغيل Xray بعد التعطيل التلقائي",
-      "restartXrayOnClientDisableDesc": "عند تعطيل العميل تلقائيا بسبب انتهاء الصلاحية أو حد حركة المرور، أعد تشغيل Xray.",
+      "restartXrayOnClientDisable": "إعادة تشغيل Xray بعد تعطيل العميل",
+      "restartXrayOnClientDisableDesc": "عندما يتوقف تقديم الخدمة للعميل — بتعطيل تلقائي بسبب انتهاء الصلاحية أو حد حركة المرور، أو بتعطيل أو حذف يدوي — أعد تشغيل Xray لإنهاء اتصالاته النشطة.",
       "fragment": "تجزئة",
       "fragmentDesc": "يفعل تجزئة لحزمة TLS hello.",
       "fragmentSett": "إعدادات التجزئة",

+ 2 - 2
internal/web/translation/en-US.json

@@ -1367,8 +1367,8 @@
       "externalTrafficInformEnableDesc": "Inform external API on every traffic update.",
       "externalTrafficInformURI": "External Traffic Inform URI",
       "externalTrafficInformURIDesc": "Traffic updates are sent to this URI.",
-      "restartXrayOnClientDisable": "Restart Xray After Auto Disable",
-      "restartXrayOnClientDisableDesc": "When a client is automatically disabled due to expiration or traffic limit, restart Xray.",
+      "restartXrayOnClientDisable": "Restart Xray After Client Disable",
+      "restartXrayOnClientDisableDesc": "When a client stops being served - auto-disabled by expiration or traffic limit, or disabled or deleted by hand - restart Xray so its live connections end.",
       "fragment": "Fragmentation",
       "fragmentDesc": "Enable fragmentation for TLS hello packet.",
       "fragmentSett": "Fragmentation Settings",

+ 2 - 2
internal/web/translation/es-ES.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Informar a una API externa en cada actualización de tráfico.",
       "externalTrafficInformURI": "URI de información de tráfico externo",
       "externalTrafficInformURIDesc": "Las actualizaciones de tráfico se envían a este URI.",
-      "restartXrayOnClientDisable": "Reiniciar Xray tras desactivación automática",
-      "restartXrayOnClientDisableDesc": "Cuando un cliente se desactive automáticamente por vencimiento o límite de tráfico, reiniciar Xray.",
+      "restartXrayOnClientDisable": "Reiniciar Xray al desactivar un cliente",
+      "restartXrayOnClientDisableDesc": "Cuando un cliente deja de atenderse — desactivado automáticamente por vencimiento o límite de tráfico, o desactivado o eliminado a mano — reiniciar Xray para que terminen sus conexiones activas.",
       "fragment": "Fragmentación",
       "fragmentDesc": "Habilitar la fragmentación para el paquete de saludo de TLS",
       "fragmentSett": "Configuración de Fragmentación",

+ 2 - 2
internal/web/translation/fa-IR.json

@@ -1249,8 +1249,8 @@
       "externalTrafficInformEnableDesc": "به API خارجی در هر به‌روزرسانی ترافیک اطلاع بده.",
       "externalTrafficInformURI": "لینک اطلاع رسانی خارجی مصرف ترافیک",
       "externalTrafficInformURIDesc": "ترافیک های مصرفی به این لینک هم ارسال می شود",
-      "restartXrayOnClientDisable": "ری‌استارت Xray بعد از غیرفعال‌سازی خودکار",
-      "restartXrayOnClientDisableDesc": "وقتی کاربر به‌صورت خودکار به‌دلیل اتمام زمان یا ترافیک غیرفعال می‌شود، Xray ری‌استارت شود.",
+      "restartXrayOnClientDisable": "ری‌استارت Xray پس از غیرفعال‌سازی کلاینت",
+      "restartXrayOnClientDisableDesc": "وقتی دیگر به کاربر سرویس داده نمی‌شود — غیرفعال شدن خودکار به‌خاطر اتمام زمان یا محدودیت ترافیک، یا غیرفعال/حذف دستی — Xray ری‌استارت شود تا اتصال‌های فعالش بسته شوند.",
       "fragment": "فرگمنت",
       "fragmentDesc": "فعال کردن فرگمنت برای بسته‌ی نخست تی‌ال‌اس",
       "fragmentSett": "تنظیمات فرگمنت",

+ 2 - 2
internal/web/translation/id-ID.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Beritahu API eksternal setiap kali ada pembaruan trafik.",
       "externalTrafficInformURI": "Lalu Lintas Eksternal Menginformasikan URI",
       "externalTrafficInformURIDesc": "Pembaruan lalu lintas dikirim ke URI ini.",
-      "restartXrayOnClientDisable": "Nyalakan Ulang Xray Setelah Nonaktif Otomatis",
-      "restartXrayOnClientDisableDesc": "Saat klien otomatis dinonaktifkan karena kedaluwarsa atau batas trafik, mulai ulang Xray.",
+      "restartXrayOnClientDisable": "Nyalakan Ulang Xray Setelah Klien Dinonaktifkan",
+      "restartXrayOnClientDisableDesc": "Saat klien berhenti dilayani — dinonaktifkan otomatis karena kedaluwarsa atau batas trafik, atau dinonaktifkan maupun dihapus secara manual — mulai ulang Xray agar koneksi aktifnya berakhir.",
       "fragment": "Fragmentasi",
       "fragmentDesc": "Aktifkan fragmentasi untuk paket hello TLS",
       "fragmentSett": "Pengaturan Fragmentasi",

+ 2 - 2
internal/web/translation/ja-JP.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "トラフィック更新ごとに外部 API に通知。",
       "externalTrafficInformURI": "外部トラフィック通知 URI",
       "externalTrafficInformURIDesc": "トラフィックの更新ごとに外部 API に通知します。",
-      "restartXrayOnClientDisable": "自動無効化後に Xray を再起動",
-      "restartXrayOnClientDisableDesc": "有効期限切れまたはトラフィック上限でクライアントが自動的に無効化されたとき、Xray を再起動します。",
+      "restartXrayOnClientDisable": "クライアント無効化後に Xray を再起動",
+      "restartXrayOnClientDisableDesc": "クライアントがサービス対象でなくなったとき(有効期限や通信量上限による自動無効化、または手動での無効化・削除)、接続を終わらせるため Xray を再起動します。",
       "fragment": "フラグメント",
       "fragmentDesc": "TLS helloパケットのフラグメントを有効にする",
       "fragmentSett": "設定",

+ 2 - 2
internal/web/translation/pt-BR.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Informar API externa a cada atualização de tráfego.",
       "externalTrafficInformURI": "URI de informação de tráfego externo",
       "externalTrafficInformURIDesc": "As atualizações de tráfego são enviadas para este URI.",
-      "restartXrayOnClientDisable": "Reiniciar Xray Após Desativação Automática",
-      "restartXrayOnClientDisableDesc": "Quando um cliente for desativado automaticamente por expiração ou limite de tráfego, reinicie o Xray.",
+      "restartXrayOnClientDisable": "Reiniciar Xray Após Desativar um Cliente",
+      "restartXrayOnClientDisableDesc": "Quando um cliente deixa de ser atendido — desativado automaticamente por expiração ou limite de tráfego, ou desativado ou excluído manualmente — reinicie o Xray para encerrar suas conexões ativas.",
       "fragment": "Fragmentação",
       "fragmentDesc": "Ativa a fragmentação para o pacote TLS hello.",
       "fragmentSett": "Configurações de Fragmentação",

+ 2 - 2
internal/web/translation/ru-RU.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Уведомлять внешний API при каждом обновлении трафика.",
       "externalTrafficInformURI": "URI информации о внешнем трафике",
       "externalTrafficInformURIDesc": "Обновления трафика отправляются на этот URI",
-      "restartXrayOnClientDisable": "Перезапускать Xray после автоотключения",
-      "restartXrayOnClientDisableDesc": "Когда клиент автоматически отключается из-за окончания срока действия или лимита трафика, перезапускать Xray.",
+      "restartXrayOnClientDisable": "Перезапускать Xray после отключения клиента",
+      "restartXrayOnClientDisableDesc": "Когда клиент перестаёт обслуживаться — автоматически (по сроку или лимиту трафика) или вручную — перезапускать Xray, чтобы его активные соединения оборвались.",
       "fragment": "Фрагментация",
       "fragmentDesc": "Включить фрагментацию TLS-хэндшейка",
       "fragmentSett": "Настройки фрагментации",

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

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Her trafik güncellemesinde harici API'yi bilgilendirir.",
       "externalTrafficInformURI": "Harici Trafik Bilgisi URI'si",
       "externalTrafficInformURIDesc": "Trafik güncellemeleri bu URI'ye gönderilir.",
-      "restartXrayOnClientDisable": "Otomatik Devre Dışı Sonrası Xray'i Yeniden Başlat",
-      "restartXrayOnClientDisableDesc": "Bir kullanıcı süre dolumu veya trafik limiti nedeniyle otomatik devre dışı bırakıldığında Xray'i yeniden başlatır.",
+      "restartXrayOnClientDisable": "İstemci Devre Dışı Bırakıldığında Xray'i Yeniden Başlat",
+      "restartXrayOnClientDisableDesc": "Bir istemci artık sunulmadığında — süre dolumu veya trafik limiti nedeniyle otomatik, ya da elle devre dışı bırakıldığında veya silindiğinde — açık bağlantıları bitsin diye Xray'i yeniden başlatır.",
       "fragment": "Parçalama",
       "fragmentDesc": "TLS merhaba paketinin parçalanmasını etkinleştirir.",
       "fragmentSett": "Parçalama Ayarları",

+ 2 - 2
internal/web/translation/uk-UA.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Повідомляти зовнішній API про кожне оновлення трафіку.",
       "externalTrafficInformURI": "Інформаційний URI зовнішнього трафіку",
       "externalTrafficInformURIDesc": "Оновлення трафіку надсилаються на цей URI.",
-      "restartXrayOnClientDisable": "Перезапускати Xray після авто-вимкнення",
-      "restartXrayOnClientDisableDesc": "Коли клієнт автоматично вимикається через закінчення терміну дії або ліміт трафіку, перезапускати Xray.",
+      "restartXrayOnClientDisable": "Перезапускати Xray після вимкнення клієнта",
+      "restartXrayOnClientDisableDesc": "Коли клієнт перестає обслуговуватися — автоматично (через термін дії чи ліміт трафіку) або вручну — перезапускати Xray, щоб його активні з'єднання обірвалися.",
       "fragment": "Фрагментація",
       "fragmentDesc": "Увімкнути фрагментацію для пакету привітання TLS",
       "fragmentSett": "Параметри фрагментації",

+ 2 - 2
internal/web/translation/vi-VN.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "Thông báo API ngoài mỗi khi cập nhật lưu lượng.",
       "externalTrafficInformURI": "URI thông báo lưu lượng truy cập bên ngoài",
       "externalTrafficInformURIDesc": "Cập nhật lưu lượng truy cập được gửi tới URI này.",
-      "restartXrayOnClientDisable": "Khởi Động Lại Xray Sau Khi Tự Động Vô Hiệu Hóa",
-      "restartXrayOnClientDisableDesc": "Khi người dùng bị vô hiệu hóa tự động do hết hạn hoặc chạm giới hạn lưu lượng, hãy khởi động lại Xray.",
+      "restartXrayOnClientDisable": "Khởi Động Lại Xray Sau Khi Vô Hiệu Hóa Máy Khách",
+      "restartXrayOnClientDisableDesc": "Khi một máy khách không còn được phục vụ — bị vô hiệu hóa tự động do hết hạn hoặc giới hạn lưu lượng, hoặc bị vô hiệu hóa hay xóa thủ công — hãy khởi động lại Xray để các kết nối đang mở của nó kết thúc.",
       "fragment": "Sự phân mảnh",
       "fragmentDesc": "Kích hoạt phân mảnh cho gói TLS hello",
       "fragmentSett": "Cài đặt phân mảnh",

+ 2 - 2
internal/web/translation/zh-CN.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "每次流量更新时通知外部 API。",
       "externalTrafficInformURI": "外部流量通知 URI",
       "externalTrafficInformURIDesc": "流量更新将发送到此 URI",
-      "restartXrayOnClientDisable": "客户端自动禁用后重启 Xray",
-      "restartXrayOnClientDisableDesc": "当客户端因到期或流量超限被自动禁用,重启 Xray。",
+      "restartXrayOnClientDisable": "客户端禁用后重启 Xray",
+      "restartXrayOnClientDisableDesc": "当客户端不再被服务时——因到期或流量超限被自动禁用,或被手动禁用、删除——重启 Xray 以断开其活动连接。",
       "fragment": "分片",
       "fragmentDesc": "启用 TLS hello 数据包分片",
       "fragmentSett": "设置",

+ 2 - 2
internal/web/translation/zh-TW.json

@@ -1245,8 +1245,8 @@
       "externalTrafficInformEnableDesc": "每次流量更新時通知外部 API。",
       "externalTrafficInformURI": "外部流量通知 URI",
       "externalTrafficInformURIDesc": "流量更新將會傳送到此 URI",
-      "restartXrayOnClientDisable": "用戶自動停用後重新啟動 Xray",
-      "restartXrayOnClientDisableDesc": "當用戶因到期或流量上限被自動停用,重新啟動 Xray。",
+      "restartXrayOnClientDisable": "用戶停用後重新啟動 Xray",
+      "restartXrayOnClientDisableDesc": "當用戶不再被服務時——因到期或流量上限被自動停用,或被手動停用、刪除——重新啟動 Xray 以中斷其作用中的連線。",
       "fragment": "分片",
       "fragmentDesc": "啟用 TLS hello 資料包分片",
       "fragmentSett": "設定",

+ 51 - 4
internal/xray/hot_diff.go

@@ -13,10 +13,13 @@ import (
 // process. It only covers the sections Xray can reload at runtime: inbounds,
 // outbounds and routing rules/balancers.
 type HotDiff struct {
-	RemovedInboundTags  []string
-	AddedInbounds       [][]byte
-	RemovedUsers        []UserOp
-	AddedUsers          []UserOp
+	RemovedInboundTags []string
+	AddedInbounds      [][]byte
+	RemovedUsers       []UserOp
+	AddedUsers         []UserOp
+	// DroppedClients are emails an inbound that survives the change stopped
+	// serving, including the protocols diffInboundUsers will not diff.
+	DroppedClients      []UserOp
 	RemovedOutboundTags []string
 	AddedOutbounds      [][]byte
 	RoutingConfig       []byte // full new routing section; nil when unchanged
@@ -30,6 +33,27 @@ type UserOp struct {
 	User     map[string]any
 }
 
+// DropsUsers reports users removed without being re-added under the same tag:
+// a disable or a delete, where an edit re-adds the email with new values.
+func (d *HotDiff) DropsUsers() bool {
+	if len(d.DroppedClients) > 0 {
+		return true
+	}
+	if len(d.RemovedUsers) == 0 {
+		return false
+	}
+	readded := make(map[string]struct{}, len(d.AddedUsers))
+	for _, u := range d.AddedUsers {
+		readded[u.Tag+"\x00"+u.Email] = struct{}{}
+	}
+	for _, u := range d.RemovedUsers {
+		if _, ok := readded[u.Tag+"\x00"+u.Email]; !ok {
+			return true
+		}
+	}
+	return false
+}
+
 // Empty reports whether the diff contains no operations.
 func (d *HotDiff) Empty() bool {
 	return len(d.RemovedInboundTags) == 0 &&
@@ -125,6 +149,9 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 			logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
 			return false
 		}
+		if exists {
+			diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
+		}
 		if exists && diffInboundUsers(oldIb, newIb, diff) {
 			continue
 		}
@@ -174,6 +201,26 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 	return true
 }
 
+// droppedClients lists the emails an inbound present in both configs stopped
+// serving, whatever its protocol: settings.clients is the shape they all share.
+func droppedClients(oldIb, newIb *InboundConfig) []UserOp {
+	oldClients, _, ok := splitSettingsClients(oldIb.Settings)
+	if !ok {
+		return nil
+	}
+	newClients, _, ok := splitSettingsClients(newIb.Settings)
+	if !ok {
+		return nil
+	}
+	var dropped []UserOp
+	for email := range oldClients {
+		if _, still := newClients[email]; !still {
+			dropped = append(dropped, UserOp{Tag: newIb.Tag, Protocol: newIb.Protocol, Email: email})
+		}
+	}
+	return dropped
+}
+
 var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}}
 
 // diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds

+ 113 - 0
internal/xray/hot_diff_drops_users_test.go

@@ -0,0 +1,113 @@
+package xray
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
+)
+
+func hotConfigWithClients(clients string) *Config {
+	cfg := makeHotConfig()
+	for i := range cfg.InboundConfigs {
+		if cfg.InboundConfigs[i].Tag == "inbound-1080" {
+			cfg.InboundConfigs[i].Settings = json_util.RawMessage(`{"clients":` + clients + `}`)
+		}
+	}
+	return cfg
+}
+
+// diffInboundUsers refuses shadowsocks and hysteria, so their dropped clients
+// reach the guard through the inbound instead of through a per-user op.
+func TestHotDiffDropsUsersOnProtocolsItCannotDiff(t *testing.T) {
+	for _, protocol := range []string{"shadowsocks", "hysteria"} {
+		t.Run(protocol, func(t *testing.T) {
+			withClients := func(clients string) *Config {
+				cfg := makeHotConfig()
+				ib := &cfg.InboundConfigs[1]
+				ib.Protocol = protocol
+				ib.Settings = json_util.RawMessage(`{"clients":` + clients + `}`)
+				return cfg
+			}
+			both := `[{"email":"a@x","password":"pa"},{"email":"b@x","password":"pb"}]`
+			onlyA := `[{"email":"a@x","password":"pa"}]`
+
+			diff, ok := ComputeHotDiff(withClients(both), withClients(onlyA))
+			if !ok {
+				t.Fatal("a dropped client must stay API-applicable")
+			}
+			if !diff.DropsUsers() {
+				t.Fatalf("DropsUsers = false for a dropped %s client (removed=%+v added=%+v dropped=%+v)",
+					protocol, diff.RemovedUsers, diff.AddedUsers, diff.DroppedClients)
+			}
+
+			edited, ok := ComputeHotDiff(withClients(both), withClients(`[{"email":"a@x","password":"pa"},{"email":"b@x","password":"pb","level":1}]`))
+			if !ok {
+				t.Fatal("a client edit must stay API-applicable")
+			}
+			if edited.DropsUsers() {
+				t.Fatal("an edited client is still served")
+			}
+		})
+	}
+}
+
+// A disable or a delete takes the client out of the generated config; an edit
+// keeps the email and re-adds it. Only the first leaves sessions running.
+func TestHotDiffDropsUsers(t *testing.T) {
+	cases := []struct {
+		name string
+		old  string
+		new  string
+		want bool
+	}{
+		{
+			"client taken out of the config",
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			`[]`,
+			true,
+		},
+		{
+			"edited in place",
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","limitIp":5,"enable":true}]`,
+			false,
+		},
+		{
+			"added",
+			`[]`,
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			false,
+		},
+		{
+			"renamed",
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			`[{"email":"b@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			true,
+		},
+		{
+			"one dropped, one edited",
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true},{"email":"b@x","id":"22222222-2222-2222-2222-222222222222","enable":true}]`,
+			`[{"email":"b@x","id":"22222222-2222-2222-2222-222222222222","limitIp":5,"enable":true}]`,
+			true,
+		},
+		{
+			"unchanged",
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
+			false,
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			diff, ok := ComputeHotDiff(hotConfigWithClients(tc.old), hotConfigWithClients(tc.new))
+			if !ok {
+				t.Fatalf("diff of %s -> %s must be API-applicable", tc.old, tc.new)
+			}
+			if got := diff.DropsUsers(); got != tc.want {
+				t.Fatalf("DropsUsers = %v, want %v (removed=%+v added=%+v)",
+					got, tc.want, diff.RemovedUsers, diff.AddedUsers)
+			}
+		})
+	}
+}