1
0

13 Коммиты d291e1c5ee ... ad32144c42

Автор SHA1 Сообщение Дата
  n0ctal ad32144c42 fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174) 17 часов назад
  n0ctal 34c248bb79 fix(cli): stop -getApiToken accumulating admin tokens (#6175) 17 часов назад
  n0ctal 17fea2f656 fix(database): keep IP limits when the fail2ban probe is inconclusive (#6176) 17 часов назад
  n0ctal c5dec64d36 fix(clients): push bulk client changes to nodes only after the commit lands (#6181) 17 часов назад
  n0ctal b56b087254 fix(migration): stop a half-applied startup migration from committing silently (#6182) 17 часов назад
  n0ctal 3bb87e80aa fix(db): harden unrestricted freedom outbounds (#6184) 17 часов назад
  n0ctal 60453bf523 fix(nodes): persist the master mTLS credential atomically and stop silent reissue (#6195) 17 часов назад
  n0ctal bb29b6afec fix(node): adopt a matching deployed inbound instead of recreating it (#6197) 17 часов назад
  n0ctal 3b19091547 fix(sub): render the full remark once per subscription, not once per credential (#6198) 17 часов назад
  n0ctal 0496c23a26 fix(groups): report changed bulk moves without restarting xray (#6199) 17 часов назад
  n0ctal 1396005082 fix(traffic): apply maintenance side effects only after the commit lands (#6200) 17 часов назад
  n0ctal b70c5abce8 fix(outbounds): propagate allocation query failures (#6208) 17 часов назад
  n0ctal 20b3f84f77 fix(web): report unexpected HTTP serve failures (#6210) 17 часов назад
46 измененных файлов с 1929 добавлено и 503 удалено
  1. 1 0
      frontend/src/generated/types.ts
  2. 3 0
      frontend/src/generated/zod.ts
  3. 1 1
      frontend/src/lib/remark/remarkVariables.ts
  4. 38 6
      internal/database/db.go
  5. 109 0
      internal/database/fail2ban_state_test.go
  6. 52 0
      internal/database/freedom_finalrules_migration_test.go
  7. 15 7
      internal/sub/remark_vars.go
  8. 47 8
      internal/sub/remark_vars_test.go
  9. 2 3
      internal/sub/service.go
  10. 1 3
      internal/sub/sub.go
  11. 0 1
      internal/web/controller/group.go
  12. 1 0
      internal/web/job/node_traffic_sync_job.go
  13. 17 0
      internal/web/network/serve.go
  14. 111 0
      internal/web/network/serve_test.go
  15. 32 7
      internal/web/runtime/remote.go
  16. 50 90
      internal/web/service/client_bulk.go
  17. 42 0
      internal/web/service/client_group_bulk_test.go
  18. 11 5
      internal/web/service/client_groups.go
  19. 5 5
      internal/web/service/global_traffic_test.go
  20. 0 1
      internal/web/service/inbound.go
  21. 1 1
      internal/web/service/inbound_autorenew_shadowsocks_test.go
  22. 1 1
      internal/web/service/inbound_autorenew_test.go
  23. 46 97
      internal/web/service/inbound_disable.go
  24. 43 23
      internal/web/service/inbound_migration.go
  25. 38 0
      internal/web/service/inbound_migration_test.go
  26. 77 3
      internal/web/service/inbound_node.go
  27. 101 0
      internal/web/service/inbound_node_reconcile_test.go
  28. 255 206
      internal/web/service/inbound_traffic.go
  29. 105 0
      internal/web/service/inbound_traffic_apply.go
  30. 3 0
      internal/web/service/node.go
  31. 86 0
      internal/web/service/node_bulk_dispatch_test.go
  32. 34 0
      internal/web/service/node_tag_sync_test.go
  33. 15 0
      internal/web/service/node_test.go
  34. 4 18
      internal/web/service/outbound/outbound.go
  35. 54 0
      internal/web/service/outbound/outbound_commit_postgres_test.go
  36. 16 6
      internal/web/service/outbound_subscription.go
  37. 92 0
      internal/web/service/outbound_subscription_test.go
  38. 24 0
      internal/web/service/panel/api_token.go
  39. 76 1
      internal/web/service/panel/api_token_test.go
  40. 1 0
      internal/web/service/setting.go
  41. 1 0
      internal/web/service/setting_factory_defaults_test.go
  42. 74 2
      internal/web/service/setting_mtls.go
  43. 149 0
      internal/web/service/setting_mtls_test.go
  44. 86 0
      internal/web/service/traffic_runtime_apply_test.go
  45. 1 3
      internal/web/web.go
  46. 8 5
      main.go

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

@@ -4,6 +4,7 @@ export type ProcessState = string;
 export type Protocol = string;
 export type Protocol = string;
 export type SubLinkProvider = unknown;
 export type SubLinkProvider = unknown;
 export type staticEgressResolver = string;
 export type staticEgressResolver = string;
+export type trafficLocalApplyAction = number;
 export type transportBits = number;
 export type transportBits = number;
 
 
 export interface AllSetting {
 export interface AllSetting {

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

@@ -15,6 +15,9 @@ export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
 export const staticEgressResolverSchema = z.string();
 export const staticEgressResolverSchema = z.string();
 export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
 export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
 
 
+export const trafficLocalApplyActionSchema = z.number().int();
+export type trafficLocalApplyAction = z.infer<typeof trafficLocalApplyActionSchema>;
+
 export const transportBitsSchema = z.number().int();
 export const transportBitsSchema = z.number().int();
 export type transportBits = z.infer<typeof transportBitsSchema>;
 export type transportBits = z.infer<typeof transportBitsSchema>;
 
 

+ 1 - 1
frontend/src/lib/remark/remarkVariables.ts

@@ -39,7 +39,7 @@ export const REMARK_VARIABLES: RemarkVar[] = [
   { token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
   { token: 'STATUS_EMOJI', group: 'time', sample: '✅' },
   { token: 'DAYS_LEFT', group: 'time', sample: '12' },
   { token: 'DAYS_LEFT', group: 'time', sample: '12' },
   { token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
   { token: 'TIME_LEFT', group: 'time', sample: '12d 4h 30m' },
-  { token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3%' },
+  { token: 'USAGE_PERCENTAGE', group: 'time', sample: '52.3' },
   { token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
   { token: 'EXPIRE_DATE', group: 'time', sample: '2026-09-01' },
   { token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
   { token: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },

+ 38 - 6
internal/database/db.go

@@ -1278,9 +1278,14 @@ func resetIpLimitsWithoutFail2ban() error {
 		return nil
 		return nil
 	}
 	}
 
 
-	if fail2banCanEnforce() {
+	state, probeErr := fail2banEnforcementState()
+	if state == fail2banEnforcing {
 		return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
 		return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
 	}
 	}
+	if state == fail2banUnknown {
+		log.Printf("ResetIpLimitNoFail2ban: fail2ban-client present but not runnable (%v); keeping configured IP limits, will retry next start", probeErr)
+		return nil
+	}
 
 
 	var inbounds []model.Inbound
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
 	if err := db.Find(&inbounds).Error; err != nil {
@@ -1340,14 +1345,30 @@ func resetIpLimitsWithoutFail2ban() error {
 	})
 	})
 }
 }
 
 
-func fail2banCanEnforce() bool {
+type fail2banState int
+
+const (
+	fail2banEnforcing fail2banState = iota
+	fail2banAbsent
+	fail2banUnknown
+)
+
+// fail2banEnforcementState separates "fail2ban is not installed" from "the probe
+// itself failed", so a transient failure never drives an irreversible cleanup.
+func fail2banEnforcementState() (fail2banState, error) {
 	if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
 	if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
-		return false
+		return fail2banAbsent, nil
 	}
 	}
 	if runtime.GOOS == "windows" {
 	if runtime.GOOS == "windows" {
-		return false
+		return fail2banAbsent, nil
+	}
+	if _, err := exec.LookPath("fail2ban-client"); err != nil {
+		return fail2banAbsent, nil
 	}
 	}
-	return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
+	if err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run(); err != nil {
+		return fail2banUnknown, err
+	}
+	return fail2banEnforcing, nil
 }
 }
 
 
 func clearLegacyProxySettings() error {
 func clearLegacyProxySettings() error {
@@ -1628,6 +1649,14 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
 	return true
 	return true
 }
 }
 
 
+func isUnrestrictedFreedomFinalRules(v any, present bool) bool {
+	if !present || v == nil {
+		return true
+	}
+	rules, ok := v.([]any)
+	return ok && len(rules) == 0
+}
+
 func hardenFreedomFinalRules() error {
 func hardenFreedomFinalRules() error {
 	var setting model.Setting
 	var setting model.Setting
 	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
 	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
@@ -1680,7 +1709,10 @@ func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
 		if !ok {
 		if !ok {
 			continue
 			continue
 		}
 		}
-		if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
+		finalRules, present := settings["finalRules"]
+		if !isUnrestrictedFreedomFinalRules(finalRules, present) &&
+			!isAllowOnlyFinalRules(finalRules) &&
+			!isLegacyPrivateOnlyFinalRules(finalRules) {
 			continue
 			continue
 		}
 		}
 		settings["finalRules"] = []any{
 		settings["finalRules"] = []any{

+ 109 - 0
internal/database/fail2ban_state_test.go

@@ -0,0 +1,109 @@
+package database
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// stubFail2banClient puts a fail2ban-client on PATH whose exit code the test picks.
+func stubFail2banClient(t *testing.T, exitCode int) {
+	t.Helper()
+	dir := t.TempDir()
+	script := filepath.Join(dir, "fail2ban-client")
+	body := "#!/bin/sh\nexit " + string(rune('0'+exitCode)) + "\n"
+	if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
+		t.Fatalf("write stub: %v", err)
+	}
+	t.Setenv("PATH", dir)
+}
+
+func TestFail2banEnforcementStateSeparatesAbsentFromUnrunnable(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("fail2ban shell fixtures are Unix-only")
+	}
+	t.Run("absent", func(t *testing.T) {
+		t.Setenv("PATH", t.TempDir())
+		if got, _ := fail2banEnforcementState(); got != fail2banAbsent {
+			t.Fatalf("state = %v, want fail2banAbsent", got)
+		}
+	})
+
+	t.Run("present and runnable", func(t *testing.T) {
+		stubFail2banClient(t, 0)
+		if got, _ := fail2banEnforcementState(); got != fail2banEnforcing {
+			t.Fatalf("state = %v, want fail2banEnforcing", got)
+		}
+	})
+
+	t.Run("present but failing", func(t *testing.T) {
+		stubFail2banClient(t, 1)
+		got, err := fail2banEnforcementState()
+		if got != fail2banUnknown {
+			t.Fatalf("state = %v, want fail2banUnknown", got)
+		}
+		if err == nil {
+			t.Fatal("want the probe error, got nil")
+		}
+	})
+}
+
+func TestResetIpLimitsKeepsConfiguredLimitsWhenProbeFails(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("fail2ban shell fixtures are Unix-only")
+	}
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	if err := db.Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+	settings, err := json.Marshal(map[string]any{"clients": []any{map[string]any{"email": "[email protected]", "limitIp": 6}}})
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	inbound := model.Inbound{Remark: "kept", Settings: string(settings)}
+	if err := db.Create(&inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	record := model.ClientRecord{Email: "[email protected]", LimitIP: 2}
+	if err := db.Create(&record).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+	stubFail2banClient(t, 1)
+	if err := resetIpLimitsWithoutFail2ban(); err != nil {
+		t.Fatalf("reset: %v", err)
+	}
+	var gotInbound model.Inbound
+	if err := db.First(&gotInbound, inbound.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	var got map[string]any
+	if err := json.Unmarshal([]byte(gotInbound.Settings), &got); err != nil {
+		t.Fatalf("decode settings: %v", err)
+	}
+	clients := got["clients"].([]any)
+	if limit := clients[0].(map[string]any)["limitIp"]; limit != float64(6) {
+		t.Fatalf("inbound limitIp = %v, want 6", limit)
+	}
+	if err := db.First(&record, record.Id).Error; err != nil {
+		t.Fatalf("reload client record: %v", err)
+	}
+	if record.LimitIP != 2 {
+		t.Fatalf("client record limitIp = %d, want 2", record.LimitIP)
+	}
+	var count int64
+	if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Count(&count).Error; err != nil {
+		t.Fatalf("count seeder history: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("seeder history rows = %d, want 0", count)
+	}
+}

+ 52 - 0
internal/database/freedom_finalrules_migration_test.go

@@ -23,6 +23,24 @@ func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) {
 			wantChanged: true,
 			wantChanged: true,
 			wantRules:   hardened,
 			wantRules:   hardened,
 		},
 		},
+		{
+			name:        "missing finalRules is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "null finalRules is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":null},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "empty finalRules is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":[]},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
 		{
 		{
 			name:        "legacy private-only allow is hardened",
 			name:        "legacy private-only allow is hardened",
 			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`,
 			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`,
@@ -76,6 +94,40 @@ func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestRewriteFreedomFinalRulesPreservesSplitRouting(t *testing.T) {
+	const raw = `{
+		"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"}],
+		"routing":{"domainStrategy":"AsIs","rules":[
+			{"type":"field","domain":["regexp:.*\\.ru$"],"outboundTag":"direct"},
+			{"type":"field","network":"tcp,udp","outboundTag":"proxy"}
+		]}
+	}`
+	updated, changed, err := rewriteFreedomFinalRulesPrivateEgress(raw)
+	if err != nil {
+		t.Fatalf("rewrite: %v", err)
+	}
+	if !changed {
+		t.Fatal("missing finalRules must be hardened")
+	}
+	var before, after map[string]any
+	if err := json.Unmarshal([]byte(raw), &before); err != nil {
+		t.Fatalf("decode before: %v", err)
+	}
+	if err := json.Unmarshal([]byte(updated), &after); err != nil {
+		t.Fatalf("decode after: %v", err)
+	}
+	beforeRouting, _ := json.Marshal(before["routing"])
+	afterRouting, _ := json.Marshal(after["routing"])
+	if string(afterRouting) != string(beforeRouting) {
+		t.Fatalf("split routing changed:\n got %s\nwant %s", afterRouting, beforeRouting)
+	}
+	outbound := after["outbounds"].([]any)[0].(map[string]any)
+	settings := outbound["settings"].(map[string]any)
+	if settings["domainStrategy"] != "AsIs" {
+		t.Fatalf("freedom domainStrategy=%v want AsIs", settings["domainStrategy"])
+	}
+}
+
 func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) {
 func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) {
 	_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
 	_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
 	if err == nil {
 	if err == nil {

+ 15 - 7
internal/sub/remark_vars.go

@@ -350,8 +350,8 @@ func statusEmoji(st xray.ClientTraffic) string {
 	}
 	}
 }
 }
 
 
-// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3%").
-// Returns "" when the client has no traffic limit.
+// usagePercentage computes the traffic usage as a percentage string (e.g. "52.3").
+// Uses U+FF05: an ASCII percent encodes to %25, which Happ rejects, dropping the remark.
 func usagePercentage(st xray.ClientTraffic) string {
 func usagePercentage(st xray.ClientTraffic) string {
 	if st.Total <= 0 {
 	if st.Total <= 0 {
 		return ""
 		return ""
@@ -361,7 +361,7 @@ func usagePercentage(st xray.ClientTraffic) string {
 	if pct > 100 {
 	if pct > 100 {
 		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
 		pct = 100 // clamp over-quota usage, consistent with TRAFFIC_LEFT
 	}
 	}
-	return fmt.Sprintf("%.1f%%", pct)
+	return fmt.Sprintf("%.1f", pct)
 }
 }
 
 
 // timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
 // timeLeftLabel renders remaining time as "Xd Xh Xm" (or shorter when days/hours
@@ -607,19 +607,27 @@ func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []
 	return runs
 	return runs
 }
 }
 
 
-func (s *SubService) effectiveTemplate(email string) string {
+func templateInfoKey(client model.Client) string {
+	if client.SubID != "" {
+		return "sub:" + client.SubID
+	}
+	return "email:" + client.Email
+}
+
+func (s *SubService) effectiveTemplate(client model.Client) string {
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	translated := translateUISingleBrackets(s.remarkTemplate)
 	if s.usageShown == nil {
 	if s.usageShown == nil {
 		s.usageShown = map[string]bool{}
 		s.usageShown = map[string]bool{}
 	}
 	}
-	if s.usageShown[email] {
+	key := templateInfoKey(client)
+	if s.usageShown[key] {
 		remove := firstLinkOnlyBodyTokens
 		remove := firstLinkOnlyBodyTokens
 		if s.showIdentityOnAllLinks {
 		if s.showIdentityOnAllLinks {
 			remove = usageInfoTokens
 			remove = usageInfoTokens
 		}
 		}
 		return filterRemarkTemplate(translated, remove)
 		return filterRemarkTemplate(translated, remove)
 	}
 	}
-	s.usageShown[email] = true
+	s.usageShown[key] = true
 	return translated
 	return translated
 }
 }
 
 
@@ -646,7 +654,7 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 	}
 	}
 	var tmpl string
 	var tmpl string
 	if s.subscriptionBody {
 	if s.subscriptionBody {
-		tmpl = s.effectiveTemplate(client.Email)
+		tmpl = s.effectiveTemplate(client)
 	} else {
 	} else {
 		tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
 		tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
 	}
 	}

+ 47 - 8
internal/sub/remark_vars_test.go

@@ -1,6 +1,7 @@
 package sub
 package sub
 
 
 import (
 import (
+	"net/url"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
@@ -480,18 +481,33 @@ func TestStatusEmoji(t *testing.T) {
 }
 }
 
 
 func TestUsagePercentage(t *testing.T) {
 func TestUsagePercentage(t *testing.T) {
-	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0%" {
+	if got := usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 25 * gb, Down: 25 * gb}); got != "50.0" {
 		t.Errorf("usagePercentage 50%% = %q", got)
 		t.Errorf("usagePercentage 50%% = %q", got)
 	}
 	}
 	if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
 	if got := usagePercentage(xray.ClientTraffic{Total: 0}); got != "" {
 		t.Errorf("usagePercentage unlimited = %q, want empty", got)
 		t.Errorf("usagePercentage unlimited = %q, want empty", got)
 	}
 	}
-	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0%" {
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 10 * gb}); got != "100.0" {
 		t.Errorf("usagePercentage 100%% = %q", got)
 		t.Errorf("usagePercentage 100%% = %q", got)
 	}
 	}
 	// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
 	// Over-quota usage clamps to 100%, consistent with TRAFFIC_LEFT.
-	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
-		t.Errorf("usagePercentage over-quota = %q, want 100.0%%", got)
+	if got := usagePercentage(xray.ClientTraffic{Total: 10 * gb, Up: 25 * gb}); got != "100.0%" {
+		t.Errorf("usagePercentage over-quota = %q, want 100.0%", got)
+	}
+}
+
+func TestUsagePercentageSurvivesFragmentEncoding(t *testing.T) {
+	remark := "node " + usagePercentage(xray.ClientTraffic{Total: 100 * gb, Up: 50 * gb})
+	link := buildLinkWithParams("vless://[email protected]:443", nil, remark)
+	if strings.Contains(link, "%25") {
+		t.Fatalf("encoded remark contains %%25, Happ drops such remarks: %s", link)
+	}
+	u, err := url.Parse(link)
+	if err != nil {
+		t.Fatalf("parse: %v", err)
+	}
+	if u.Fragment != remark {
+		t.Fatalf("fragment = %q, want %q", u.Fragment, remark)
 	}
 	}
 }
 }
 
 
@@ -546,7 +562,7 @@ func TestExpandNewTokensInTemplate(t *testing.T) {
 
 
 	cases := []struct{ tmpl, want string }{
 	cases := []struct{ tmpl, want string }{
 		{"{{STATUS_EMOJI}}", "✅"},
 		{"{{STATUS_EMOJI}}", "✅"},
-		{"{{USAGE_PERCENTAGE}}", "50.0%"},
+		{"{{USAGE_PERCENTAGE}}", "50.0"},
 		{"{{PROTOCOL}}", "VLESS"},
 		{"{{PROTOCOL}}", "VLESS"},
 		{"{{TRANSPORT}}", "ws"},
 		{"{{TRANSPORT}}", "ws"},
 		{"{{SECURITY}}", "REALITY"},
 		{"{{SECURITY}}", "REALITY"},
@@ -619,7 +635,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
 		{"{DATA_USAGE}", "50.00GB"},
 		{"{DATA_USAGE}", "50.00GB"},
 		{"{DATA_LIMIT}", "100.00GB"},
 		{"{DATA_LIMIT}", "100.00GB"},
 		{"{STATUS_EMOJI}", "✅"},
 		{"{STATUS_EMOJI}", "✅"},
-		{"{USAGE_PERCENTAGE}", "50.0%"},
+		{"{USAGE_PERCENTAGE}", "50.0"},
 		{"{PROTOCOL}", "VLESS"},
 		{"{PROTOCOL}", "VLESS"},
 		{"{TRANSPORT}", "ws"},
 		{"{TRANSPORT}", "ws"},
 		{"{SECURITY}", "TLS"},
 		{"{SECURITY}", "TLS"},
@@ -649,7 +665,6 @@ func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
 	}
 	}
 	client := model.Client{Email: "alice@x"}
 	client := model.Client{Email: "alice@x"}
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
-	s.usageShown["alice@x"] = true
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	if !strings.Contains(first, "📊") {
 	if !strings.Contains(first, "📊") {
 		t.Fatalf("first link should carry usage: %q", first)
 		t.Fatalf("first link should carry usage: %q", first)
@@ -675,7 +690,6 @@ func TestEmailOnFirstLinkOnly(t *testing.T) {
 	}
 	}
 	client := model.Client{Email: "alice@x"}
 	client := model.Client{Email: "alice@x"}
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
-	s.usageShown["alice@x"] = true
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	if !strings.Contains(first, "alice@x") {
 	if !strings.Contains(first, "alice@x") {
 		t.Fatalf("first link should carry email: %q", first)
 		t.Fatalf("first link should carry email: %q", first)
@@ -724,3 +738,28 @@ func TestIdentityOnAllLinks(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestSharedSubIDRemark_FullInfoOncePerSubscription(t *testing.T) {
+	const tmpl = "{{INBOUND}}-{{EMAIL}}"
+	s := &SubService{
+		remarkTemplate:   tmpl,
+		subscriptionBody: true,
+		usageShown:       map[string]bool{},
+	}
+	first := model.Client{Email: "first@example", SubID: "shared-sub"}
+	second := model.Client{Email: "second@example", SubID: "shared-sub"}
+	if got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, first, "", "tcp"); got != "DE-first@example" {
+		t.Fatalf("first credential remark = %q", got)
+	}
+	if got := s.genTemplatedRemark(&model.Inbound{Remark: "FI"}, second, "", "tcp"); got != "FI" {
+		t.Fatalf("second credential with shared subId remark = %q, want identity suppressed", got)
+	}
+}
+
+func TestGenTemplatedRemarkPreservesConfiguredOuterWhitespace(t *testing.T) {
+	s := &SubService{remarkTemplate: "  {{INBOUND}}  ", subscriptionBody: true, usageShown: map[string]bool{}}
+	got := s.genTemplatedRemark(&model.Inbound{Remark: "DE"}, model.Client{Email: "[email protected]"}, "", "tcp")
+	if got != "  DE  " {
+		t.Fatalf("remark = %q, want configured outer whitespace preserved", got)
+	}
+}

+ 2 - 3
internal/sub/service.go

@@ -37,9 +37,8 @@ type SubService struct {
 	// other context — the sub info page, the panel's link/QR displays — renders
 	// other context — the sub info page, the panel's link/QR displays — renders
 	// the name-only template, like Remnawave.
 	// the name-only template, like Remnawave.
 	subscriptionBody bool
 	subscriptionBody bool
-	// usageShown tracks, per client email, whether the info part of the template
-	// has already been emitted this request, so it appears on the first body
-	// link only. Per-request state; reset in PrepareForRequest.
+	// usageShown emits info once per subscription identity, including twins.
+	// PrepareForRequest resets this per-request state.
 	usageShown             map[string]bool
 	usageShown             map[string]bool
 	showIdentityOnAllLinks bool
 	showIdentityOnAllLinks bool
 	inboundService         service.InboundService
 	inboundService         service.InboundService

+ 1 - 3
internal/sub/sub.go

@@ -371,9 +371,7 @@ func (s *Server) Start() (err error) {
 		IdleTimeout:       120 * time.Second,
 		IdleTimeout:       120 * time.Second,
 	}
 	}
 
 
-	go func() {
-		_ = s.httpServer.Serve(listener)
-	}()
+	go network.ServeHTTP(s.httpServer, listener, "Subscription server")
 
 
 	return nil
 	return nil
 }
 }

+ 0 - 1
internal/web/controller/group.go

@@ -148,7 +148,6 @@ func (a *GroupController) bulkAdd(c *gin.Context) {
 		return
 		return
 	}
 	}
 	jsonObj(c, gin.H{"affected": affected}, nil)
 	jsonObj(c, gin.H{"affected": affected}, nil)
-	a.xrayService.SetToNeedRestart()
 	notifyClientsChanged()
 	notifyClientsChanged()
 }
 }
 
 

+ 1 - 0
internal/web/job/node_traffic_sync_job.go

@@ -389,6 +389,7 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSy
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		j.inboundService.ClearNodeOnlineClients(n.Id)
 		return nil
 		return nil
 	}
 	}
+	snap.ManagedAliases = rt.AdoptedInboundAliases()
 	service.FilterNodeSnapshot(n, snap)
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	if !dirty {
 	if !dirty {

+ 17 - 0
internal/web/network/serve.go

@@ -0,0 +1,17 @@
+package network
+
+import (
+	"errors"
+	"net"
+	"net/http"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+// ServeHTTP runs a panel HTTP server and records unexpected listener failures.
+// A normal Shutdown returns http.ErrServerClosed and is intentionally silent.
+func ServeHTTP(server *http.Server, listener net.Listener, name string) {
+	if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
+		logger.Error(name, " stopped unexpectedly: ", err)
+	}
+}

+ 111 - 0
internal/web/network/serve_test.go

@@ -0,0 +1,111 @@
+package network
+
+import (
+	"errors"
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"net"
+	"net/http"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+)
+
+type failingListener struct{ err error }
+
+func (l failingListener) Accept() (net.Conn, error) { return nil, l.err }
+func (failingListener) Close() error                { return nil }
+func (failingListener) Addr() net.Addr              { return testAddr("failing") }
+
+type testAddr string
+
+func (a testAddr) Network() string { return string(a) }
+func (a testAddr) String() string  { return string(a) }
+
+func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
+	errInjected := errors.New("injected listener failure")
+	ServeHTTP(&http.Server{}, failingListener{err: errInjected}, "Test server")
+
+	for _, line := range logger.GetLogs(100, "error") {
+		if strings.Contains(line, errInjected.Error()) {
+			return
+		}
+	}
+	t.Fatal("unexpected listener failure was not recorded in the panel log")
+}
+
+func TestServeHTTPSuppressesNormalServerClose(t *testing.T) {
+	const marker = "normal-close-must-stay-silent"
+	ServeHTTP(&http.Server{}, failingListener{err: http.ErrServerClosed}, marker)
+
+	for _, line := range logger.GetLogs(100, "error") {
+		if strings.Contains(line, marker) {
+			t.Fatalf("normal http.ErrServerClosed was recorded as an error: %s", line)
+		}
+	}
+}
+
+func TestProductionHTTPServersUseServeHTTPWrapper(t *testing.T) {
+	_, currentFile, _, ok := runtime.Caller(0)
+	if !ok {
+		t.Fatal("locate test source")
+	}
+	repoRoot := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../../.."))
+	fset := token.NewFileSet()
+
+	err := filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, walkErr error) error {
+		if walkErr != nil {
+			return walkErr
+		}
+		if entry.IsDir() {
+			if entry.Name() == ".git" || entry.Name() == "vendor" || entry.Name() == "node_modules" {
+				return filepath.SkipDir
+			}
+			return nil
+		}
+		if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") || path == currentFile || path == filepath.Join(filepath.Dir(currentFile), "serve.go") {
+			return nil
+		}
+
+		parsed, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
+		if err != nil {
+			return err
+		}
+		usesHTTP := false
+		for _, imp := range parsed.Imports {
+			if imp.Path.Value == `"net/http"` {
+				usesHTTP = true
+				break
+			}
+		}
+		if !usesHTTP {
+			return nil
+		}
+
+		parsed, err = parser.ParseFile(fset, path, nil, 0)
+		if err != nil {
+			return err
+		}
+		ast.Inspect(parsed, func(node ast.Node) bool {
+			call, ok := node.(*ast.CallExpr)
+			if !ok {
+				return true
+			}
+			selector, ok := call.Fun.(*ast.SelectorExpr)
+			if ok && selector.Sel.Name == "Serve" {
+				position := fset.Position(call.Pos())
+				t.Errorf("direct Serve call at %s; production HTTP servers must use network.ServeHTTP", position)
+			}
+			return true
+		})
+		return nil
+	})
+	if err != nil {
+		t.Fatalf("scan production Go files: %v", err)
+	}
+}

+ 32 - 7
internal/web/runtime/remote.go

@@ -78,8 +78,9 @@ func (e *remoteAPIError) Error() string { return "remote: " + e.msg }
 type Remote struct {
 type Remote struct {
 	node *model.Node
 	node *model.Node
 
 
-	mu            sync.RWMutex
-	remoteIDByTag map[string]int
+	mu             sync.RWMutex
+	remoteIDByTag  map[string]int
+	adoptedAliases map[string]string
 	// pushedFP holds the fingerprint of the last inbound wire payload successfully
 	// pushedFP holds the fingerprint of the last inbound wire payload successfully
 	// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
 	// pushed, keyed by panel-side tag, so reconcile can skip re-sending an
 	// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
 	// unchanged inbound. Guarded by mu; dropped with the Remote on node config change.
@@ -99,8 +100,10 @@ type Remote struct {
 }
 }
 
 
 type RemoteInboundOption struct {
 type RemoteInboundOption struct {
+	Id       int            `json:"id"`
 	Tag      string         `json:"tag"`
 	Tag      string         `json:"tag"`
 	Remark   string         `json:"remark"`
 	Remark   string         `json:"remark"`
+	Listen   string         `json:"listen"`
 	Protocol model.Protocol `json:"protocol"`
 	Protocol model.Protocol `json:"protocol"`
 	Port     int            `json:"port"`
 	Port     int            `json:"port"`
 }
 }
@@ -109,6 +112,7 @@ func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 	return &Remote{
 	return &Remote{
 		node:           n,
 		node:           n,
 		remoteIDByTag:  make(map[string]int),
 		remoteIDByTag:  make(map[string]int),
+		adoptedAliases: make(map[string]string),
 		pushedFP:       make(map[string]string),
 		pushedFP:       make(map[string]string),
 		egressResolver: r,
 		egressResolver: r,
 	}
 	}
@@ -479,13 +483,33 @@ func (r *Remote) recordPushedInbound(ib *model.Inbound) {
 	r.mu.Unlock()
 	r.mu.Unlock()
 }
 }
 
 
-// RecordAdoptedInbound stamps the fingerprint when the master adopts the
-// node's own settings serialization into its DB — direct knowledge of the
-// exact payload the node holds.
+// RecordAdoptedInbound stamps the exact payload fingerprint after the master
+// adopts a node's settings serialization.
 func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
 func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) {
 	r.recordPushedInbound(ib)
 	r.recordPushedInbound(ib)
 }
 }
 
 
+// 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) {
+	r.mu.Lock()
+	r.remoteIDByTag[remote.Tag] = remote.Id
+	r.remoteIDByTag[ib.Tag] = remote.Id
+	r.adoptedAliases[ib.Tag] = remote.Tag
+	r.pushedFP[ib.Tag] = wireFingerprint(wireInbound(ib, r.node.Id))
+	r.mu.Unlock()
+}
+
+func (r *Remote) AdoptedInboundAliases() []string {
+	r.mu.RLock()
+	defer r.mu.RUnlock()
+	aliases := make([]string, 0, len(r.adoptedAliases))
+	for _, alias := range r.adoptedAliases {
+		aliases = append(aliases, alias)
+	}
+	return aliases
+}
+
 // AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's
 // AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's
 // pre-edit payload to its post-edit payload once every per-client push for the
 // pre-edit payload to its post-edit payload once every per-client push for the
 // edit succeeded. It advances only when the recorded fingerprint proves the
 // edit succeeded. It advances only when the recorded fingerprint proves the
