diff --git a/docs/content/docs/en/config/clients.mdx b/docs/content/docs/en/config/clients.mdx
index d1f5f1e22..9908322e4 100644
--- a/docs/content/docs/en/config/clients.mdx
+++ b/docs/content/docs/en/config/clients.mdx
@@ -27,8 +27,8 @@ inbounds** at once, with per-client traffic accounting.
| **Comment** | all | Free-text note. |
- 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).
diff --git a/docs/content/docs/fa/config/clients.mdx b/docs/content/docs/fa/config/clients.mdx
index ed3c20d35..e1ba5af62 100644
--- a/docs/content/docs/fa/config/clients.mdx
+++ b/docs/content/docs/fa/config/clients.mdx
@@ -27,8 +27,8 @@ icon: Users
| **Comment** | همه | یادداشت متنی آزاد. |
- رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ پنل میتواند
- هنگام غیرفعالشدن خودکار کلاینتها، Xray را بهصورت خودکار راهاندازی مجدد کند
+ رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ غیرفعالسازی یا
+ حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راهاندازی مجدد میکند
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
diff --git a/docs/content/docs/ru/config/clients.mdx b/docs/content/docs/ru/config/clients.mdx
index eba423351..15686f80e 100644
--- a/docs/content/docs/ru/config/clients.mdx
+++ b/docs/content/docs/ru/config/clients.mdx
@@ -28,9 +28,9 @@ icon: Users
| **Comment** | все | Произвольная текстовая заметка. |
- Достижение лимита **трафика** или **срока действия** отключает клиента; при
- автоматическом отключении клиентов панель может автоматически перезапускать
- Xray (`restartXrayOnClientDisable`, включено по умолчанию).
+ Достижение лимита **трафика** или **срока действия** отключает клиента, как и
+ ручное отключение или удаление; тогда панель перезапускает Xray
+ (`restartXrayOnClientDisable`, включено по умолчанию).
## Лимиты и контроль IP
diff --git a/docs/content/docs/zh/config/clients.mdx b/docs/content/docs/zh/config/clients.mdx
index a7cbc3fd2..78d0fe97a 100644
--- a/docs/content/docs/zh/config/clients.mdx
+++ b/docs/content/docs/zh/config/clients.mdx
@@ -26,8 +26,8 @@ icon: Users
| **Comment** | 全部 | 自由文本备注。 |
- 达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
- 自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
+ 达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
+ 此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
## 限制与 IP 控制
diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go
index 70b848133..c040f9ddb 100644
--- a/internal/web/service/client_bulk.go
+++ b/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
}
}
}
diff --git a/internal/web/service/client_disable_restart_test.go b/internal/web/service/client_disable_restart_test.go
new file mode 100644
index 000000000..aede9f81d
--- /dev/null
+++ b/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 = "manual-disable@example.com"
+
+ 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)
+ }
+ })
+ }
+}
diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go
index 34942e140..4f9cd9c1a 100644
--- a/internal/web/service/client_inbound_apply.go
+++ b/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 {
diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go
index e9513e39b..c79e752e5 100644
--- a/internal/web/service/xray.go
+++ b/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 {
diff --git a/internal/web/service/xray_drop_clients_test.go b/internal/web/service/xray_drop_clients_test.go
new file mode 100644
index 000000000..00ce16182
--- /dev/null
+++ b/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)
+ }
+ })
+ }
+}
diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json
index 01bccca17..abc1bcc7e 100644
--- a/internal/web/translation/ar-EG.json
+++ b/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": "إعدادات التجزئة",
diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json
index 7e90cbb5d..c15d6ff2e 100644
--- a/internal/web/translation/en-US.json
+++ b/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",
diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json
index cb6fbf49e..8c16e2e57 100644
--- a/internal/web/translation/es-ES.json
+++ b/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",
diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json
index 34eb1382e..46cf48ed6 100644
--- a/internal/web/translation/fa-IR.json
+++ b/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": "تنظیمات فرگمنت",
diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json
index bcad6c48b..40a79a218 100644
--- a/internal/web/translation/id-ID.json
+++ b/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",
diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json
index bf5299db1..fa462ba71 100644
--- a/internal/web/translation/ja-JP.json
+++ b/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": "設定",
diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json
index 9913a4db9..6a971e4cd 100644
--- a/internal/web/translation/pt-BR.json
+++ b/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",
diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json
index 635b3f414..6ed46153f 100644
--- a/internal/web/translation/ru-RU.json
+++ b/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": "Настройки фрагментации",
diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json
index 6d1f8a984..21b3c2d13 100644
--- a/internal/web/translation/tr-TR.json
+++ b/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ı",
diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json
index 8e8f96f49..3c511bcf5 100644
--- a/internal/web/translation/uk-UA.json
+++ b/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": "Параметри фрагментації",
diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json
index 6df0bc07f..05a9995ff 100644
--- a/internal/web/translation/vi-VN.json
+++ b/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",
diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json
index 3f36fbe58..13a5bfc57 100644
--- a/internal/web/translation/zh-CN.json
+++ b/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": "设置",
diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json
index 77a83f963..f0101c9b7 100644
--- a/internal/web/translation/zh-TW.json
+++ b/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": "設定",
diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go
index 817e277c8..2d6e77ba3 100644
--- a/internal/xray/hot_diff.go
+++ b/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
diff --git a/internal/xray/hot_diff_drops_users_test.go b/internal/xray/hot_diff_drops_users_test.go
new file mode 100644
index 000000000..c192f55f2
--- /dev/null
+++ b/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)
+ }
+ })
+ }
+}