@@ -661,8 +685,9 @@ func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) err
 }
 }
 
 
 type TrafficSnapshot struct {
 type TrafficSnapshot struct {
-	Inbounds     []*model.Inbound
-	OnlineEmails []string
+	Inbounds       []*model.Inbound
+	OnlineEmails   []string
+	ManagedAliases []string
 	// OnlineTree is the node's GUID-keyed online subtree (its own clients under
 	// OnlineTree is the node's GUID-keyed online subtree (its own clients under
 	// its panelGuid plus every descendant under theirs). Preferred over the flat
 	// its panelGuid plus every descendant under theirs). Preferred over the flat
 	// OnlineEmails so the master can attribute deeply nested clients to the real
 	// OnlineEmails so the master can attribute deeply nested clients to the real

+ 50 - 90
internal/web/service/client_bulk.go

@@ -652,7 +652,6 @@ func (s *ClientService) bulkAdjustInboundClients(
 		}
 		}
 		return res
 		return res
 	}
 	}
-	prevSettings := oldInbound.Settings
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
 	// A flow change rewrites the user's xray config, which the lightweight
 	// A flow change rewrites the user's xray config, which the lightweight
@@ -662,45 +661,6 @@ func (s *ClientService) bulkAdjustInboundClients(
 		res.needRestart = true
 		res.needRestart = true
 	}
 	}
 
 
-	if oldInbound.NodeID != nil {
-		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
-		if perr != nil {
-			for email := range foundEmails {
-				res.perEmailSkipped[email] = perr.Error()
-				delete(foundEmails, email)
-			}
-		} else {
-			if flowChanged {
-				push = false
-			}
-			// Large batches collapse into one reconcile push rather than M updates.
-			if push && len(foundEmails) > nodeBulkPushThreshold {
-				push = false
-			}
-			if push {
-				pushFailed := false
-				for email := range foundEmails {
-					entry := plan[email]
-					updated := *entry.record.ToClient()
-					if entry.applyExpiry {
-						updated.ExpiryTime = entry.newExpiry
-					}
-					if entry.applyTotal {
-						updated.TotalGB = entry.newTotal
-					}
-					updated.UpdatedAt = nowMs
-					if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
-						logger.Warning("Error in updating client on", rt.Name(), ":", err1)
-						pushFailed = true
-					}
-				}
-				if !pushFailed {
-					advancePushedInbound(rt, prevSettings, oldInbound)
-				}
-			}
-		}
-	}
-
 	// Serialize against the traffic poll to avoid the cross-transaction
 	// Serialize against the traffic poll to avoid the cross-transaction
 	// lock-order deadlock on inbounds/client_records (runSerializedTx).
 	// lock-order deadlock on inbounds/client_records (runSerializedTx).
 	txErr := runSerializedTx(func(tx *gorm.DB) error {
 	txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -725,6 +685,26 @@ func (s *ClientService) bulkAdjustInboundClients(
 				res.perEmailSkipped[email] = txErr.Error()
 				res.perEmailSkipped[email] = txErr.Error()
 			}
 			}
 		}
 		}
+	} else if oldInbound.NodeID != nil && !flowChanged && len(foundEmails) <= nodeBulkPushThreshold {
+		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
+		if perr != nil {
+			logger.Warning("BulkAdjust: node runtime lookup after commit failed:", perr)
+		} else if push {
+			for email := range foundEmails {
+				entry := plan[email]
+				updated := *entry.record.ToClient()
+				if entry.applyExpiry {
+					updated.ExpiryTime = entry.newExpiry
+				}
+				if entry.applyTotal {
+					updated.TotalGB = entry.newTotal
+				}
+				updated.UpdatedAt = nowMs
+				if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
+					logger.Warning("Error in updating client on", rt.Name(), ":", err1)
+				}
+			}
+		}
 	}
 	}
 
 
 	return res
 	return res
@@ -980,7 +960,6 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 		}
 		return res
 		return res
 	}
 	}
-	prevSettings := oldInbound.Settings
 	oldInbound.Settings = string(newSettings)
 	oldInbound.Settings = string(newSettings)
 
 
 	foundList := make([]string, 0, len(foundEmails))
 	foundList := make([]string, 0, len(foundEmails))
@@ -1048,7 +1027,31 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 		}
 	}
 	}
 
 
-	if oldInbound.NodeID == nil {
+	// Serialize against the traffic poll to avoid the cross-transaction
+	// lock-order deadlock on inbounds/client_records (runSerializedTx).
+	txErr := runSerializedTx(func(tx *gorm.DB) error {
+		if err := tx.Save(oldInbound).Error; err != nil {
+			return err
+		}
+		finalClients, err := inboundSvc.GetClients(oldInbound)
+		if err != nil {
+			return err
+		}
+		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
+			return err
+		}
+		if oldInbound.NodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
+		}
+		return nil
+	})
+	if txErr != nil {
+		for email := range foundEmails {
+			if _, skip := res.perEmailSkipped[email]; !skip {
+				res.perEmailSkipped[email] = txErr.Error()
+			}
+		}
+	} else if oldInbound.NodeID == nil {
 		rt, rterr := inboundSvc.runtimeFor(oldInbound)
 		rt, rterr := inboundSvc.runtimeFor(oldInbound)
 		if rterr != nil {
 		if rterr != nil {
 			res.needRestart = true
 			res.needRestart = true
@@ -1068,62 +1071,19 @@ func (s *ClientService) bulkDelInboundClients(
 				}
 				}
 			}
 			}
 		}
 		}
-	} else {
+	} else if len(foundEmails) <= nodeBulkPushThreshold {
 		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
 		if perr != nil {
+			logger.Warning("BulkDelete: node runtime lookup after commit failed:", perr)
+		} else if push {
 			for email := range foundEmails {
 			for email := range foundEmails {
-				res.perEmailSkipped[email] = perr.Error()
-				delete(foundEmails, email)
-			}
-		} else {
-			// Large batches collapse into one reconcile push rather than M deletes.
-			if push && len(foundEmails) > nodeBulkPushThreshold {
-				push = false
-			}
-			if push {
-				// bulkDelInboundClients only runs for full client deletion
-				// (BulkDelete), so the node must drop its client record too,
-				// not just detach from this inbound (#5797).
-				pushFailed := false
-				for email := range foundEmails {
-					if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
-						logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
-						pushFailed = true
-					}
-				}
-				if !pushFailed {
-					advancePushedInbound(rt, prevSettings, oldInbound)
+				if err1 := rt.DeleteClient(context.Background(), email); err1 != nil {
+					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 				}
 				}
 			}
 			}
 		}
 		}
 	}
 	}
 
 
-	// Serialize against the traffic poll to avoid the cross-transaction
-	// lock-order deadlock on inbounds/client_records (runSerializedTx).
-	txErr := runSerializedTx(func(tx *gorm.DB) error {
-		if err := tx.Save(oldInbound).Error; err != nil {
-			return err
-		}
-		finalClients, err := inboundSvc.GetClients(oldInbound)
-		if err != nil {
-			return err
-		}
-		if err := s.SyncInbound(tx, inboundId, finalClients); err != nil {
-			return err
-		}
-		if oldInbound.NodeID != nil {
-			return (&NodeService{}).MarkNodeDirtyTx(tx, *oldInbound.NodeID)
-		}
-		return nil
-	})
-	if txErr != nil {
-		for email := range foundEmails {
-			if _, skip := res.perEmailSkipped[email]; !skip {
-				res.perEmailSkipped[email] = txErr.Error()
-			}
-		}
-	}
-
 	return res
 	return res
 }
 }
 
 

+ 42 - 0
internal/web/service/client_group_bulk_test.go

@@ -0,0 +1,42 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestAddToGroupReportsOnlyChangedRecordsIncludingNull(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+	if err := db.Create(&model.ClientGroup{Name: "paid"}).Error; err != nil {
+		t.Fatalf("create group: %v", err)
+	}
+	rows := []model.ClientRecord{
+		{Email: "same@example", UUID: "same", Group: "paid"},
+		{Email: "other@example", UUID: "other", Group: "free"},
+		{Email: "null@example", UUID: "null"},
+	}
+	if err := db.Create(&rows).Error; err != nil {
+		t.Fatalf("create clients: %v", err)
+	}
+	if err := db.Model(&model.ClientRecord{}).Where("email = ?", "null@example").UpdateColumn("group_name", nil).Error; err != nil {
+		t.Fatalf("set NULL group: %v", err)
+	}
+
+	got, err := (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example", "missing@example"}, "paid")
+	if err != nil {
+		t.Fatalf("AddToGroup: %v", err)
+	}
+	if got != 2 {
+		t.Fatalf("affected = %d, want 2 changed records", got)
+	}
+	got, err = (&ClientService{}).AddToGroup([]string{"same@example", "other@example", "null@example"}, "paid")
+	if err != nil {
+		t.Fatalf("second AddToGroup: %v", err)
+	}
+	if got != 0 {
+		t.Fatalf("second affected = %d, want 0", got)
+	}
+}

+ 11 - 5
internal/web/service/client_groups.go

@@ -234,7 +234,9 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	var records []model.ClientRecord
 	var records []model.ClientRecord
 	for _, batch := range chunkStrings(emails, sqlInChunk) {
 	for _, batch := range chunkStrings(emails, sqlInChunk) {
 		var rows []model.ClientRecord
 		var rows []model.ClientRecord
-		if err := db.Where("email IN ?", batch).Find(&rows).Error; err != nil {
+		if err := db.Where("email IN ?", batch).
+			Where("group_name IS NULL OR group_name <> ?", group).
+			Find(&rows).Error; err != nil {
 			return 0, err
 			return 0, err
 		}
 		}
 		records = append(records, rows...)
 		records = append(records, rows...)
@@ -248,13 +250,17 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	}
 	}
 
 
 	tx := db.Begin()
 	tx := db.Begin()
+	var affected int64
 	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
 	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
-		if err := tx.Model(&model.ClientRecord{}).
+		result := tx.Model(&model.ClientRecord{}).
 			Where("email IN ?", batch).
 			Where("email IN ?", batch).
-			UpdateColumn("group_name", group).Error; err != nil {
+			Where("group_name IS NULL OR group_name <> ?", group).
+			UpdateColumn("group_name", group)
+		if result.Error != nil {
 			tx.Rollback()
 			tx.Rollback()
-			return 0, err
+			return 0, result.Error
 		}
 		}
+		affected += result.RowsAffected
 	}
 	}
 
 
 	var inboundIDs []int
 	var inboundIDs []int
@@ -331,7 +337,7 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	if err := tx.Commit().Error; err != nil {
 	if err := tx.Commit().Error; err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
-	return len(records), nil
+	return int(affected), nil
 }
 }
 
 
 func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {
 func (s *ClientService) replaceGroupValue(oldName, newName string) (int, error) {

+ 5 - 5
internal/web/service/global_traffic_test.go

@@ -75,7 +75,7 @@ func TestDepletedCond_ProbeGuard(t *testing.T) {
 		t.Fatalf("empty globals must use the local-only predicate")
 		t.Fatalf("empty globals must use the local-only predicate")
 	}
 	}
 	seedClientRow(t, "local-cap", 1, 600, 600, 1000)
 	seedClientRow(t, "local-cap", 1, 600, 600, 1000)
-	if _, count, err := svc.disableInvalidClients(db); err != nil {
+	if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("disableInvalidClients: %v", err)
 		t.Fatalf("disableInvalidClients: %v", err)
 	} else if count != 1 {
 	} else if count != 1 {
 		t.Fatalf("local over-quota client must be disabled, disabled %d", count)
 		t.Fatalf("local over-quota client must be disabled, disabled %d", count)
@@ -115,7 +115,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
 		if got, _ := depletedCond(db); got != depletedClientsCondLocal {
 		if got, _ := depletedCond(db); got != depletedClientsCondLocal {
 			t.Fatalf("only stale globals must fall back to the local-only predicate")
 			t.Fatalf("only stale globals must fall back to the local-only predicate")
 		}
 		}
-		if _, count, err := svc.disableInvalidClients(db); err != nil {
+		if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 			t.Fatalf("disableInvalidClients: %v", err)
 			t.Fatalf("disableInvalidClients: %v", err)
 		} else if count != 0 {
 		} else if count != 0 {
 			t.Fatalf("stale global usage must not disable a client, disabled %d", count)
 			t.Fatalf("stale global usage must not disable a client, disabled %d", count)
@@ -140,7 +140,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
 		if got, _ := depletedCond(db); got != depletedClientsCond {
 		if got, _ := depletedCond(db); got != depletedClientsCond {
 			t.Fatalf("a fresh global row must select the cross-panel predicate")
 			t.Fatalf("a fresh global row must select the cross-panel predicate")
 		}
 		}
-		if _, count, err := svc.disableInvalidClients(db); err != nil {
+		if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 			t.Fatalf("disableInvalidClients: %v", err)
 			t.Fatalf("disableInvalidClients: %v", err)
 		} else if count != 0 {
 		} else if count != 0 {
 			t.Fatalf("the live master reports usage well under quota, disabled %d", count)
 			t.Fatalf("the live master reports usage well under quota, disabled %d", count)
@@ -149,7 +149,7 @@ func TestStaleGlobalTraffic_Ignored(t *testing.T) {
 		if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
 		if err := svc.AcceptGlobalTraffic("live-master", []*xray.ClientTraffic{{Email: "cap", Up: 600, Down: 500}}); err != nil {
 			t.Fatalf("AcceptGlobalTraffic: %v", err)
 			t.Fatalf("AcceptGlobalTraffic: %v", err)
 		}
 		}
-		if _, count, err := svc.disableInvalidClients(db); err != nil {
+		if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 			t.Fatalf("disableInvalidClients: %v", err)
 			t.Fatalf("disableInvalidClients: %v", err)
 		} else if count != 1 {
 		} else if count != 1 {
 			t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
 			t.Fatalf("fresh cross-panel depletion must disable the client, disabled %d", count)
@@ -167,7 +167,7 @@ func TestGlobalUsage_DisablesClient(t *testing.T) {
 		t.Fatalf("AcceptGlobalTraffic: %v", err)
 		t.Fatalf("AcceptGlobalTraffic: %v", err)
 	}
 	}
 
 
-	if _, count, err := svc.disableInvalidClients(db); err != nil {
+	if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("disableInvalidClients: %v", err)
 		t.Fatalf("disableInvalidClients: %v", err)
 	} else if count != 1 {
 	} else if count != 1 {
 		t.Fatalf("expected 1 client disabled, got %d", count)
 		t.Fatalf("expected 1 client disabled, got %d", count)

+ 0 - 1
internal/web/service/inbound.go

@@ -29,7 +29,6 @@ import (
 )
 )
 
 
 type InboundService struct {
 type InboundService struct {
-	xrayApi         xray.XrayAPI
 	clientService   ClientService
 	clientService   ClientService
 	fallbackService FallbackService
 	fallbackService FallbackService
 }
 }

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

@@ -77,7 +77,7 @@ func TestAutoRenewShadowsocksKeepsSettingsClean(t *testing.T) {
 		t.Fatalf("seed client_traffics: %v", err)
 		t.Fatalf("seed client_traffics: %v", err)
 	}
 	}
 
 
-	if _, count, err := svc.autoRenewClients(db); err != nil {
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("autoRenewClients: %v", err)
 		t.Fatalf("autoRenewClients: %v", err)
 	} else if count != 1 {
 	} else if count != 1 {
 		t.Fatalf("renewed count = %d, want 1", count)
 		t.Fatalf("renewed count = %d, want 1", count)

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

@@ -52,7 +52,7 @@ func TestAutoRenewClients_MultiInbound(t *testing.T) {
 		t.Fatalf("seed client_traffics: %v", err)
 		t.Fatalf("seed client_traffics: %v", err)
 	}
 	}
 
 
-	if _, count, err := svc.autoRenewClients(db); err != nil {
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("autoRenewClients: %v", err)
 		t.Fatalf("autoRenewClients: %v", err)
 	} else if count != 3 {
 	} else if count != 3 {
 		t.Fatalf("renewed count = %d, want 3", count)
 		t.Fatalf("renewed count = %d, want 3", count)

+ 46 - 97
internal/web/service/inbound_disable.go

@@ -1,44 +1,27 @@
 package service
 package service
 
 
 import (
 import (
-	"context"
 	"encoding/json"
 	"encoding/json"
-	"fmt"
 	"slices"
 	"slices"
-	"strings"
 	"time"
 	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 
 
 	"gorm.io/gorm"
 	"gorm.io/gorm"
 )
 )
 
 
-func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error) {
+func (s *InboundService) disableInvalidInbounds(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) {
 	now := time.Now().Unix() * 1000
 	now := time.Now().Unix() * 1000
-	needRestart := false
-
-	if process := currentXrayProcess(); process != nil {
-		var tags []string
-		err := tx.Table("inbounds").
-			Select("inbounds.tag").
-			Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
-			Scan(&tags).Error
-		if err != nil {
-			return false, 0, err
-		}
-		_ = s.xrayApi.Init(process.GetAPIPort())
-		for _, tag := range tags {
-			err1 := s.xrayApi.DelInbound(tag)
-			if err1 == nil {
-				logger.Debug("Inbound disabled by api:", tag)
-			} else {
-				logger.Debug("Error in disabling inbound by api:", err1)
-				needRestart = true
-			}
-		}
-		s.xrayApi.Close()
+	var inbounds []model.Inbound
+	if err := tx.Where("((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?)) and enable = ? and node_id IS NULL", now, true).
+		Find(&inbounds).Error; err != nil {
+		return false, 0, err
+	}
+	for i := range inbounds {
+		mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
+			action: trafficDisableInbound, inbound: inbounds[i],
+		})
 	}
 	}
 
 
 	result := tx.Model(model.Inbound{}).
 	result := tx.Model(model.Inbound{}).
@@ -46,7 +29,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 		Update("enable", false)
 		Update("enable", false)
 	err := result.Error
 	err := result.Error
 	count := result.RowsAffected
 	count := result.RowsAffected
-	return needRestart, count, err
+	return false, count, err
 }
 }
 
 
 const globalTrafficFreshWindow = 24 * time.Hour
 const globalTrafficFreshWindow = 24 * time.Hour
@@ -94,8 +77,8 @@ func depletedCond(tx *gorm.DB) (string, []any) {
 	return depletedClientsCondLocal, []any{now}
 	return depletedClientsCondLocal, []any{now}
 }
 }
 
 
-func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error) {
-	needRestart := false
+func (s *InboundService) disableInvalidClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, []int, error) {
+	now := time.Now().UnixMilli()
 	cond, condArgs := depletedCond(tx)
 	cond, condArgs := depletedCond(tx)
 
 
 	var depletedRows []xray.ClientTraffic
 	var depletedRows []xray.ClientTraffic
@@ -103,10 +86,10 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 		Where(cond+" AND enable = ?", append(condArgs, true)...).
 		Where(cond+" AND enable = ?", append(condArgs, true)...).
 		Find(&depletedRows).Error
 		Find(&depletedRows).Error
 	if err != nil {
 	if err != nil {
-		return false, 0, err
+		return false, 0, nil, err
 	}
 	}
 	if len(depletedRows) == 0 {
 	if len(depletedRows) == 0 {
-		return false, 0, nil
+		return false, 0, nil, nil
 	}
 	}
 
 
 	depletedEmails := make([]string, 0, len(depletedRows))
 	depletedEmails := make([]string, 0, len(depletedRows))
@@ -134,47 +117,39 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 			WHERE clients.email IN ?
 			WHERE clients.email IN ?
 		`, depletedEmails).Scan(&targets).Error
 		`, depletedEmails).Scan(&targets).Error
 		if err != nil {
 		if err != nil {
-			return false, 0, err
+			return false, 0, nil, err
 		}
 		}
 	}
 	}
 
 
-	var localTargets []target
-	localByInbound := make(map[int]map[string]struct{})
-	remoteByInbound := make(map[int][]target)
+	byInbound := make(map[int][]target)
 	for _, t := range targets {
 	for _, t := range targets {
-		if t.NodeID == nil {
-			localTargets = append(localTargets, t)
-			if localByInbound[t.InboundID] == nil {
-				localByInbound[t.InboundID] = make(map[string]struct{})
-			}
-			localByInbound[t.InboundID][t.Email] = struct{}{}
-		} else {
-			remoteByInbound[t.InboundID] = append(remoteByInbound[t.InboundID], t)
-		}
+		byInbound[t.InboundID] = append(byInbound[t.InboundID], t)
 	}
 	}
 
 
-	if process := currentXrayProcess(); process != nil && len(localTargets) > 0 {
-		_ = s.xrayApi.Init(process.GetAPIPort())
-		for _, t := range localTargets {
-			err1 := s.xrayApi.RemoveUser(t.Tag, t.Email)
-			if err1 == nil {
-				logger.Debug("Client disabled by api:", t.Email)
-			} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", t.Email)) {
-				logger.Debug("User is already disabled. Nothing to do more...")
-			} else {
-				logger.Debug("Error in disabling client by api:", err1)
-				needRestart = true
-			}
+	disabledNodeIDs := make(map[int]struct{})
+	for inboundID, group := range byInbound {
+		emails := make(map[string]struct{}, len(group))
+		for _, t := range group {
+			emails[t.Email] = struct{}{}
 		}
 		}
-		s.xrayApi.Close()
-	}
-
-	for inboundID, emails := range localByInbound {
-		if _, _, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails); mErr != nil {
-			logger.Warning("disableInvalidClients: settings.JSON sync failed for inbound", inboundID, ":", mErr)
+		oldInbound, inbound, mErr := s.markClientsDisabledInSettings(tx, inboundID, emails)
+		if mErr != nil {
+			return false, 0, nil, mErr
+		}
+		if inbound.NodeID != nil {
+			mutationBatch.remotePlans = append(mutationBatch.remotePlans, trafficInboundUpdatePlan{
+				oldInbound: *oldInbound, newInbound: *inbound,
+			})
+			mutationBatch.addNode(*inbound.NodeID)
+			disabledNodeIDs[*inbound.NodeID] = struct{}{}
+			continue
+		}
+		for email := range emails {
+			mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
+				action: trafficRemoveUser, inbound: *inbound, email: email,
+			})
 		}
 		}
 	}
 	}
-
 	// Flip the rows already collected above by primary key instead of
 	// Flip the rows already collected above by primary key instead of
 	// re-evaluating the depleted predicate, which was a second full scan of
 	// re-evaluating the depleted predicate, which was a second full scan of
 	// client_traffics on every poll. Sorted ids keep the lock order stable.
 	// client_traffics on every poll. Sorted ids keep the lock order stable.
@@ -189,7 +164,7 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 			Where("id IN ? AND enable = ?", batch, true).
 			Where("id IN ? AND enable = ?", batch, true).
 			Update("enable", false)
 			Update("enable", false)
 		if result.Error != nil {
 		if result.Error != nil {
-			return needRestart, count, result.Error
+			return false, count, nil, result.Error
 		}
 		}
 		count += result.RowsAffected
 		count += result.RowsAffected
 	}
 	}
@@ -197,23 +172,17 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 	if len(depletedEmails) > 0 {
 	if len(depletedEmails) > 0 {
 		if err := tx.Model(&model.ClientRecord{}).
 		if err := tx.Model(&model.ClientRecord{}).
 			Where("email IN ?", depletedEmails).
 			Where("email IN ?", depletedEmails).
-			Updates(map[string]any{"enable": false, "updated_at": time.Now().UnixMilli()}).Error; err != nil {
-			logger.Warning("disableInvalidClients update clients.enable:", err)
+			Updates(map[string]any{"enable": false, "updated_at": now}).Error; err != nil {
+			return false, count, nil, err
 		}
 		}
 	}
 	}
 
 
-	for inboundID, group := range remoteByInbound {
-		emails := make(map[string]struct{}, len(group))
-		for _, t := range group {
-			emails[t.Email] = struct{}{}
-		}
-		if pushErr := s.disableRemoteClients(tx, inboundID, emails); pushErr != nil {
-			logger.Warning("disableInvalidClients: push to remote failed for inbound", inboundID, ":", pushErr)
-			needRestart = true
-		}
+	nodeIDs := make([]int, 0, len(disabledNodeIDs))
+	for nodeID := range disabledNodeIDs {
+		nodeIDs = append(nodeIDs, nodeID)
 	}
 	}
 
 
-	return needRestart, count, nil
+	return false, count, nodeIDs, nil
 }
 }
 
 
 // markClientsDisabledInSettings flips client.enable=false in the inbound's
 // markClientsDisabledInSettings flips client.enable=false in the inbound's
@@ -265,23 +234,3 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
 	}
 	}
 	return &snapshot, &ib, nil
 	return &snapshot, &ib, nil
 }
 }
-
-// disableRemoteClients flips the clients off in the inbound's stored settings
-// and pushes the updated inbound to its node, which applies it to its own
-// running Xray. That push is the whole reconcile — restarting the node's Xray
-// afterwards would drop every live connection on the node for nothing (#5740).
-func (s *InboundService) disableRemoteClients(tx *gorm.DB, inboundID int, emails map[string]struct{}) error {
-	oldSnapshot, ib, err := s.markClientsDisabledInSettings(tx, inboundID, emails)
-	if err != nil {
-		return err
-	}
-
-	rt, err := s.runtimeFor(ib)
-	if err != nil {
-		return err
-	}
-	if err := rt.UpdateInbound(context.Background(), oldSnapshot, ib); err != nil {
-		return err
-	}
-	return nil
-}

+ 43 - 23
internal/web/service/inbound_migration.go

@@ -33,13 +33,15 @@ func (s *InboundService) MigrationRemoveOrphanedTraffics() {
 	}
 	}
 }
 }
 
 
-func (s *InboundService) MigrationRequirements() {
+func (s *InboundService) MigrationRequirements() (err error) {
 	db := database.GetDB()
 	db := database.GetDB()
 	tx := db.Begin()
 	tx := db.Begin()
-	var err error
 	defer func() {
 	defer func() {
 		if err == nil {
 		if err == nil {
-			tx.Commit()
+			if commitErr := tx.Commit().Error; commitErr != nil {
+				err = commitErr
+				return
+			}
 			if !database.IsPostgres() {
 			if !database.IsPostgres() {
 				if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
 				if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
 					logger.Warningf("VACUUM failed: %v", dbErr)
 					logger.Warningf("VACUUM failed: %v", dbErr)
@@ -76,8 +78,8 @@ func (s *InboundService) MigrationRequirements() {
 	// SQLite (no PG :: casts).
 	// SQLite (no PG :: casts).
 	if database.IsPostgres() {
 	if database.IsPostgres() {
 		// Use DO block so it is idempotent and doesn't fail if already boolean.
 		// Use DO block so it is idempotent and doesn't fail if already boolean.
-		normalizeBool := func(table, col string) {
-			tx.Exec(fmt.Sprintf(`
+		normalizeBool := func(table, col string) error {
+			return tx.Exec(fmt.Sprintf(`
 				DO $$
 				DO $$
 				BEGIN
 				BEGIN
 					IF EXISTS (
 					IF EXISTS (
@@ -88,14 +90,13 @@ func (s *InboundService) MigrationRequirements() {
 						ALTER TABLE %s ALTER COLUMN %s
 						ALTER TABLE %s ALTER COLUMN %s
 							TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
 							TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
 					END IF;
 					END IF;
-				END $$;`, table, col, table, col, col))
+				END $$;`, table, col, table, col, col)).Error
+		}
+		for _, column := range [][2]string{{"inbounds", "enable"}, {"client_traffics", "enable"}, {"nodes", "enable"}, {"clients", "enable"}, {"api_tokens", "enabled"}, {"outbound_subscriptions", "enabled"}} {
+			if err = normalizeBool(column[0], column[1]); err != nil {
+				return
+			}
 		}
 		}
-		normalizeBool("inbounds", "enable")
-		normalizeBool("client_traffics", "enable")
-		normalizeBool("nodes", "enable")
-		normalizeBool("clients", "enable")
-		normalizeBool("api_tokens", "enabled")
-		normalizeBool("outbound_subscriptions", "enabled")
 	}
 	}
 
 
 	// Fix inbounds based problems
 	// Fix inbounds based problems
@@ -160,7 +161,8 @@ func (s *InboundService) MigrationRequirements() {
 				delete(settings, "testseed")
 				delete(settings, "testseed")
 			}
 			}
 
 
-			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+			var modifiedSettings []byte
+			modifiedSettings, err = json.MarshalIndent(settings, "", "  ")
 			if err != nil {
 			if err != nil {
 				return
 				return
 			}
 			}
@@ -169,30 +171,39 @@ func (s *InboundService) MigrationRequirements() {
 		}
 		}
 
 
 		// Add client traffic row for all clients which has email
 		// Add client traffic row for all clients which has email
-		modelClients, err := s.GetClients(inbounds[inbound_index])
+		var modelClients []model.Client
+		modelClients, err = s.GetClients(inbounds[inbound_index])
 		if err != nil {
 		if err != nil {
 			return
 			return
 		}
 		}
 		for _, modelClient := range modelClients {
 		for _, modelClient := range modelClients {
 			if len(modelClient.Email) > 0 {
 			if len(modelClient.Email) > 0 {
 				var count int64
 				var count int64
-				tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count)
+				if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count).Error; err != nil {
+					return
+				}
 				if count == 0 {
 				if count == 0 {
-					_ = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient)
+					if err = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient); err != nil {
+						return
+					}
 				}
 				}
 			}
 			}
 		}
 		}
 
 
 		// Heal clients table for installs where the one-shot seeder
 		// Heal clients table for installs where the one-shot seeder
 		// skipped clients due to a tgId-string unmarshal error.
 		// skipped clients due to a tgId-string unmarshal error.
-		if syncErr := s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); syncErr != nil {
-			logger.Warning("MigrationRequirements sync clients failed:", syncErr)
+		if err = s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); err != nil {
+			return
 		}
 		}
 	}
 	}
-	tx.Save(inbounds)
+	if err = tx.Save(inbounds).Error; err != nil {
+		return
+	}
 
 
 	// Remove orphaned traffics
 	// Remove orphaned traffics
-	tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{})
+	if err = tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}).Error; err != nil {
+		return
+	}
 
 
 	// Migrate old MultiDomain to External Proxy
 	// Migrate old MultiDomain to External Proxy
 	var externalProxy []struct {
 	var externalProxy []struct {
@@ -238,8 +249,14 @@ func (s *InboundService) MigrationRequirements() {
 			}
 			}
 		}
 		}
 		stream["externalProxy"] = reverses
 		stream["externalProxy"] = reverses
-		newStream, _ := json.MarshalIndent(stream, " ", "  ")
-		tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
+		newStream, marshalErr := json.MarshalIndent(stream, " ", "  ")
+		if marshalErr != nil {
+			err = marshalErr
+			return
+		}
+		if err = tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream).Error; err != nil {
+			return
+		}
 	}
 	}
 
 
 	// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
 	// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
@@ -256,10 +273,13 @@ func (s *InboundService) MigrationRequirements() {
 	if err != nil {
 	if err != nil {
 		return
 		return
 	}
 	}
+	return err
 }
 }
 
 
 func (s *InboundService) MigrateDB() {
 func (s *InboundService) MigrateDB() {
-	s.MigrationRequirements()
+	if err := s.MigrationRequirements(); err != nil {
+		logger.Errorf("MigrationRequirements failed: %v", err)
+	}
 	s.MigrationRemoveOrphanedTraffics()
 	s.MigrationRemoveOrphanedTraffics()
 	s.MigrationRestoreVisionFlow()
 	s.MigrationRestoreVisionFlow()
 }
 }

+ 38 - 0
internal/web/service/inbound_migration_test.go

@@ -1,10 +1,13 @@
 package service
 package service
 
 
 import (
 import (
+	"errors"
 	"path/filepath"
 	"path/filepath"
 	"strings"
 	"strings"
 	"testing"
 	"testing"
 
 
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
 	"github.com/mhsanaei/3x-ui/v3/internal/xray"
@@ -90,6 +93,41 @@ func TestMigrationRequirements_BackfillsClientTrafficsWithMultiDomainInbound(t *
 	}
 	}
 }
 }
 
 
+func TestMigrationRequirementsReturnsAddClientStatFailure(t *testing.T) {
+	dbDir := t.TempDir()
+	t.Setenv("XUI_DB_FOLDER", dbDir)
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+	first := &model.Inbound{UserId: 1, Tag: "first", Port: 31001, Protocol: model.VLESS, Settings: `{"clients":[{"email":"[email protected]","id":"id-1"}]}`, StreamSettings: `{}`}
+	if err := db.Create(first).Error; err != nil {
+		t.Fatalf("create first: %v", err)
+	}
+	const injected = "injected AddClientStat failure"
+	failSave := func(tx *gorm.DB) {
+		tx.AddError(errors.New(injected))
+	}
+	if err := db.Callback().Update().Before("gorm:update").Register("test:fail-migration-inbound-save", failSave); err != nil {
+		t.Fatalf("register update callback: %v", err)
+	}
+	if err := db.Callback().Create().Before("gorm:create").Register("test:fail-migration-inbound-save", failSave); err != nil {
+		t.Fatalf("register create callback: %v", err)
+	}
+	err := (&InboundService{}).MigrationRequirements()
+	if err == nil || err.Error() != injected {
+		t.Fatalf("MigrationRequirements error = %v, want %q", err, injected)
+	}
+	var count int64
+	if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", "[email protected]").Count(&count).Error; err != nil {
+		t.Fatalf("count rolled-back traffic: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("earlier traffic write committed after save failure: count=%d", count)
+	}
+}
+
 // TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
 // TestMigrationRequirements_CleansLegacyZeroAddrTag guards the legacy tag cleanup that
 // strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
 // strips the auto-generated "0.0.0.0:" prefix. The inbound is MultiDomain TLS so the
 // externalProxy detection query returns rows and the cleanup is reached (it early-returns
 // externalProxy detection query returns rows and the cleanup is reached (it early-returns

+ 77 - 3
internal/web/service/inbound_node.go

@@ -96,13 +96,15 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 	if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
 	if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
 		return err
 		return err
 	}
 	}
-	remoteTags, err := rt.ListRemoteTags(ctx)
+	remoteInbounds, err := rt.ListInboundOptions(ctx)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
+	remoteTags := make([]string, 0, len(remoteInbounds))
 	remoteTagSet := make(map[string]struct{}, len(remoteTags))
 	remoteTagSet := make(map[string]struct{}, len(remoteTags))
-	for _, tag := range remoteTags {
-		remoteTagSet[tag] = struct{}{}
+	for _, remoteIb := range remoteInbounds {
+		remoteTags = append(remoteTags, remoteIb.Tag)
+		remoteTagSet[remoteIb.Tag] = struct{}{}
 	}
 	}
 	prefix := nodeTagPrefix(&nodeID)
 	prefix := nodeTagPrefix(&nodeID)
 	desiredTags := make(map[string]struct{}, len(inbounds)*2)
 	desiredTags := make(map[string]struct{}, len(inbounds)*2)
@@ -129,6 +131,33 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 		if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
 		if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil {
 			runtimeIb = built
 			runtimeIb = built
 		}
 		}
+		if !existsOnNode && n.Guid != "" && ib.OriginNodeGuid == n.Guid {
+			var compatible []runtime.RemoteInboundOption
+			for _, remoteIb := range remoteInbounds {
+				if remoteIb.Port == runtimeIb.Port &&
+					remoteIb.Protocol == runtimeIb.Protocol &&
+					strings.TrimSpace(remoteIb.Listen) == strings.TrimSpace(runtimeIb.Listen) {
+					compatible = append(compatible, remoteIb)
+				}
+			}
+			switch len(compatible) {
+			case 1:
+				alias := compatible[0]
+				desiredTags[alias.Tag] = struct{}{}
+				rt.AdoptInboundAlias(runtimeIb, alias)
+				existsOnNode = true
+				logger.Infof("adopted compatible inbound %q on node %s as %q", alias.Tag, n.Name, ib.Tag)
+			case 0:
+				// No compatible occupant: keep the normal create path, which
+				// leaves a real port/protocol drift loud.
+			default:
+				for _, candidate := range compatible {
+					desiredTags[candidate.Tag] = struct{}{}
+				}
+				errs = append(errs, fmt.Errorf("reconcile inbound %q: ambiguous compatible remote inbounds", ib.Tag))
+				continue
+			}
+		}
 		if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
 		if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 		}
@@ -514,6 +543,29 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 		}
 		}
 
 
 		c, ok := tagToCentral[snapIb.Tag]
 		c, ok := tagToCentral[snapIb.Tag]
+		if !ok {
+			origin := originGuidFor(snapIb)
+			var compatible []*model.Inbound
+			for i := range central {
+				candidate := &central[i]
+				if candidate.OriginNodeGuid == origin &&
+					candidate.Port == snapIb.Port &&
+					candidate.Protocol == snapIb.Protocol &&
+					strings.TrimSpace(candidate.Listen) == strings.TrimSpace(snapIb.Listen) {
+					compatible = append(compatible, candidate)
+				}
+			}
+			switch len(compatible) {
+			case 1:
+				c, ok = compatible[0], true
+				tagToCentral[snapIb.Tag] = c
+				snapTags[c.Tag] = struct{}{}
+			case 0:
+				// A genuinely new inbound follows the normal adoption path.
+			default:
+				return false, fmt.Errorf("setRemoteTraffic: inbound %q has ambiguous compatible central aliases", snapIb.Tag)
+			}
+		}
 		if !ok {
 		if !ok {
 			if dirty {
 			if dirty {
 				continue
 				continue
@@ -1079,6 +1131,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	return structuralChange, nil
 	return structuralChange, nil
 }
 }
 
 
+func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) {
+	restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable()
+	if err != nil {
+		logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err)
+		return
+	}
+	if !restartOnDisable {
+		return
+	}
+	for _, nodeID := range nodeIDs {
+		nodeIDCopy := nodeID
+		rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy)
+		if rtErr != nil {
+			logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr)
+			continue
+		}
+		if rtErr = rt.RestartXray(context.Background()); rtErr != nil {
+			logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr)
+		}
+	}
+}
+
 func (s *InboundService) GetOnlineClients() []string {
 func (s *InboundService) GetOnlineClients() []string {
 	process := currentXrayProcess()
 	process := currentXrayProcess()
 	if process == nil {
 	if process == nil {

+ 101 - 0
internal/web/service/inbound_node_reconcile_test.go

@@ -251,6 +251,107 @@ func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) {
+	setupConflictDB(t)
+
+	var mu sync.Mutex
+	mutations := 0
+	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": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}})
+	})
+	mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) {
+		mu.Lock()
+		mutations++
+		mu.Unlock()
+		writeOK(w, nil)
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+
+	node := reconcileTestNode(t, ts, "adopt-node", "all", nil)
+	node.Guid = "origin-guid"
+	if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
+		t.Fatalf("update node guid: %v", err)
+	}
+	seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
+		t.Fatalf("set origin guid: %v", err)
+	}
+
+	svc := InboundService{}
+	rt := runtime.NewRemote(node, nil)
+	if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
+		t.Fatalf("first ReconcileNode: %v", err)
+	}
+	if err := svc.ReconcileNode(context.Background(), rt, node); err != nil {
+		t.Fatalf("second ReconcileNode: %v", err)
+	}
+	mu.Lock()
+	got := mutations
+	mu.Unlock()
+	if got != 0 {
+		t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got)
+	}
+}
+
+func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) {
+	setupConflictDB(t)
+
+	ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52})
+	node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil)
+	node.Guid = "origin-guid"
+	if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil {
+		t.Fatalf("update node guid: %v", err)
+	}
+	seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
+		t.Fatalf("set origin guid: %v", err)
+	}
+
+	err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
+	if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") {
+		t.Fatalf("ReconcileNode error = %v, want ambiguity error", err)
+	}
+	if got := deletedIDs(); len(got) != 0 {
+		t.Fatalf("deleted ambiguous candidates = %v, want none", got)
+	}
+}
+
+func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) {
+	setupConflictDB(t)
+
+	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": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}})
+	})
+	mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) {
+		_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil})
+	})
+	ts := httptest.NewServer(mux)
+	t.Cleanup(ts.Close)
+
+	node := reconcileTestNode(t, ts, "drift-node", "all", nil)
+	node.Guid = "origin-guid"
+	seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id)
+	if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil {
+		t.Fatalf("set origin guid: %v", err)
+	}
+
+	err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node)
+	if err == nil || !strings.Contains(err.Error(), "port already occupied") {
+		t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err)
+	}
+}
+
 func TestEnsureInboundTagAllowed(t *testing.T) {
 func TestEnsureInboundTagAllowed(t *testing.T) {
 	setupConflictDB(t)
 	setupConflictDB(t)
 	db := database.GetDB()
 	db := database.GetDB()

+ 255 - 206
internal/web/service/inbound_traffic.go

@@ -22,60 +22,77 @@ import (
 )
 )
 
 
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
 func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) {
+	var disabledNodeIDs []int
 	err = submitTrafficWrite(func() error {
 	err = submitTrafficWrite(func() error {
 		var inner error
 		var inner error
-		needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
+		needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
 		return inner
 		return inner
 	})
 	})
+	if err == nil && len(disabledNodeIDs) > 0 {
+		s.restartRemoteNodesOnDisable(disabledNodeIDs)
+	}
 	return
 	return
 }
 }
 
 
-func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, error) {
-	var err error
+func (s *InboundService) addTrafficLocked(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (bool, bool, []int, error) {
 	db := database.GetDB()
 	db := database.GetDB()
-	tx := db.Begin()
-
-	defer func() {
-		if err != nil {
-			if rbErr := tx.Rollback().Error; rbErr != nil {
-				logger.Warning("Error rolling back traffic tx:", rbErr)
-			}
-		} else if cErr := tx.Commit().Error; cErr != nil {
-			logger.Warning("Error committing traffic tx:", cErr)
+	// Commit durable traffic before best-effort lifecycle maintenance so helper
+	// failures cannot discard usage already reported by Xray.
+	if err := db.Transaction(func(tx *gorm.DB) error {
+		if err := s.addInboundTraffic(tx, inboundTraffics); err != nil {
+			return err
 		}
 		}
-	}()
-	err = s.addInboundTraffic(tx, inboundTraffics)
-	if err != nil {
-		return false, false, err
-	}
-	err = s.addClientTraffic(tx, clientTraffics)
-	if err != nil {
-		return false, false, err
+		return s.addClientTraffic(tx, clientTraffics)
+	}); err != nil {
+		return false, false, nil, err
 	}
 	}
 
 
-	needRestart0, count, renewErr := s.autoRenewClients(tx)
-	if renewErr != nil {
-		logger.Warning("Error in renew clients:", renewErr)
-	} else if count > 0 {
-		logger.Debugf("%v clients renewed", count)
-	}
+	var (
+		needRestart          bool
+		clientsDisabled      bool
+		disabledNodeIDs      []int
+		disabledClientsCount int64
+	)
+	batch := newTrafficMutationBatch()
+	err := db.Transaction(func(tx *gorm.DB) error {
+		needRestart0, count, err := s.autoRenewClients(tx, batch)
+		if err != nil {
+			return fmt.Errorf("renew clients: %w", err)
+		}
+		if count > 0 {
+			logger.Debugf("%v clients renewed", count)
+		}
 
 
-	disabledClientsCount := int64(0)
-	needRestart1, count, disableClientsErr := s.disableInvalidClients(tx)
-	if disableClientsErr != nil {
-		logger.Warning("Error in disabling invalid clients:", disableClientsErr)
-	} else if count > 0 {
-		logger.Debugf("%v clients disabled", count)
-		disabledClientsCount = count
-	}
+		needRestart1, count, nodeIDs, err := s.disableInvalidClients(tx, batch)
+		if err != nil {
+			return fmt.Errorf("disable invalid clients: %w", err)
+		}
+		if count > 0 {
+			logger.Debugf("%v clients disabled", count)
+			disabledClientsCount = count
+		}
 
 
-	needRestart2, count, disableInboundsErr := s.disableInvalidInbounds(tx)
-	if disableInboundsErr != nil {
-		logger.Warning("Error in disabling invalid inbounds:", disableInboundsErr)
-	} else if count > 0 {
-		logger.Debugf("%v inbounds disabled", count)
+		needRestart2, count, err := s.disableInvalidInbounds(tx, batch)
+		if err != nil {
+			return fmt.Errorf("disable invalid inbounds: %w", err)
+		}
+		if count > 0 {
+			logger.Debugf("%v inbounds disabled", count)
+		}
+		if err := batch.markNodesTx(tx); err != nil {
+			return err
+		}
+		needRestart = needRestart0 || needRestart1 || needRestart2
+		clientsDisabled = disabledClientsCount > 0
+		disabledNodeIDs = nodeIDs
+		return nil
+	})
+	if err != nil {
+		logger.Warning("traffic lifecycle maintenance failed after traffic commit:", err)
+		return false, false, nil, nil
 	}
 	}
-	return needRestart0 || needRestart1 || needRestart2, disabledClientsCount > 0, nil
+	needRestart = needRestart || s.applyTrafficMutationBatch(batch)
+	return needRestart, clientsDisabled, disabledNodeIDs, nil
 }
 }
 
 
 func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
 func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic) error {
@@ -304,11 +321,11 @@ func apiUserFromClient(client map[string]any, cipher string) map[string]any {
 	return user
 	return user
 }
 }
 
 
-func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
+func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMutationBatch) (bool, int64, error) {
 	// check for time expired
 	// check for time expired
 	var traffics []*xray.ClientTraffic
 	var traffics []*xray.ClientTraffic
 	now := time.Now().Unix() * 1000
 	now := time.Now().Unix() * 1000
-	var err, err1 error
+	var err error
 
 
 	// Filter to clients that have at least one local inbound. Using
 	// Filter to clients that have at least one local inbound. Using
 	// client_traffics.inbound_id is wrong: it goes stale after an inbound is
 	// client_traffics.inbound_id is wrong: it goes stale after an inbound is
@@ -335,9 +352,8 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	var inbounds []*model.Inbound
 	var inbounds []*model.Inbound
 	needRestart := false
 	needRestart := false
 	var clientsToAdd []struct {
 	var clientsToAdd []struct {
-		protocol string
-		tag      string
-		client   map[string]any
+		inbound model.Inbound
+		client  map[string]any
 	}
 	}
 
 
 	// Resolve the inbounds to renew through the client_inbounds link rather than
 	// Resolve the inbounds to renew through the client_inbounds link rather than
@@ -408,13 +424,11 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 				c["enable"] = true
 				c["enable"] = true
 				clientsToAdd = append(clientsToAdd,
 				clientsToAdd = append(clientsToAdd,
 					struct {
 					struct {
-						protocol string
-						tag      string
-						client   map[string]any
+						inbound model.Inbound
+						client  map[string]any
 					}{
 					}{
-						protocol: string(inbounds[inbound_index].Protocol),
-						tag:      inbounds[inbound_index].Tag,
-						client:   apiUserFromClient(c, cipher),
+						inbound: *inbounds[inbound_index],
+						client:  apiUserFromClient(c, cipher),
 					})
 					})
 			}
 			}
 			clients[client_index] = any(c)
 			clients[client_index] = any(c)
@@ -452,18 +466,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
 	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
 		return false, 0, err
 		return false, 0, err
 	}
 	}
-	if process := currentXrayProcess(); process != nil {
-		err1 = s.xrayApi.Init(process.GetAPIPort())
-		if err1 != nil {
-			return true, int64(len(traffics)), nil
-		}
-		for _, clientToAdd := range clientsToAdd {
-			err1 = s.xrayApi.AddUser(clientToAdd.protocol, clientToAdd.tag, clientToAdd.client)
-			if err1 != nil {
-				needRestart = true
-			}
+	for _, clientToAdd := range clientsToAdd {
+		if clientToAdd.inbound.NodeID != nil {
+			mutationBatch.addNode(*clientToAdd.inbound.NodeID)
+			continue
 		}
 		}
-		s.xrayApi.Close()
+		mutationBatch.localPlans = append(mutationBatch.localPlans, trafficLocalApplyPlan{
+			action: trafficAddUser, inbound: clientToAdd.inbound, client: clientToAdd.client,
+		})
 	}
 	}
 	return needRestart, int64(len(traffics)), nil
 	return needRestart, int64(len(traffics)), nil
 }
 }
@@ -577,56 +587,58 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 }
 }
 
 
 func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) {
 func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) {
+	var resetInbound *model.Inbound
 	err = submitTrafficWrite(func() error {
 	err = submitTrafficWrite(func() error {
 		var inner error
 		var inner error
-		needRestart, inner = s.resetClientTrafficLocked(id, clientEmail)
+		needRestart, resetInbound, inner = s.resetClientTrafficLocked(id, clientEmail)
 		return inner
 		return inner
 	})
 	})
 	if err == nil {
 	if err == nil {
 		s.resetMtprotoClientQuota(clientEmail)
 		s.resetMtprotoClientQuota(clientEmail)
+		if resetInbound != nil && resetInbound.NodeID != nil {
+			if rt, rterr := s.runtimeFor(resetInbound); rterr == nil {
+				if e := rt.ResetClientTraffic(context.Background(), resetInbound, clientEmail); e != nil {
+					logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
+				}
+			} else {
+				logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
+			}
+		}
 	}
 	}
 	return
 	return
 }
 }
 
 
-func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, error) {
+func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, *model.Inbound, error) {
 	needRestart := false
 	needRestart := false
+	var reenablePlan *trafficLocalApplyPlan
+	var reenableNodeID *int
 
 
 	traffic, err := s.GetClientTrafficByEmail(clientEmail)
 	traffic, err := s.GetClientTrafficByEmail(clientEmail)
 	if err != nil {
 	if err != nil {
-		return false, err
+		return false, nil, err
 	}
 	}
 
 
 	if !traffic.Enable {
 	if !traffic.Enable {
 		inbound, err := s.GetInbound(id)
 		inbound, err := s.GetInbound(id)
 		if err != nil {
 		if err != nil {
-			return false, err
+			return false, nil, err
 		}
 		}
 		clients, err := s.GetClients(inbound)
 		clients, err := s.GetClients(inbound)
 		if err != nil {
 		if err != nil {
-			return false, err
+			return false, nil, err
 		}
 		}
 		for _, client := range clients {
 		for _, client := range clients {
 			if client.Email == clientEmail && client.Enable {
 			if client.Email == clientEmail && client.Enable {
-				rt, push, _, perr := s.nodePushPlan(inbound)
-				if perr != nil {
-					return false, perr
-				}
-				if !push {
-					if inbound.NodeID == nil {
-						needRestart = true
-					}
-					break
-				}
 				cipher := ""
 				cipher := ""
 				if string(inbound.Protocol) == "shadowsocks" {
 				if string(inbound.Protocol) == "shadowsocks" {
 					var oldSettings map[string]any
 					var oldSettings map[string]any
 					err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
 					err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
 					if err != nil {
 					if err != nil {
-						return false, err
+						return false, nil, err
 					}
 					}
 					cipher, _ = oldSettings["method"].(string)
 					cipher, _ = oldSettings["method"].(string)
 				}
 				}
-				err1 := rt.AddUser(context.Background(), inbound, map[string]any{
+				clientMap := map[string]any{
 					"email":    client.Email,
 					"email":    client.Email,
 					"id":       client.ID,
 					"id":       client.ID,
 					"auth":     client.Auth,
 					"auth":     client.Auth,
@@ -634,14 +646,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 					"flow":     client.Flow,
 					"flow":     client.Flow,
 					"password": client.Password,
 					"password": client.Password,
 					"cipher":   cipher,
 					"cipher":   cipher,
-				})
-				if err1 == nil {
-					logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
-				} else if inbound.NodeID != nil {
-					logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
+				}
+				if inbound.NodeID != nil {
+					reenableNodeID = inbound.NodeID
 				} else {
 				} else {
-					logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
-					needRestart = true
+					reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
 				}
 				}
 				break
 				break
 			}
 			}
@@ -656,7 +665,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	now := time.Now().UnixMilli()
 	now := time.Now().UnixMilli()
 	inbound, err := s.GetInbound(id)
 	inbound, err := s.GetInbound(id)
 	if err != nil {
 	if err != nil {
-		return false, err
+		return false, nil, err
 	}
 	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
 	if err := db.Transaction(func(tx *gorm.DB) error {
 		if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
 		if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
@@ -676,25 +685,30 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 			Update("last_traffic_reset_time", now).Error; err != nil {
 			Update("last_traffic_reset_time", now).Error; err != nil {
 			return err
 			return err
 		}
 		}
+		if reenableNodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
+		}
 		if inbound != nil && inbound.NodeID != nil {
 		if inbound != nil && inbound.NodeID != nil {
 			return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
 			return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
 		}
 		}
 		return nil
 		return nil
 	}); err != nil {
 	}); err != nil {
-		return false, err
+		return false, nil, err
 	}
 	}
 
 
-	if inbound != nil && inbound.NodeID != nil {
-		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
-			if e := rt.ResetClientTraffic(context.Background(), inbound, clientEmail); e != nil {
-				logger.Warning("ResetClientTraffic: remote propagation to", rt.Name(), "failed:", e)
-			}
+	if reenablePlan != nil {
+		rt, err := s.runtimeFor(&reenablePlan.inbound)
+		if err != nil {
+			needRestart = true
+		} else if err := rt.AddUser(context.Background(), &reenablePlan.inbound, reenablePlan.client); err != nil {
+			logger.Debug("Error in enabling client on", rt.Name(), ":", err)
+			needRestart = true
 		} else {
 		} else {
-			logger.Warning("ResetClientTraffic: runtime lookup failed:", rterr)
+			logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
 		}
 		}
 	}
 	}
 
 
-	return needRestart, nil
+	return needRestart, inbound, nil
 }
 }
 
 
 func (s *InboundService) ResetAllTraffics() error {
 func (s *InboundService) ResetAllTraffics() error {
@@ -740,16 +754,24 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
 }
 }
 
 
 func (s *InboundService) ResetInboundTraffic(id int) error {
 func (s *InboundService) ResetInboundTraffic(id int) error {
+	var inbound *model.Inbound
 	if err := submitTrafficWrite(func() error {
 	if err := submitTrafficWrite(func() error {
-		return database.GetDB().Model(model.Inbound{}).
+		db := database.GetDB()
+		if err := db.Model(model.Inbound{}).
 			Where("id = ?", id).
 			Where("id = ?", id).
-			Updates(map[string]any{"up": 0, "down": 0}).Error
+			Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
+			return err
+		}
+		var err error
+		inbound, err = s.GetInbound(id)
+		if err != nil {
+			return err
+		}
+		return nil
 	}); err != nil {
 	}); err != nil {
 		return err
 		return err
 	}
 	}
-
-	inbound, err := s.GetInbound(id)
-	if err == nil && inbound != nil && inbound.NodeID != nil {
+	if inbound != nil && inbound.NodeID != nil {
 		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
 		if rt, rterr := s.runtimeFor(inbound); rterr == nil {
 			if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
 			if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
 				logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
 				logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
@@ -763,134 +785,161 @@ func (s *InboundService) ResetInboundTraffic(id int) error {
 
 
 func (s *InboundService) DelDepletedClients(id int) (err error) {
 func (s *InboundService) DelDepletedClients(id int) (err error) {
 	db := database.GetDB()
 	db := database.GetDB()
-	tx := db.Begin()
-	defer func() {
-		if err == nil {
-			tx.Commit()
-		} else {
-			tx.Rollback()
+	var deletedInbounds []model.Inbound
+	err = db.Transaction(func(tx *gorm.DB) error {
+		// Collect depleted emails globally — a shared-email row owned by one
+		// inbound depletes every sibling that lists the email.
+		now := time.Now().Unix() * 1000
+		depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
+		var depletedRows []xray.ClientTraffic
+		if err := tx.Model(xray.ClientTraffic{}).
+			Where(depletedClause, now).
+			Find(&depletedRows).Error; err != nil {
+			return err
 		}
 		}
-	}()
-
-	// Collect depleted emails globally — a shared-email row owned by one
-	// inbound depletes every sibling that lists the email.
-	now := time.Now().Unix() * 1000
-	depletedClause := "reset = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))"
-	var depletedRows []xray.ClientTraffic
-	err = db.Model(xray.ClientTraffic{}).
-		Where(depletedClause, now).
-		Find(&depletedRows).Error
-	if err != nil {
-		return err
-	}
-	if len(depletedRows) == 0 {
-		return nil
-	}
-
-	depletedEmails := make(map[string]struct{}, len(depletedRows))
-	for _, r := range depletedRows {
-		if r.Email == "" {
-			continue
+		if len(depletedRows) == 0 {
+			return nil
 		}
 		}
-		depletedEmails[strings.ToLower(r.Email)] = struct{}{}
-	}
-	if len(depletedEmails) == 0 {
-		return nil
-	}
 
 
-	var inbounds []*model.Inbound
-	inboundQuery := db.Model(model.Inbound{})
-	if id >= 0 {
-		inboundQuery = inboundQuery.Where("id = ?", id)
-	}
-	if err = inboundQuery.Find(&inbounds).Error; err != nil {
-		return err
-	}
+		depletedEmails := make(map[string]struct{}, len(depletedRows))
+		for _, r := range depletedRows {
+			if r.Email == "" {
+				continue
+			}
+			depletedEmails[strings.ToLower(r.Email)] = struct{}{}
+		}
+		if len(depletedEmails) == 0 {
+			return nil
+		}
 
 
-	for _, inbound := range inbounds {
-		var settings map[string]any
-		if err = json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
-			return err
+		var inbounds []*model.Inbound
+		inboundQuery := tx.Model(model.Inbound{})
+		if id >= 0 {
+			inboundQuery = inboundQuery.Where("id = ?", id)
 		}
 		}
-		rawClients, ok := settings["clients"].([]any)
-		if !ok {
-			continue
+		if err := inboundQuery.Find(&inbounds).Error; err != nil {
+			return err
 		}
 		}
-		newClients := make([]any, 0, len(rawClients))
-		removed := 0
-		for _, client := range rawClients {
-			c, ok := client.(map[string]any)
+
+		for _, inbound := range inbounds {
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				return err
+			}
+			rawClients, ok := settings["clients"].([]any)
 			if !ok {
 			if !ok {
+				continue
+			}
+			newClients := make([]any, 0, len(rawClients))
+			removed := 0
+			for _, client := range rawClients {
+				c, ok := client.(map[string]any)
+				if !ok {
+					newClients = append(newClients, client)
+					continue
+				}
+				email, _ := c["email"].(string)
+				if _, isDepleted := depletedEmails[strings.ToLower(email)]; isDepleted {
+					removed++
+					continue
+				}
 				newClients = append(newClients, client)
 				newClients = append(newClients, client)
+			}
+			if removed == 0 {
 				continue
 				continue
 			}
 			}
-			email, _ := c["email"].(string)
-			if _, isDepleted := depletedEmails[strings.ToLower(email)]; isDepleted {
-				removed++
+			if len(newClients) == 0 {
+				deletedInbounds = append(deletedInbounds, *inbound)
+				if err := s.clientService.DetachInbound(tx, inbound.Id); err != nil {
+					return err
+				}
+				if err := tx.Where("inbound_id = ?", inbound.Id).Delete(&model.Host{}).Error; err != nil {
+					return err
+				}
+				if err := tx.Delete(model.Inbound{}, inbound.Id).Error; err != nil {
+					return err
+				}
+				if inbound.NodeID != nil {
+					if err := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); err != nil {
+						return err
+					}
+				}
 				continue
 				continue
 			}
 			}
-			newClients = append(newClients, client)
-		}
-		if removed == 0 {
-			continue
+			settings["clients"] = newClients
+			ns, mErr := json.MarshalIndent(settings, "", "  ")
+			if mErr != nil {
+				return mErr
+			}
+			inbound.Settings = string(ns)
+			if err := tx.Save(inbound).Error; err != nil {
+				return err
+			}
+			survivingClients, gcErr := s.GetClients(inbound)
+			if gcErr != nil {
+				return gcErr
+			}
+			if err := s.clientService.SyncInbound(tx, inbound.Id, survivingClients); err != nil {
+				return err
+			}
+			if inbound.NodeID != nil {
+				if err := (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID); err != nil {
+					return err
+				}
+			}
 		}
 		}
-		if len(newClients) == 0 {
-			_, _ = s.DelInbound(inbound.Id)
-			continue
+
+		// Drop now-orphaned rows. With id >= 0, a row is safe to drop only when
+		// no out-of-scope inbound still references the email.
+		if id < 0 {
+			return tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error
 		}
 		}
-		settings["clients"] = newClients
-		ns, mErr := json.MarshalIndent(settings, "", "  ")
-		if mErr != nil {
-			return mErr
+		emails := make([]string, 0, len(depletedEmails))
+		for e := range depletedEmails {
+			emails = append(emails, e)
 		}
 		}
-		inbound.Settings = string(ns)
-		if err = tx.Save(inbound).Error; err != nil {
+		var stillReferenced []string
+		emailExpr := database.JSONFieldText("client.value", "email")
+		stillQuery := fmt.Sprintf(
+			"SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?",
+			emailExpr,
+			database.JSONClientsFromInbound(),
+			emailExpr,
+		)
+		if err := tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil {
 			return err
 			return err
 		}
 		}
-		survivingClients, gcErr := s.GetClients(inbound)
-		if gcErr != nil {
-			err = gcErr
-			return err
+		stillSet := make(map[string]struct{}, len(stillReferenced))
+		for _, e := range stillReferenced {
+			stillSet[e] = struct{}{}
 		}
 		}
-		if err = s.clientService.SyncInbound(tx, inbound.Id, survivingClients); err != nil {
-			return err
+		toDelete := make([]string, 0, len(emails))
+		for _, e := range emails {
+			if _, kept := stillSet[e]; !kept {
+				toDelete = append(toDelete, e)
+			}
 		}
 		}
-	}
-
-	// Drop now-orphaned rows. With id >= 0, a row is safe to drop only when
-	// no out-of-scope inbound still references the email.
-	if id < 0 {
-		err = tx.Where(depletedClause, now).Delete(xray.ClientTraffic{}).Error
-		return err
-	}
-	emails := make([]string, 0, len(depletedEmails))
-	for e := range depletedEmails {
-		emails = append(emails, e)
-	}
-	var stillReferenced []string
-	emailExpr := database.JSONFieldText("client.value", "email")
-	stillQuery := fmt.Sprintf(
-		"SELECT DISTINCT LOWER(%s) %s WHERE LOWER(%s) IN ?",
-		emailExpr,
-		database.JSONClientsFromInbound(),
-		emailExpr,
-	)
-	if err = tx.Raw(stillQuery, emails).Scan(&stillReferenced).Error; err != nil {
+		if len(toDelete) > 0 {
+			if err := tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	if err != nil {
 		return err
 		return err
 	}
 	}
-	stillSet := make(map[string]struct{}, len(stillReferenced))
-	for _, e := range stillReferenced {
-		stillSet[e] = struct{}{}
-	}
-	toDelete := make([]string, 0, len(emails))
-	for _, e := range emails {
-		if _, kept := stillSet[e]; !kept {
-			toDelete = append(toDelete, e)
+	for i := range deletedInbounds {
+		inbound := &deletedInbounds[i]
+		if rt, rtErr := s.runtimeFor(inbound); rtErr != nil {
+			logger.Warning("DelDepletedClients: runtime lookup failed after commit:", rtErr)
+		} else if rtErr = rt.DelInbound(context.Background(), inbound); rtErr != nil && !xray.IsMissingHandlerErr(rtErr) {
+			logger.Warning("DelDepletedClients: runtime cleanup failed after commit:", rtErr)
 		}
 		}
-	}
-	if len(toDelete) > 0 {
-		if err = tx.Where("LOWER(email) IN ?", toDelete).Delete(xray.ClientTraffic{}).Error; err != nil {
-			return err
+		if inbound.Tag != "" {
+			if _, syncErr := (&XraySettingService{}).RemoveInboundTagReferences(inbound.Tag); syncErr != nil {
+				logger.Warning("DelDepletedClients: routing cleanup failed after commit:", syncErr)
+			}
 		}
 		}
 	}
 	}
 	return nil
 	return nil

+ 105 - 0
internal/web/service/inbound_traffic_apply.go

@@ -0,0 +1,105 @@
+package service
+
+import (
+	"context"
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/logger"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+)
+
+type trafficLocalApplyAction uint8
+
+const (
+	trafficAddUser trafficLocalApplyAction = iota + 1
+	trafficRemoveUser
+	trafficDisableInbound
+)
+
+type trafficLocalApplyPlan struct {
+	action  trafficLocalApplyAction
+	inbound model.Inbound
+	client  map[string]any
+	email   string
+}
+
+type trafficMutationBatch struct {
+	localPlans  []trafficLocalApplyPlan
+	remotePlans []trafficInboundUpdatePlan
+	nodeIDs     map[int]struct{}
+}
+
+type trafficInboundUpdatePlan struct{ oldInbound, newInbound model.Inbound }
+
+func newTrafficMutationBatch() *trafficMutationBatch {
+	return &trafficMutationBatch{nodeIDs: make(map[int]struct{})}
+}
+
+func (b *trafficMutationBatch) addNode(nodeID int) {
+	if nodeID > 0 {
+		b.nodeIDs[nodeID] = struct{}{}
+	}
+}
+
+func (b *trafficMutationBatch) markNodesTx(tx *gorm.DB) error {
+	if b == nil {
+		return nil
+	}
+	nodeSvc := NodeService{}
+	for nodeID := range b.nodeIDs {
+		if err := nodeSvc.MarkNodeDirtyTx(tx, nodeID); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (s *InboundService) applyTrafficMutationBatch(b *trafficMutationBatch) bool {
+	if b == nil {
+		return false
+	}
+	needRestart := false
+	for i := range b.remotePlans {
+		plan := &b.remotePlans[i]
+		rt, err := s.runtimeFor(&plan.newInbound)
+		if err == nil {
+			err = rt.UpdateInbound(context.Background(), &plan.oldInbound, &plan.newInbound)
+		}
+		if err != nil {
+			logger.Debug("traffic post-commit remote apply failed:", err)
+			needRestart = true
+		}
+	}
+	for i := range b.localPlans {
+		plan := &b.localPlans[i]
+		if plan.inbound.Protocol == model.MTProto {
+			s.applyLocalMtproto(plan.inbound.Id)
+			continue
+		}
+		rt, err := s.runtimeFor(&plan.inbound)
+		if err == nil {
+			switch plan.action {
+			case trafficAddUser:
+				err = rt.AddUser(context.Background(), &plan.inbound, plan.client)
+			case trafficRemoveUser:
+				err = rt.RemoveUser(context.Background(), &plan.inbound, plan.email)
+				if err != nil && strings.Contains(err.Error(), "not found") {
+					err = nil
+				}
+			case trafficDisableInbound:
+				err = rt.DelInbound(context.Background(), &plan.inbound)
+				if xray.IsMissingHandlerErr(err) {
+					err = nil
+				}
+			}
+		}
+		if err != nil {
+			logger.Debug("traffic post-commit runtime apply failed:", err)
+			needRestart = true
+		}
+	}
+	return needRestart
+}

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

@@ -689,6 +689,9 @@ func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) {
 		return
 		return
 	}
 	}
 	allowed := nodeSelectedTagSet(n)
 	allowed := nodeSelectedTagSet(n)
+	for _, tag := range snap.ManagedAliases {
+		allowed[tag] = struct{}{}
+	}
 	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
 	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
 	for _, inbound := range snap.Inbounds {
 	for _, inbound := range snap.Inbounds {
 		if inbound == nil {
 		if inbound == nil {

+ 86 - 0
internal/web/service/node_bulk_dispatch_test.go

@@ -2,11 +2,13 @@ package service
 
 
 import (
 import (
 	"context"
 	"context"
+	"errors"
 	"fmt"
 	"fmt"
 	"sync/atomic"
 	"sync/atomic"
 	"testing"
 	"testing"
 
 
 	"github.com/google/uuid"
 	"github.com/google/uuid"
+	"gorm.io/gorm"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
@@ -165,6 +167,90 @@ func TestNodeBulk_SmallAddPushesLive(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestNodeBulkAdjustDoesNotPushBeforeFailedCommit(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	client := model.Client{
+		ID:         uuid.NewString(),
+		Email:      "txfail-adjust@x",
+		Enable:     true,
+		ExpiryTime: 1_900_000_000_000,
+	}
+	nodeInbound(t, nodeID, 30022, []model.Client{client})
+
+	db := database.GetDB()
+	const callbackName = "bulk-adjust:fail-inbound-update"
+	if err := db.Callback().Update().After("gorm:update").Register(callbackName, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "inbounds" {
+			tx.AddError(errors.New("injected bulk-adjust transaction failure"))
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
+
+	result, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{client.Email}, 1, 0, "")
+	if err != nil {
+		t.Fatalf("BulkAdjust: %v", err)
+	}
+	if result.Adjusted != 0 || len(result.Skipped) != 1 {
+		t.Fatalf("BulkAdjust result = %+v, want one skipped client after injected failure", result)
+	}
+	if got := fake.updateUser.Load(); got != 0 {
+		t.Fatalf("failed transaction pushed %d UpdateUser call(s) to the node, want 0", got)
+	}
+}
+
+func TestNodeBulkDeleteDoesNotPushBeforeFailedCommit(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	client := model.Client{ID: uuid.NewString(), Email: "txfail-delete@x", Enable: true}
+	nodeInbound(t, nodeID, 30023, []model.Client{client})
+
+	db := database.GetDB()
+	const callbackName = "bulk-delete:fail-inbound-update"
+	if err := db.Callback().Update().After("gorm:update").Register(callbackName, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "inbounds" {
+			tx.AddError(errors.New("injected bulk-delete transaction failure"))
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Update().Remove(callbackName) })
+
+	result, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{client.Email}, true)
+	if err != nil {
+		t.Fatalf("BulkDelete: %v", err)
+	}
+	if result.Deleted != 0 || len(result.Skipped) != 1 {
+		t.Fatalf("BulkDelete result = %+v, want one skipped client after injected failure", result)
+	}
+	if got := fake.deleteClient.Load() + fake.deleteUser.Load(); got != 0 {
+		t.Fatalf("failed transaction pushed %d delete call(s) to the node, want 0", got)
+	}
+}
+
+func TestNodeBulkSmallDeleteRemovesWholeRemoteClient(t *testing.T) {
+	setupBulkDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	client := model.Client{ID: uuid.NewString(), Email: "full-delete@x", Enable: true}
+	nodeInbound(t, nodeID, 30024, []model.Client{client})
+
+	result, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{client.Email}, true)
+	if err != nil {
+		t.Fatalf("BulkDelete: %v", err)
+	}
+	if result.Deleted != 1 || len(result.Skipped) != 0 {
+		t.Fatalf("BulkDelete result = %+v, want one deleted client", result)
+	}
+	if got := fake.deleteClient.Load(); got != 1 {
+		t.Fatalf("remote DeleteClient calls = %d, want 1", got)
+	}
+	if got := fake.deleteUser.Load(); got != 0 {
+		t.Fatalf("remote DeleteUser detach calls = %d, want 0 for full deletion", got)
+	}
+}
+
 func TestNodeUpdateInboundClientNoopSkipsRuntimeAndDirty(t *testing.T) {
 func TestNodeUpdateInboundClientNoopSkipsRuntimeAndDirty(t *testing.T) {
 	setupBulkDB(t)
 	setupBulkDB(t)
 	nodeID, fake := setupNodeRuntime(t)
 	nodeID, fake := setupNodeRuntime(t)

+ 34 - 0
internal/web/service/node_tag_sync_test.go

@@ -64,3 +64,37 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
 		t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
 		t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down)
 	}
 	}
 }
 }
+
+func TestSetRemoteTraffic_AdoptsCompatibleOriginAliasWithoutDuplicate(t *testing.T) {
+	setupConflictDB(t)
+	db := database.GetDB()
+
+	const nodeID = 1
+	if err := db.Create(&model.Node{Id: nodeID, Name: "node", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-guid"}).Error; err != nil {
+		t.Fatalf("create node: %v", err)
+	}
+	id := nodeID
+	central := &model.Inbound{UserId: 1, NodeID: &id, OriginNodeGuid: "node-guid", Tag: "desired-name", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
+	if err := db.Create(central).Error; err != nil {
+		t.Fatalf("create central inbound: %v", err)
+	}
+
+	snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{
+		Tag: "already-deployed", Enable: true, Port: 8443, Protocol: model.VLESS,
+		Settings: `{"clients":[]}`, Up: 11, Down: 22,
+	}}}
+	if _, err := (&InboundService{}).setRemoteTrafficLocked(nodeID, snap, false); err != nil {
+		t.Fatalf("setRemoteTrafficLocked: %v", err)
+	}
+
+	var rows []model.Inbound
+	if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil {
+		t.Fatalf("list node inbounds: %v", err)
+	}
+	if len(rows) != 1 || rows[0].Id != central.Id || rows[0].Tag != "desired-name" {
+		t.Fatalf("alias adoption rows = %#v, want original central inbound only", rows)
+	}
+	if rows[0].Up != 11 || rows[0].Down != 22 {
+		t.Fatalf("alias traffic = %d/%d, want 11/22", rows[0].Up, rows[0].Down)
+	}
+}

+ 15 - 0
internal/web/service/node_test.go

@@ -235,3 +235,18 @@ func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) {
 		t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
 		t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept)
 	}
 	}
 }
 }
+
+func TestFilterNodeSnapshotKeepsAdoptedAlias(t *testing.T) {
+	snap := &runtime.TrafficSnapshot{
+		Inbounds:       []*model.Inbound{{Tag: "deployed-alias"}, {Tag: "unmanaged"}},
+		ManagedAliases: []string{"deployed-alias"},
+	}
+	FilterNodeSnapshot(&model.Node{
+		InboundSyncMode: "selected",
+		InboundTags:     []string{"desired-name"},
+	}, snap)
+
+	if len(snap.Inbounds) != 1 || snap.Inbounds[0].Tag != "deployed-alias" {
+		t.Fatalf("filtered snapshot = %#v, want adopted alias only", snap.Inbounds)
+	}
+}

+ 4 - 18
internal/web/service/outbound/outbound.go

@@ -22,24 +22,10 @@ import (
 type OutboundService struct{}
 type OutboundService struct{}
 
 
 func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
 func (s *OutboundService) AddTraffic(traffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (error, bool) {
-	var err error
-	db := database.GetDB()
-	tx := db.Begin()
-
-	defer func() {
-		if err != nil {
-			tx.Rollback()
-		} else {
-			tx.Commit()
-		}
-	}()
-
-	err = s.addOutboundTraffic(tx, traffics)
-	if err != nil {
-		return err, false
-	}
-
-	return nil, false
+	err := database.GetDB().Transaction(func(tx *gorm.DB) error {
+		return s.addOutboundTraffic(tx, traffics)
+	})
+	return err, false
 }
 }
 
 
 // saturatingAdd caps counters at database.TrafficMax: unlike the SQL paths,
 // saturatingAdd caps counters at database.TrafficMax: unlike the SQL paths,

+ 54 - 0
internal/web/service/outbound/outbound_commit_postgres_test.go

@@ -0,0 +1,54 @@
+package outbound
+
+import (
+	"os"
+	"strings"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+func TestAddTrafficReturnsDeferredCommitFailure(t *testing.T) {
+	if os.Getenv("XUI_DB_TYPE") != "postgres" || strings.TrimSpace(os.Getenv("XUI_DB_DSN")) == "" {
+		t.Skip("set XUI_DB_TYPE=postgres and XUI_DB_DSN to run commit-failure injection")
+	}
+	if err := database.InitDB(""); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+	db := database.GetDB()
+	const parent = "outbound_commit_parent"
+	const child = "outbound_commit_child"
+	_ = db.Exec("DROP TABLE IF EXISTS " + child).Error
+	_ = db.Exec("DROP TABLE IF EXISTS " + parent).Error
+	if err := db.Exec("CREATE TABLE " + parent + " (id bigint PRIMARY KEY)").Error; err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Exec("CREATE TABLE " + child + " (id bigint PRIMARY KEY, parent_id bigint REFERENCES " + parent + "(id) DEFERRABLE INITIALLY DEFERRED)").Error; err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() {
+		_ = db.Exec("DROP TABLE IF EXISTS " + child).Error
+		_ = db.Exec("DROP TABLE IF EXISTS " + parent).Error
+	})
+	const callback = "test:outbound-deferred-commit"
+	if err := db.Callback().Create().After("gorm:create").Register(callback, func(tx *gorm.DB) {
+		if tx.Statement == nil || tx.Statement.Table != "outbound_traffics" {
+			return
+		}
+		if result := tx.Session(&gorm.Session{NewDB: true}).Exec("INSERT INTO " + child + " (id, parent_id) VALUES (1, 999999)"); result.Error != nil {
+			tx.AddError(result.Error)
+		}
+	}); err != nil {
+		t.Fatal(err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
+
+	err, _ := (&OutboundService{}).AddTraffic([]*xray.Traffic{{Tag: "commit-test", IsOutbound: true, Up: 1}}, nil)
+	if err == nil || !strings.Contains(strings.ToLower(err.Error()), "foreign key") {
+		t.Fatalf("AddTraffic error = %v, want deferred foreign-key commit failure", err)
+	}
+}

+ 16 - 6
internal/web/service/outbound_subscription.go

@@ -156,10 +156,12 @@ func defaultPrefixNumber(subs []*model.OutboundSubscription, excludeId int) int
 // nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
 // nextDefaultSubPrefix builds the default "subN-" prefix for a new/edited
 // subscription, picking the smallest free N (excludeId skips a subscription's
 // subscription, picking the smallest free N (excludeId skips a subscription's
 // own current prefix when editing).
 // own current prefix when editing).
-func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
+func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (string, error) {
 	var subs []*model.OutboundSubscription
 	var subs []*model.OutboundSubscription
-	_ = database.GetDB().Find(&subs).Error
-	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
+	if err := database.GetDB().Find(&subs).Error; err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId)), nil
 }
 }
 
 
 func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
 func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
@@ -175,11 +177,16 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 	}
 	}
 	prefix := strings.TrimSpace(tagPrefix)
 	prefix := strings.TrimSpace(tagPrefix)
 	if prefix == "" {
 	if prefix == "" {
-		prefix = s.nextDefaultSubPrefix(0)
+		prefix, err = s.nextDefaultSubPrefix(0)
+		if err != nil {
+			return nil, err
+		}
 	}
 	}
 	// New subscriptions go to the end of the priority order.
 	// New subscriptions go to the end of the priority order.
 	var count int64
 	var count int64
-	database.GetDB().Model(&model.OutboundSubscription{}).Count(&count)
+	if err := database.GetDB().Model(&model.OutboundSubscription{}).Count(&count).Error; err != nil {
+		return nil, err
+	}
 	sub := &model.OutboundSubscription{
 	sub := &model.OutboundSubscription{
 		Remark:         strings.TrimSpace(remark),
 		Remark:         strings.TrimSpace(remark),
 		Url:            cleanURL,
 		Url:            cleanURL,
@@ -215,7 +222,10 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	}
 	}
 	prefix := strings.TrimSpace(tagPrefix)
 	prefix := strings.TrimSpace(tagPrefix)
 	if prefix == "" {
 	if prefix == "" {
-		prefix = s.nextDefaultSubPrefix(sub.Id)
+		prefix, err = s.nextDefaultSubPrefix(sub.Id)
+		if err != nil {
+			return err
+		}
 	}
 	}
 	sub.Remark = strings.TrimSpace(remark)
 	sub.Remark = strings.TrimSpace(remark)
 	sub.Url = cleanURL
 	sub.Url = cleanURL

+ 92 - 0
internal/web/service/outbound_subscription_test.go

@@ -5,10 +5,102 @@ import (
 	"errors"
 	"errors"
 	"testing"
 	"testing"
 
 
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/link"
 )
 )
 
 
+func TestOutboundSubscriptionCreatePropagatesAllocationDatabaseFailures(t *testing.T) {
+	setupSettingTestDB(t)
+	db := database.GetDB()
+	const callback = "test:fail_outbound_subscription_query"
+	errInjected := errors.New("injected outbound subscription query failure")
+	if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
+		if tx.Statement != nil && tx.Statement.Table == "outbound_subscriptions" {
+			tx.AddError(errInjected)
+		}
+	}); err != nil {
+		t.Fatalf("register query callback: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := db.Callback().Query().Remove(callback); err != nil {
+			t.Errorf("remove query callback: %v", err)
+		}
+	})
+
+	for _, tc := range []struct {
+		name      string
+		tagPrefix string
+		operation string
+	}{
+		{name: "default prefix query", tagPrefix: "", operation: "prefix allocation"},
+		{name: "priority count query", tagPrefix: "custom-", operation: "priority allocation"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			created, err := (&OutboundSubscriptionService{}).Create("test", "https://1.1.1.1/sub", tc.tagPrefix, true, 600, false, false, false)
+			if !errors.Is(err, errInjected) {
+				t.Fatalf("Create error = %v, want injected %s query failure", err, tc.operation)
+			}
+			if created != nil {
+				t.Fatalf("Create returned row %+v after %s query failure", created, tc.operation)
+			}
+		})
+	}
+}
+
+func TestOutboundSubscriptionUpdatePropagatesPrefixQueryFailureWithoutMutation(t *testing.T) {
+	setupSettingTestDB(t)
+	db := database.GetDB()
+	original := &model.OutboundSubscription{
+		Remark: "before", Url: "https://1.1.1.1/original", TagPrefix: "custom-",
+		Enabled: true, UpdateInterval: 600,
+	}
+	if err := db.Create(original).Error; err != nil {
+		t.Fatalf("seed subscription: %v", err)
+	}
+
+	errInjected := errors.New("injected update prefix query failure")
+	queryCount := 0
+	const callback = "test:fail_update_prefix_query"
+	if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
+		if tx.Statement == nil || tx.Statement.Table != "outbound_subscriptions" {
+			return
+		}
+		queryCount++
+		if queryCount == 2 {
+			tx.AddError(errInjected)
+		}
+	}); err != nil {
+		t.Fatalf("register query callback: %v", err)
+	}
+	t.Cleanup(func() {
+		if err := db.Callback().Query().Remove(callback); err != nil {
+			t.Errorf("remove query callback: %v", err)
+		}
+	})
+
+	err := (&OutboundSubscriptionService{}).Update(
+		original.Id, "after", "https://1.1.1.1/changed", "", false, 1200, false, false, false,
+	)
+	if !errors.Is(err, errInjected) {
+		t.Fatalf("Update error = %v, want injected prefix query failure", err)
+	}
+	if queryCount != 2 {
+		t.Fatalf("outbound subscription queries = %d, want Get plus prefix allocation", queryCount)
+	}
+
+	var got model.OutboundSubscription
+	if err := db.First(&got, original.Id).Error; err != nil {
+		t.Fatalf("reload subscription: %v", err)
+	}
+	if got.Remark != original.Remark || got.Url != original.Url || got.TagPrefix != original.TagPrefix ||
+		got.Enabled != original.Enabled || got.UpdateInterval != original.UpdateInterval {
+		t.Fatalf("subscription changed after failed allocation: got %+v, want %+v", got, *original)
+	}
+}
+
 func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
 func TestReadBoundedOutboundSubscriptionBody(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))

+ 24 - 0
internal/web/service/panel/api_token.go

@@ -5,6 +5,8 @@ import (
 	"errors"
 	"errors"
 	"strings"
 	"strings"
 
 
+	"gorm.io/gorm"
+
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
@@ -86,6 +88,28 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	return view, nil
 	return view, nil
 }
 }
 
 
+// RecreateByName replaces any token with this name, keeping exactly one so a
+// repeatedly-run caller cannot accumulate credentials it can never revoke.
+func (s *ApiTokenService) RecreateByName(name string) (*ApiTokenView, error) {
+	name = strings.TrimSpace(name)
+	if name == "" {
+		return nil, common.NewError("token name is required")
+	}
+	plaintext := random.Seq(apiTokenLength)
+	row := &model.ApiToken{Name: name, Token: crypto.HashTokenSHA256(plaintext), Enabled: true}
+	if err := database.GetDB().Transaction(func(tx *gorm.DB) error {
+		if err := tx.Where("name = ?", name).Delete(model.ApiToken{}).Error; err != nil {
+			return err
+		}
+		return tx.Create(row).Error
+	}); err != nil {
+		return nil, err
+	}
+	view := toView(row)
+	view.Token = plaintext
+	return view, nil
+}
+
 func (s *ApiTokenService) Delete(id int) error {
 func (s *ApiTokenService) Delete(id int) error {
 	if id <= 0 {
 	if id <= 0 {
 		return common.NewError("invalid token id")
 		return common.NewError("invalid token id")

+ 76 - 1
internal/web/service/panel/api_token_test.go

@@ -1,6 +1,17 @@
 package panel
 package panel
 
 
-import "testing"
+import (
+	"errors"
+	"testing"
+
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+var errInjectedTokenCreate = errors.New("injected token create failure")
 
 
 func TestApiTokenCreatedAtSeconds(t *testing.T) {
 func TestApiTokenCreatedAtSeconds(t *testing.T) {
 	tests := []struct {
 	tests := []struct {
@@ -21,3 +32,67 @@ func TestApiTokenCreatedAtSeconds(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestRecreateByNamePreservesTokenWhenReplacementFails(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := ApiTokenService{}
+	first, err := svc.RecreateByName("cli-fallback")
+	if err != nil {
+		t.Fatalf("first recreate: %v", err)
+	}
+	db := database.GetDB()
+	const callback = "test:fail-token-replacement"
+	if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) {
+		if token, ok := tx.Statement.Dest.(*model.ApiToken); ok && token.Name == "cli-fallback" {
+			tx.AddError(errInjectedTokenCreate)
+		}
+	}); err != nil {
+		t.Fatalf("register callback: %v", err)
+	}
+	t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) })
+
+	if _, err := svc.RecreateByName("cli-fallback"); !errors.Is(err, errInjectedTokenCreate) {
+		t.Fatalf("recreate error = %v, want %v", err, errInjectedTokenCreate)
+	}
+	var row model.ApiToken
+	if err := db.Where("name = ?", "cli-fallback").First(&row).Error; err != nil {
+		t.Fatalf("load preserved token: %v", err)
+	}
+	if !svc.Match(first.Token) {
+		t.Fatal("original token was revoked after replacement failure")
+	}
+}
+
+func TestRecreateByNameKeepsOneToken(t *testing.T) {
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := database.InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	svc := ApiTokenService{}
+	first, err := svc.RecreateByName("cli-fallback")
+	if err != nil {
+		t.Fatalf("first recreate: %v", err)
+	}
+	second, err := svc.RecreateByName("cli-fallback")
+	if err != nil {
+		t.Fatalf("second recreate: %v", err)
+	}
+	if first.Token == second.Token {
+		t.Fatal("second call returned the same plaintext, want a rotated token")
+	}
+
+	var count int64
+	if err := database.GetDB().Model(model.ApiToken{}).Where("name = ?", "cli-fallback").Count(&count).Error; err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	if count != 1 {
+		t.Fatalf("token rows = %d, want 1", count)
+	}
+}

+ 1 - 0
internal/web/service/setting.go

@@ -59,6 +59,7 @@ var defaultValueMap = map[string]string{
 	"nodeMtlsCaKeyPem":            "",
 	"nodeMtlsCaKeyPem":            "",
 	"nodeMtlsClientCertPem":       "",
 	"nodeMtlsClientCertPem":       "",
 	"nodeMtlsClientKeyPem":        "",
 	"nodeMtlsClientKeyPem":        "",
+	"nodeMtlsClientCertSha256":    "",
 	"nodeMtlsClientCAPem":         "",
 	"nodeMtlsClientCAPem":         "",
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",
 	"sessionMaxAge":               "360",

+ 1 - 0
internal/web/service/setting_factory_defaults_test.go

@@ -52,6 +52,7 @@ func TestGetFactoryDefaultsOmitsSensitiveMaterial(t *testing.T) {
 		"nodeMtlsCaKeyPem",
 		"nodeMtlsCaKeyPem",
 		"nodeMtlsClientCertPem",
 		"nodeMtlsClientCertPem",
 		"nodeMtlsClientKeyPem",
 		"nodeMtlsClientKeyPem",
+		"nodeMtlsClientCertSha256",
 		"xrayTemplateConfig",
 		"xrayTemplateConfig",
 		"tgBotToken",
 		"tgBotToken",
 		"twoFactorToken",
 		"twoFactorToken",

+ 74 - 2
internal/web/service/setting_mtls.go

@@ -1,17 +1,30 @@
 package service
 package service
 
 
 import (
 import (
+	"crypto/sha256"
+	"crypto/tls"
 	"crypto/x509"
 	"crypto/x509"
+	"encoding/hex"
+	"encoding/pem"
+	"strings"
+	"sync"
 
 
+	"gorm.io/gorm"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/common"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 )
 
 
+var masterClientCredentialMu sync.Mutex
+
 const (
 const (
 	settingNodeMtlsCaCert     = "nodeMtlsCaCertPem"
 	settingNodeMtlsCaCert     = "nodeMtlsCaCertPem"
 	settingNodeMtlsCaKey      = "nodeMtlsCaKeyPem"
 	settingNodeMtlsCaKey      = "nodeMtlsCaKeyPem"
 	settingNodeMtlsClientCert = "nodeMtlsClientCertPem"
 	settingNodeMtlsClientCert = "nodeMtlsClientCertPem"
 	settingNodeMtlsClientKey  = "nodeMtlsClientKeyPem"
 	settingNodeMtlsClientKey  = "nodeMtlsClientKeyPem"
+	settingNodeMtlsClientPin  = "nodeMtlsClientCertSha256"
 	settingNodeMtlsClientCA   = "nodeMtlsClientCAPem"
 	settingNodeMtlsClientCA   = "nodeMtlsClientCAPem"
 )
 )
 
 
@@ -49,10 +62,26 @@ func (s *SettingService) EnsureNodeMtlsCA() (crypto.CertKeyPEM, error) {
 	return ca, nil
 	return ca, nil
 }
 }
 
 
+func clientCertSHA256FromPEM(certPEM []byte) (string, error) {
+	block, rest := pem.Decode(certPEM)
+	if block == nil || block.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 {
+		return "", common.NewError("client certificate is not valid PEM")
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return "", err
+	}
+	sum := sha256.Sum256(cert.Raw)
+	return hex.EncodeToString(sum[:]), nil
+}
+
 // EnsureMasterClientCert returns the client certificate this panel presents when
 // EnsureMasterClientCert returns the client certificate this panel presents when
 // calling its nodes over mTLS, issuing it from the node CA on first use and
 // calling its nodes over mTLS, issuing it from the node CA on first use and
 // reusing the stored pair thereafter.
 // reusing the stored pair thereafter.
 func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
+	masterClientCredentialMu.Lock()
+	defer masterClientCredentialMu.Unlock()
+
 	certPem, err := s.getString(settingNodeMtlsClientCert)
 	certPem, err := s.getString(settingNodeMtlsClientCert)
 	if err != nil {
 	if err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
@@ -61,7 +90,27 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 	if err != nil {
 	if err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
 	}
 	}
+	storedPin, err := s.getString(settingNodeMtlsClientPin)
+	if err != nil {
+		return crypto.CertKeyPEM{}, err
+	}
+	storedPin = strings.ToLower(strings.TrimSpace(storedPin))
 	if certPem != "" && keyPem != "" {
 	if certPem != "" && keyPem != "" {
+		if _, err := tls.X509KeyPair([]byte(certPem), []byte(keyPem)); err != nil {
+			return crypto.CertKeyPEM{}, common.NewError("stored master client certificate/key pair is invalid: ", err)
+		}
+		actualPin, err := clientCertSHA256FromPEM([]byte(certPem))
+		if err != nil {
+			return crypto.CertKeyPEM{}, err
+		}
+		if storedPin != "" && storedPin != actualPin {
+			return crypto.CertKeyPEM{}, common.NewError("stored master client certificate does not match nodeMtlsClientCertSha256; refusing to rotate")
+		}
+		if storedPin == "" {
+			if err := s.saveSetting(settingNodeMtlsClientPin, actualPin); err != nil {
+				return crypto.CertKeyPEM{}, err
+			}
+		}
 		return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
 		return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil
 	}
 	}
 	// Half a stored pair signals corrupted settings; reissuing would rotate the
 	// Half a stored pair signals corrupted settings; reissuing would rotate the
@@ -77,15 +126,38 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 	if err != nil {
 	if err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
 	}
 	}
-	if err := s.saveSetting(settingNodeMtlsClientCert, string(client.CertPEM)); err != nil {
+	pin, err := clientCertSHA256FromPEM(client.CertPEM)
+	if err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
 	}
 	}
-	if err := s.saveSetting(settingNodeMtlsClientKey, string(client.KeyPEM)); err != nil {
+	if err := saveMasterClientCredential(client, pin); err != nil {
 		return crypto.CertKeyPEM{}, err
 		return crypto.CertKeyPEM{}, err
 	}
 	}
 	return client, nil
 	return client, nil
 }
 }
 
 
+func saveMasterClientCredential(client crypto.CertKeyPEM, pin string) error {
+	values := map[string]string{
+		settingNodeMtlsClientCert: string(client.CertPEM),
+		settingNodeMtlsClientKey:  string(client.KeyPEM),
+		settingNodeMtlsClientPin:  pin,
+	}
+	return database.GetDB().Transaction(func(tx *gorm.DB) error {
+		for key, value := range values {
+			result := tx.Model(&model.Setting{}).Where("key = ?", key).Update("value", value)
+			if result.Error != nil {
+				return result.Error
+			}
+			if result.RowsAffected == 0 {
+				if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil {
+					return err
+				}
+			}
+		}
+		return nil
+	})
+}
+
 // NodeMtlsClientCAPool builds the trust pool used as the panel listener's
 // NodeMtlsClientCAPool builds the trust pool used as the panel listener's
 // ClientCAs for incoming node-API client certificates. It returns (nil, nil)
 // ClientCAs for incoming node-API client certificates. It returns (nil, nil)
 // when no trust CA is configured, so mTLS stays off and the listener behaves
 // when no trust CA is configured, so mTLS stays off and the listener behaves

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

@@ -6,8 +6,10 @@ import (
 	"encoding/pem"
 	"encoding/pem"
 	"path/filepath"
 	"path/filepath"
 	"testing"
 	"testing"
+	"time"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 )
 
 
 func setupSettingMtlsDB(t *testing.T) *SettingService {
 func setupSettingMtlsDB(t *testing.T) *SettingService {
@@ -88,6 +90,153 @@ func TestEnsureMasterClientCert_VerifiesAndIdempotent(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestEnsureMasterClientCertRejectsMismatchedStoredKey(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+	first, err := s.EnsureMasterClientCert()
+	if err != nil {
+		t.Fatal(err)
+	}
+	ca, err := s.EnsureNodeMtlsCA()
+	if err != nil {
+		t.Fatal(err)
+	}
+	other, err := crypto.IssueClientCert(ca, "other master")
+	if err != nil {
+		t.Fatal(err)
+	}
+	if err := s.setString(settingNodeMtlsClientCert, string(first.CertPEM)); err != nil {
+		t.Fatal(err)
+	}
+	if err := s.setString(settingNodeMtlsClientKey, string(other.KeyPEM)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := s.EnsureMasterClientCert(); err == nil {
+		t.Fatal("mismatched stored certificate and key were accepted")
+	}
+}
+
+func TestEnsureMasterClientCert_ReissuesLeafWhenCAStillExists(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+
+	client, err := s.EnsureMasterClientCert()
+	if err != nil {
+		t.Fatalf("EnsureMasterClientCert: %v", err)
+	}
+	pin, err := clientCertSHA256FromPEM(client.CertPEM)
+	if err != nil {
+		t.Fatalf("clientCertSHA256FromPEM: %v", err)
+	}
+	if err := s.setString(settingNodeMtlsClientPin, pin); err != nil {
+		t.Fatalf("persist client pin: %v", err)
+	}
+	if err := s.setString(settingNodeMtlsClientCert, ""); err != nil {
+		t.Fatalf("clear client cert: %v", err)
+	}
+	if err := s.setString(settingNodeMtlsClientKey, ""); err != nil {
+		t.Fatalf("clear client key: %v", err)
+	}
+
+	reissued, err := s.EnsureMasterClientCert()
+	if err != nil {
+		t.Fatalf("reissue with surviving CA: %v", err)
+	}
+	newPin, err := clientCertSHA256FromPEM(reissued.CertPEM)
+	if err != nil {
+		t.Fatalf("new pin: %v", err)
+	}
+	if newPin == pin {
+		t.Fatal("reissued credential kept the lost leaf identity")
+	}
+	stored, err := s.getString(settingNodeMtlsClientPin)
+	if err != nil || stored != newPin {
+		t.Fatalf("stored pin = %q, error = %v, want %q", stored, err, newPin)
+	}
+}
+
+func TestEnsureMasterClientCertConcurrentFirstUseMintsOneCredential(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+	blocked := make(chan struct{})
+	masterClientCredentialMu.Lock()
+	go func() {
+		_, _ = s.EnsureMasterClientCert()
+		close(blocked)
+	}()
+	select {
+	case <-blocked:
+		masterClientCredentialMu.Unlock()
+		t.Fatal("EnsureMasterClientCert returned while its serialization lock was held")
+	case <-time.After(50 * time.Millisecond):
+	}
+	masterClientCredentialMu.Unlock()
+	select {
+	case <-blocked:
+	case <-time.After(time.Second):
+		t.Fatal("EnsureMasterClientCert remained blocked after serialization lock release")
+	}
+	const callers = 8
+	start := make(chan struct{})
+	results := make(chan crypto.CertKeyPEM, callers)
+	errs := make(chan error, callers)
+	for range callers {
+		go func() {
+			<-start
+			credential, err := s.EnsureMasterClientCert()
+			results <- credential
+			errs <- err
+		}()
+	}
+	close(start)
+	var first crypto.CertKeyPEM
+	for i := 0; i < callers; i++ {
+		credential := <-results
+		if err := <-errs; err != nil {
+			t.Fatalf("caller %d: %v", i, err)
+		}
+		if i == 0 {
+			first = credential
+		} else if !bytes.Equal(first.CertPEM, credential.CertPEM) || !bytes.Equal(first.KeyPEM, credential.KeyPEM) {
+			t.Fatalf("caller %d received a different credential", i)
+		}
+	}
+	storedCert, _ := s.getString(settingNodeMtlsClientCert)
+	storedKey, _ := s.getString(settingNodeMtlsClientKey)
+	if storedCert != string(first.CertPEM) || storedKey != string(first.KeyPEM) {
+		t.Fatal("persisted credential differs from concurrent callers")
+	}
+}
+
+func TestEnsureMasterClientCert_PersistsCredentialAtomically(t *testing.T) {
+	s := setupSettingMtlsDB(t)
+	db := database.GetDB()
+	trigger := `CREATE TRIGGER fail_master_pin_insert
+		BEFORE INSERT ON settings
+		WHEN NEW.key = 'nodeMtlsClientCertSha256'
+		BEGIN SELECT RAISE(ABORT, 'injected pin failure'); END`
+	if err := db.Exec(trigger).Error; err != nil {
+		t.Fatalf("create failure trigger: %v", err)
+	}
+
+	if _, err := s.EnsureMasterClientCert(); err == nil {
+		t.Fatal("injected persistence failure unexpectedly succeeded")
+	}
+	for _, key := range []string{settingNodeMtlsClientCert, settingNodeMtlsClientKey, settingNodeMtlsClientPin} {
+		got, err := s.getString(key)
+		if err != nil {
+			t.Fatalf("get %s after rollback: %v", key, err)
+		}
+		if got != "" {
+			t.Fatalf("%s persisted despite transaction rollback", key)
+		}
+	}
+
+	if err := db.Exec("DROP TRIGGER fail_master_pin_insert").Error; err != nil {
+		t.Fatalf("drop failure trigger: %v", err)
+	}
+	if _, err := s.EnsureMasterClientCert(); err != nil {
+		t.Fatalf("retry after rollback: %v", err)
+	}
+}
+
 func TestNodeMtlsClientCAPool(t *testing.T) {
 func TestNodeMtlsClientCAPool(t *testing.T) {
 	s := setupSettingMtlsDB(t)
 	s := setupSettingMtlsDB(t)
 
 

+ 86 - 0
internal/web/service/traffic_runtime_apply_test.go

@@ -0,0 +1,86 @@
+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"
+)
+
+func TestTrafficDisableImmediatelyUpdatesNodeRuntime(t *testing.T) {
+	setupConflictDB(t)
+	nodeID, fake := setupNodeRuntime(t)
+	client := model.Client{Email: "spent-node", Enable: true}
+	ib := nodeInbound(t, nodeID, 46301, []model.Client{client})
+	if err := database.GetDB().Create(&xray.ClientTraffic{
+		InboundId: ib.Id, Email: client.Email, Enable: true, Up: 100, Total: 100,
+	}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
+		t.Fatalf("addTrafficLocked: %v", err)
+	}
+	if got := fake.updateInbound.Load(); got != 1 {
+		t.Fatalf("remote UpdateInbound calls = %d, want 1 after commit", got)
+	}
+}
+
+func TestTrafficDisableRefreshesLocalMTProtoSidecar(t *testing.T) {
+	setupConflictDB(t)
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	seedInboundConflict(t, "mt-spent", "", 46302, model.MTProto, "",
+		`{"clients":[{"email":"spent-mt","secret":"`+mtprotoTestSecretA+`","enable":true}]}`)
+	ib := loadInboundByTag(t, "mt-spent")
+	clients, err := (&InboundService{}).GetClients(ib)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	seedClientTraffic(t, ib.Id, "spent-mt", true)
+	if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "spent-mt").
+		Updates(map[string]any{"up": 100, "total": 100}).Error; err != nil {
+		t.Fatalf("deplete traffic: %v", err)
+	}
+
+	if _, _, _, err := (&InboundService{}).addTrafficLocked(nil, nil); err != nil {
+		t.Fatalf("addTrafficLocked: %v", err)
+	}
+	if got := fake.updateInbound.Load(); got != 1 {
+		t.Fatalf("MTProto sidecar UpdateInbound calls = %d, want 1 after commit", got)
+	}
+}
+
+func TestDelDepletedClientsCleansRuntimeAfterCommit(t *testing.T) {
+	setupConflictDB(t)
+	mgr := runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }})
+	fake := &fakeNodeRuntime{}
+	mgr.SetLocalRuntimeOverride(fake)
+	runtime.SetManager(mgr)
+	t.Cleanup(func() { runtime.SetManager(nil) })
+
+	seedInboundConflict(t, "depleted-only", "", 46303, model.VLESS, `{"network":"tcp"}`,
+		`{"clients":[{"email":"gone","enable":true}]}`)
+	ib := loadInboundByTag(t, "depleted-only")
+	seedClientTraffic(t, ib.Id, "gone", true)
+	if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", "gone").
+		Updates(map[string]any{"up": 100, "total": 100, "reset": 0}).Error; err != nil {
+		t.Fatalf("deplete traffic: %v", err)
+	}
+
+	if err := (&InboundService{}).DelDepletedClients(-1); err != nil {
+		t.Fatalf("DelDepletedClients: %v", err)
+	}
+	if got := fake.delInbound.Load(); got != 1 {
+		t.Fatalf("runtime DelInbound calls = %d, want 1 after commit", got)
+	}
+}

+ 1 - 3
internal/web/web.go

@@ -591,9 +591,7 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		IdleTimeout:       120 * time.Second,
 		IdleTimeout:       120 * time.Second,
 	}
 	}
 
 
-	go func() {
-		_ = s.httpServer.Serve(listener)
-	}()
+	go network.ServeHTTP(s.httpServer, listener, "Web server")
 
 
 	// Create event bus before startTask so jobs can use it
 	// Create event bus before startTask so jobs can use it
 	s.bus = eventbus.New(eventbus.DefaultBufferSize)
 	s.bus = eventbus.New(eventbus.DefaultBufferSize)

+ 8 - 5
main.go

@@ -12,7 +12,6 @@ import (
 	"os"
 	"os"
 	"os/signal"
 	"os/signal"
 	"syscall"
 	"syscall"
-	"time"
 	_ "unsafe"
 	_ "unsafe"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -32,6 +31,10 @@ import (
 	"github.com/op/go-logging"
 	"github.com/op/go-logging"
 )
 )
 
 
+// cliFallbackTokenName is the single token the CLI regenerates, so `-getApiToken`
+// cannot accumulate admin-equivalent credentials that are never revoked.
+const cliFallbackTokenName = "cli-fallback"
+
 // runWebServer initializes and starts the web server for the 3x-ui panel.
 // runWebServer initializes and starts the web server for the 3x-ui panel.
 func runWebServer() {
 func runWebServer() {
 	log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
 	log.Printf("Starting %v %v", config.GetName(), config.GetPanelVersion())
@@ -455,14 +458,14 @@ func GetApiToken(getApiToken bool) {
 		fmt.Printf("There are %d API token(s) configured. Existing tokens cannot be retrieved in plaintext because only hashes are stored.\n", len(tokens))
 		fmt.Printf("There are %d API token(s) configured. Existing tokens cannot be retrieved in plaintext because only hashes are stored.\n", len(tokens))
 		fmt.Println("If you have lost your token, you can manage and generate new tokens through the Panel UI (Settings -> API Tokens).")
 		fmt.Println("If you have lost your token, you can manage and generate new tokens through the Panel UI (Settings -> API Tokens).")
 
 
-		// Create a new fallback token so the CLI is still useful without the UI
-		fallbackName := fmt.Sprintf("cli-fallback-%d", time.Now().Unix())
-		created, err := apiTokenService.Create(fallbackName)
+		// Rotate one reusable fallback so repeated calls cannot pile up
+		// indefinitely many admin-equivalent tokens that never expire.
+		created, err := apiTokenService.RecreateByName(cliFallbackTokenName)
 		if err != nil {
 		if err != nil {
 			fmt.Println("Failed to create a fallback API token:", err)
 			fmt.Println("Failed to create a fallback API token:", err)
 			return
 			return
 		}
 		}
-		fmt.Println("\nA new fallback token has been generated for your convenience:")
+		fmt.Println("\nThe CLI fallback token has been regenerated (any previous one is now invalid):")
 		fmt.Println("apiToken:", created.Token)
 		fmt.Println("apiToken:", created.Token)
 		return
 		return
 	}
 	}