13 Commits d291e1c5ee ... ad32144c42

Autor SHA1 Mensaje Fecha
  n0ctal ad32144c42 fix(sub): use a fullwidth percent in USAGE_PERCENTAGE (#6174) hace 17 horas
  n0ctal 34c248bb79 fix(cli): stop -getApiToken accumulating admin tokens (#6175) hace 17 horas
  n0ctal 17fea2f656 fix(database): keep IP limits when the fail2ban probe is inconclusive (#6176) hace 17 horas
  n0ctal c5dec64d36 fix(clients): push bulk client changes to nodes only after the commit lands (#6181) hace 17 horas
  n0ctal b56b087254 fix(migration): stop a half-applied startup migration from committing silently (#6182) hace 17 horas
  n0ctal 3bb87e80aa fix(db): harden unrestricted freedom outbounds (#6184) hace 17 horas
  n0ctal 60453bf523 fix(nodes): persist the master mTLS credential atomically and stop silent reissue (#6195) hace 17 horas
  n0ctal bb29b6afec fix(node): adopt a matching deployed inbound instead of recreating it (#6197) hace 17 horas
  n0ctal 3b19091547 fix(sub): render the full remark once per subscription, not once per credential (#6198) hace 17 horas
  n0ctal 0496c23a26 fix(groups): report changed bulk moves without restarting xray (#6199) hace 17 horas
  n0ctal 1396005082 fix(traffic): apply maintenance side effects only after the commit lands (#6200) hace 18 horas
  n0ctal b70c5abce8 fix(outbounds): propagate allocation query failures (#6208) hace 18 horas
  n0ctal 20b3f84f77 fix(web): report unexpected HTTP serve failures (#6210) hace 18 horas
Se han modificado 46 ficheros con 1929 adiciones y 503 borrados
  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 SubLinkProvider = unknown;
 export type staticEgressResolver = string;
+export type trafficLocalApplyAction = number;
 export type transportBits = number;
 
 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 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 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: 'DAYS_LEFT', group: 'time', sample: '12' },
   { 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: 'JALALI_EXPIRE_DATE', group: 'time', sample: '1405/06/10' },
   { token: 'EXPIRE_UNIX', group: 'time', sample: '1788300000' },

+ 38 - 6
internal/database/db.go

@@ -1278,9 +1278,14 @@ func resetIpLimitsWithoutFail2ban() error {
 		return nil
 	}
 
-	if fail2banCanEnforce() {
+	state, probeErr := fail2banEnforcementState()
+	if state == fail2banEnforcing {
 		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
 	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" {
-		return false
+		return fail2banAbsent, nil
 	}
 	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 {
@@ -1628,6 +1649,14 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
 	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 {
 	var setting model.Setting
 	err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
@@ -1680,7 +1709,10 @@ func rewriteFreedomFinalRulesPrivateEgress(raw string) (string, bool, error) {
 		if !ok {
 			continue
 		}
-		if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
+		finalRules, present := settings["finalRules"]
+		if !isUnrestrictedFreedomFinalRules(finalRules, present) &&
+			!isAllowOnlyFinalRules(finalRules) &&
+			!isLegacyPrivateOnlyFinalRules(finalRules) {
 			continue
 		}
 		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,
 			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",
 			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) {
 	_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
 	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 {
 	if st.Total <= 0 {
 		return ""
@@ -361,7 +361,7 @@ func usagePercentage(st xray.ClientTraffic) string {
 	if pct > 100 {
 		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
@@ -607,19 +607,27 @@ func appendKeptRun(runs []string, run string, leftRemoved, rightRemoved bool) []
 	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)
 	if s.usageShown == nil {
 		s.usageShown = map[string]bool{}
 	}
-	if s.usageShown[email] {
+	key := templateInfoKey(client)
+	if s.usageShown[key] {
 		remove := firstLinkOnlyBodyTokens
 		if s.showIdentityOnAllLinks {
 			remove = usageInfoTokens
 		}
 		return filterRemarkTemplate(translated, remove)
 	}
-	s.usageShown[email] = true
+	s.usageShown[key] = true
 	return translated
 }
 
@@ -646,7 +654,7 @@ func (s *SubService) genTemplatedRemark(inbound *model.Inbound, client model.Cli
 	}
 	var tmpl string
 	if s.subscriptionBody {
-		tmpl = s.effectiveTemplate(client.Email)
+		tmpl = s.effectiveTemplate(client)
 	} else {
 		tmpl = filterRemarkTemplate(translateUISingleBrackets(s.remarkTemplate), displayRemoveTokens)
 	}

+ 47 - 8
internal/sub/remark_vars_test.go

@@ -1,6 +1,7 @@
 package sub
 
 import (
+	"net/url"
 	"strings"
 	"testing"
 
@@ -480,18 +481,33 @@ func TestStatusEmoji(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)
 	}
 	if got := usagePercentage(xray.ClientTraffic{Total: 0}); 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)
 	}
 	// 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 }{
 		{"{{STATUS_EMOJI}}", "✅"},
-		{"{{USAGE_PERCENTAGE}}", "50.0%"},
+		{"{{USAGE_PERCENTAGE}}", "50.0"},
 		{"{{PROTOCOL}}", "VLESS"},
 		{"{{TRANSPORT}}", "ws"},
 		{"{{SECURITY}}", "REALITY"},
@@ -619,7 +635,7 @@ func TestExpandRemarkVars_SingleBracketUI(t *testing.T) {
 		{"{DATA_USAGE}", "50.00GB"},
 		{"{DATA_LIMIT}", "100.00GB"},
 		{"{STATUS_EMOJI}", "✅"},
-		{"{USAGE_PERCENTAGE}", "50.0%"},
+		{"{USAGE_PERCENTAGE}", "50.0"},
 		{"{PROTOCOL}", "VLESS"},
 		{"{TRANSPORT}", "ws"},
 		{"{SECURITY}", "TLS"},
@@ -649,7 +665,6 @@ func TestUsageOnFirstLinkOnly_SingleBracket(t *testing.T) {
 	}
 	client := model.Client{Email: "alice@x"}
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
-	s.usageShown["alice@x"] = true
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	if !strings.Contains(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"}
 	first := s.genTemplatedRemark(inbound, client, "", "ws")
-	s.usageShown["alice@x"] = true
 	second := s.genTemplatedRemark(inbound, client, "", "ws")
 	if !strings.Contains(first, "alice@x") {
 		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
 	// the name-only template, like Remnawave.
 	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
 	showIdentityOnAllLinks bool
 	inboundService         service.InboundService

+ 1 - 3
internal/sub/sub.go

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

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

@@ -148,7 +148,6 @@ func (a *GroupController) bulkAdd(c *gin.Context) {
 		return
 	}
 	jsonObj(c, gin.H{"affected": affected}, nil)
-	a.xrayService.SetToNeedRestart()
 	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)
 		return nil
 	}
+	snap.ManagedAliases = rt.AdoptedInboundAliases()
 	service.FilterNodeSnapshot(n, snap)
 	_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
 	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 {
 	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
 	// 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.
@@ -99,8 +100,10 @@ type Remote struct {
 }
 
 type RemoteInboundOption struct {
+	Id       int            `json:"id"`
 	Tag      string         `json:"tag"`
 	Remark   string         `json:"remark"`
+	Listen   string         `json:"listen"`
 	Protocol model.Protocol `json:"protocol"`
 	Port     int            `json:"port"`
 }
@@ -109,6 +112,7 @@ func NewRemote(n *model.Node, r NodeEgressResolver) *Remote {
 	return &Remote{
 		node:           n,
 		remoteIDByTag:  make(map[string]int),
+		adoptedAliases: make(map[string]string),
 		pushedFP:       make(map[string]string),
 		egressResolver: r,
 	}
@@ -479,13 +483,33 @@ func (r *Remote) recordPushedInbound(ib *model.Inbound) {
 	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) {
 	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
 // 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
@@ -661,8 +685,9 @@ func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) err
 }
 
 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
 	// its panelGuid plus every descendant under theirs). Preferred over the flat
 	// 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
 	}
-	prevSettings := oldInbound.Settings
 	oldInbound.Settings = string(newSettings)
 
 	// A flow change rewrites the user's xray config, which the lightweight
@@ -662,45 +661,6 @@ func (s *ClientService) bulkAdjustInboundClients(
 		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
 	// lock-order deadlock on inbounds/client_records (runSerializedTx).
 	txErr := runSerializedTx(func(tx *gorm.DB) error {
@@ -725,6 +685,26 @@ func (s *ClientService) bulkAdjustInboundClients(
 				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
@@ -980,7 +960,6 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 		return res
 	}
-	prevSettings := oldInbound.Settings
 	oldInbound.Settings = string(newSettings)
 
 	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)
 		if rterr != nil {
 			res.needRestart = true
@@ -1068,62 +1071,19 @@ func (s *ClientService) bulkDelInboundClients(
 				}
 			}
 		}
-	} else {
+	} else if len(foundEmails) <= nodeBulkPushThreshold {
 		rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound)
 		if perr != nil {
+			logger.Warning("BulkDelete: node runtime lookup after commit failed:", perr)
+		} else if push {
 			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
 }
 

+ 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
 	for _, batch := range chunkStrings(emails, sqlInChunk) {
 		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
 		}
 		records = append(records, rows...)
@@ -248,13 +250,17 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	}
 
 	tx := db.Begin()
+	var affected int64
 	for _, batch := range chunkStrings(affectedEmails, sqlInChunk) {
-		if err := tx.Model(&model.ClientRecord{}).
+		result := tx.Model(&model.ClientRecord{}).
 			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()
-			return 0, err
+			return 0, result.Error
 		}
+		affected += result.RowsAffected
 	}
 
 	var inboundIDs []int
@@ -331,7 +337,7 @@ func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	if err := tx.Commit().Error; err != nil {
 		return 0, err
 	}
-	return len(records), nil
+	return int(affected), nil
 }
 
 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")
 	}
 	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)
 	} else if count != 1 {
 		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 {
 			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)
 		} else if count != 0 {
 			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 {
 			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)
 		} else if count != 0 {
 			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 {
 			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)
 		} else if count != 1 {
 			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)
 	}
 
-	if _, count, err := svc.disableInvalidClients(db); err != nil {
+	if _, count, _, err := svc.disableInvalidClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("disableInvalidClients: %v", err)
 	} else if count != 1 {
 		t.Fatalf("expected 1 client disabled, got %d", count)

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

@@ -29,7 +29,6 @@ import (
 )
 
 type InboundService struct {
-	xrayApi         xray.XrayAPI
 	clientService   ClientService
 	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)
 	}
 
-	if _, count, err := svc.autoRenewClients(db); err != nil {
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("autoRenewClients: %v", err)
 	} else if count != 1 {
 		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)
 	}
 
-	if _, count, err := svc.autoRenewClients(db); err != nil {
+	if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil {
 		t.Fatalf("autoRenewClients: %v", err)
 	} else if count != 3 {
 		t.Fatalf("renewed count = %d, want 3", count)

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

@@ -1,44 +1,27 @@
 package service
 
 import (
-	"context"
 	"encoding/json"
-	"fmt"
 	"slices"
-	"strings"
 	"time"
 
 	"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"
 )
 
-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
-	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{}).
@@ -46,7 +29,7 @@ func (s *InboundService) disableInvalidInbounds(tx *gorm.DB) (bool, int64, error
 		Update("enable", false)
 	err := result.Error
 	count := result.RowsAffected
-	return needRestart, count, err
+	return false, count, err
 }
 
 const globalTrafficFreshWindow = 24 * time.Hour
@@ -94,8 +77,8 @@ func depletedCond(tx *gorm.DB) (string, []any) {
 	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)
 
 	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)...).
 		Find(&depletedRows).Error
 	if err != nil {
-		return false, 0, err
+		return false, 0, nil, err
 	}
 	if len(depletedRows) == 0 {
-		return false, 0, nil
+		return false, 0, nil, nil
 	}
 
 	depletedEmails := make([]string, 0, len(depletedRows))
@@ -134,47 +117,39 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 			WHERE clients.email IN ?
 		`, depletedEmails).Scan(&targets).Error
 		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 {
-		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
 	// re-evaluating the depleted predicate, which was a second full scan of
 	// 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).
 			Update("enable", false)
 		if result.Error != nil {
-			return needRestart, count, result.Error
+			return false, count, nil, result.Error
 		}
 		count += result.RowsAffected
 	}
@@ -197,23 +172,17 @@ func (s *InboundService) disableInvalidClients(tx *gorm.DB) (bool, int64, error)
 	if len(depletedEmails) > 0 {
 		if err := tx.Model(&model.ClientRecord{}).
 			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
@@ -265,23 +234,3 @@ func (s *InboundService) markClientsDisabledInSettings(tx *gorm.DB, inboundID in
 	}
 	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()
 	tx := db.Begin()
-	var err error
 	defer func() {
 		if err == nil {
-			tx.Commit()
+			if commitErr := tx.Commit().Error; commitErr != nil {
+				err = commitErr
+				return
+			}
 			if !database.IsPostgres() {
 				if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
 					logger.Warningf("VACUUM failed: %v", dbErr)
@@ -76,8 +78,8 @@ func (s *InboundService) MigrationRequirements() {
 	// SQLite (no PG :: casts).
 	if database.IsPostgres() {
 		// 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 $$
 				BEGIN
 					IF EXISTS (
@@ -88,14 +90,13 @@ func (s *InboundService) MigrationRequirements() {
 						ALTER TABLE %s ALTER COLUMN %s
 							TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
 					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
@@ -160,7 +161,8 @@ func (s *InboundService) MigrationRequirements() {
 				delete(settings, "testseed")
 			}
 
-			modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
+			var modifiedSettings []byte
+			modifiedSettings, err = json.MarshalIndent(settings, "", "  ")
 			if err != nil {
 				return
 			}
@@ -169,30 +171,39 @@ func (s *InboundService) MigrationRequirements() {
 		}
 
 		// 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 {
 			return
 		}
 		for _, modelClient := range modelClients {
 			if len(modelClient.Email) > 0 {
 				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 {
-					_ = 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
 		// 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
-	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
 	var externalProxy []struct {
@@ -238,8 +249,14 @@ func (s *InboundService) MigrationRequirements() {
 			}
 		}
 		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-...").
@@ -256,10 +273,13 @@ func (s *InboundService) MigrationRequirements() {
 	if err != nil {
 		return
 	}
+	return err
 }
 
 func (s *InboundService) MigrateDB() {
-	s.MigrationRequirements()
+	if err := s.MigrationRequirements(); err != nil {
+		logger.Errorf("MigrationRequirements failed: %v", err)
+	}
 	s.MigrationRemoveOrphanedTraffics()
 	s.MigrationRestoreVisionFlow()
 }

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

@@ -1,10 +1,13 @@
 package service
 
 import (
+	"errors"
 	"path/filepath"
 	"strings"
 	"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/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
 // 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

+ 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 {
 		return err
 	}
-	remoteTags, err := rt.ListRemoteTags(ctx)
+	remoteInbounds, err := rt.ListInboundOptions(ctx)
 	if err != nil {
 		return err
 	}
+	remoteTags := make([]string, 0, len(remoteInbounds))
 	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)
 	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 {
 			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 {
 			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]
+		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 dirty {
 				continue
@@ -1079,6 +1131,28 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
 	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 {
 	process := currentXrayProcess()
 	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) {
 	setupConflictDB(t)
 	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) {
+	var disabledNodeIDs []int
 	err = submitTrafficWrite(func() error {
 		var inner error
-		needRestart, clientsDisabled, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
+		needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics)
 		return inner
 	})
+	if err == nil && len(disabledNodeIDs) > 0 {
+		s.restartRemoteNodesOnDisable(disabledNodeIDs)
+	}
 	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()
-	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 {
@@ -304,11 +321,11 @@ func apiUserFromClient(client map[string]any, cipher string) map[string]any {
 	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
 	var traffics []*xray.ClientTraffic
 	now := time.Now().Unix() * 1000
-	var err, err1 error
+	var err error
 
 	// Filter to clients that have at least one local inbound. Using
 	// 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
 	needRestart := false
 	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
@@ -408,13 +424,11 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 				c["enable"] = true
 				clientsToAdd = append(clientsToAdd,
 					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)
@@ -452,18 +466,14 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
 	if err = clearGlobalTraffic(tx, renewEmails...); err != nil {
 		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
 }
@@ -577,56 +587,58 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
 }
 
 func (s *InboundService) ResetClientTraffic(id int, clientEmail string) (needRestart bool, err error) {
+	var resetInbound *model.Inbound
 	err = submitTrafficWrite(func() error {
 		var inner error
-		needRestart, inner = s.resetClientTrafficLocked(id, clientEmail)
+		needRestart, resetInbound, inner = s.resetClientTrafficLocked(id, clientEmail)
 		return inner
 	})
 	if err == nil {
 		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
 }
 
-func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, error) {
+func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (bool, *model.Inbound, error) {
 	needRestart := false
+	var reenablePlan *trafficLocalApplyPlan
+	var reenableNodeID *int
 
 	traffic, err := s.GetClientTrafficByEmail(clientEmail)
 	if err != nil {
-		return false, err
+		return false, nil, err
 	}
 
 	if !traffic.Enable {
 		inbound, err := s.GetInbound(id)
 		if err != nil {
-			return false, err
+			return false, nil, err
 		}
 		clients, err := s.GetClients(inbound)
 		if err != nil {
-			return false, err
+			return false, nil, err
 		}
 		for _, client := range clients {
 			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 := ""
 				if string(inbound.Protocol) == "shadowsocks" {
 					var oldSettings map[string]any
 					err = json.Unmarshal([]byte(inbound.Settings), &oldSettings)
 					if err != nil {
-						return false, err
+						return false, nil, err
 					}
 					cipher, _ = oldSettings["method"].(string)
 				}
-				err1 := rt.AddUser(context.Background(), inbound, map[string]any{
+				clientMap := map[string]any{
 					"email":    client.Email,
 					"id":       client.ID,
 					"auth":     client.Auth,
@@ -634,14 +646,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 					"flow":     client.Flow,
 					"password": client.Password,
 					"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 {
-					logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
-					needRestart = true
+					reenablePlan = &trafficLocalApplyPlan{action: trafficAddUser, inbound: *inbound, client: clientMap}
 				}
 				break
 			}
@@ -656,7 +665,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
 	now := time.Now().UnixMilli()
 	inbound, err := s.GetInbound(id)
 	if err != nil {
-		return false, err
+		return false, nil, err
 	}
 	if err := db.Transaction(func(tx *gorm.DB) error {
 		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 {
 			return err
 		}
+		if reenableNodeID != nil {
+			return (&NodeService{}).MarkNodeDirtyTx(tx, *reenableNodeID)
+		}
 		if inbound != nil && inbound.NodeID != nil {
 			return (&NodeService{}).MarkNodeDirtyTx(tx, *inbound.NodeID)
 		}
 		return 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 {
-			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 {
@@ -740,16 +754,24 @@ func (s *InboundService) propagateResetAllTrafficsToNodes() {
 }
 
 func (s *InboundService) ResetInboundTraffic(id int) error {
+	var inbound *model.Inbound
 	if err := submitTrafficWrite(func() error {
-		return database.GetDB().Model(model.Inbound{}).
+		db := database.GetDB()
+		if err := db.Model(model.Inbound{}).
 			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 {
 		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 e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
 				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) {
 	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 {
+				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)
+			}
+			if removed == 0 {
 				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
 			}
-			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
 		}
-		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
 	}
-	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

+ 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
 	}
 	allowed := nodeSelectedTagSet(n)
+	for _, tag := range snap.ManagedAliases {
+		allowed[tag] = struct{}{}
+	}
 	filtered := make([]*model.Inbound, 0, len(snap.Inbounds))
 	for _, inbound := range snap.Inbounds {
 		if inbound == nil {

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

@@ -2,11 +2,13 @@ package service
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"sync/atomic"
 	"testing"
 
 	"github.com/google/uuid"
+	"gorm.io/gorm"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
 	"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) {
 	setupBulkDB(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)
 	}
 }
+
+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)
 	}
 }
+
+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{}
 
 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,

+ 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
 // subscription, picking the smallest free N (excludeId skips a subscription's
 // own current prefix when editing).
-func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string {
+func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) (string, error) {
 	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) {
@@ -175,11 +177,16 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 	}
 	prefix := strings.TrimSpace(tagPrefix)
 	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.
 	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{
 		Remark:         strings.TrimSpace(remark),
 		Url:            cleanURL,
@@ -215,7 +222,10 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	}
 	prefix := strings.TrimSpace(tagPrefix)
 	if prefix == "" {
-		prefix = s.nextDefaultSubPrefix(sub.Id)
+		prefix, err = s.nextDefaultSubPrefix(sub.Id)
+		if err != nil {
+			return err
+		}
 	}
 	sub.Remark = strings.TrimSpace(remark)
 	sub.Url = cleanURL

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

@@ -5,10 +5,102 @@ import (
 	"errors"
 	"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/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) {
 	t.Run("accepts body at the limit", func(t *testing.T) {
 		want := bytes.Repeat([]byte("a"), int(maxOutboundSubscriptionBytes))

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

@@ -5,6 +5,8 @@ import (
 	"errors"
 	"strings"
 
+	"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"
@@ -86,6 +88,28 @@ func (s *ApiTokenService) Create(name string) (*ApiTokenView, error) {
 	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 {
 	if id <= 0 {
 		return common.NewError("invalid token id")

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

@@ -1,6 +1,17 @@
 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) {
 	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":            "",
 	"nodeMtlsClientCertPem":       "",
 	"nodeMtlsClientKeyPem":        "",
+	"nodeMtlsClientCertSha256":    "",
 	"nodeMtlsClientCAPem":         "",
 	"webBasePath":                 normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")),
 	"sessionMaxAge":               "360",

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

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

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

@@ -1,17 +1,30 @@
 package service
 
 import (
+	"crypto/sha256"
+	"crypto/tls"
 	"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/crypto"
 )
 
+var masterClientCredentialMu sync.Mutex
+
 const (
 	settingNodeMtlsCaCert     = "nodeMtlsCaCertPem"
 	settingNodeMtlsCaKey      = "nodeMtlsCaKeyPem"
 	settingNodeMtlsClientCert = "nodeMtlsClientCertPem"
 	settingNodeMtlsClientKey  = "nodeMtlsClientKeyPem"
+	settingNodeMtlsClientPin  = "nodeMtlsClientCertSha256"
 	settingNodeMtlsClientCA   = "nodeMtlsClientCAPem"
 )
 
@@ -49,10 +62,26 @@ func (s *SettingService) EnsureNodeMtlsCA() (crypto.CertKeyPEM, error) {
 	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
 // calling its nodes over mTLS, issuing it from the node CA on first use and
 // reusing the stored pair thereafter.
 func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
+	masterClientCredentialMu.Lock()
+	defer masterClientCredentialMu.Unlock()
+
 	certPem, err := s.getString(settingNodeMtlsClientCert)
 	if err != nil {
 		return crypto.CertKeyPEM{}, err
@@ -61,7 +90,27 @@ func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) {
 	if err != nil {
 		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 _, 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
 	}
 	// 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 {
 		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
 	}
-	if err := s.saveSetting(settingNodeMtlsClientKey, string(client.KeyPEM)); err != nil {
+	if err := saveMasterClientCredential(client, pin); err != nil {
 		return crypto.CertKeyPEM{}, err
 	}
 	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
 // 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

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

@@ -6,8 +6,10 @@ import (
 	"encoding/pem"
 	"path/filepath"
 	"testing"
+	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
 )
 
 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) {
 	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,
 	}
 
-	go func() {
-		_ = s.httpServer.Serve(listener)
-	}()
+	go network.ServeHTTP(s.httpServer, listener, "Web server")
 
 	// Create event bus before startTask so jobs can use it
 	s.bus = eventbus.New(eventbus.DefaultBufferSize)

+ 8 - 5
main.go

@@ -12,7 +12,6 @@ import (
 	"os"
 	"os/signal"
 	"syscall"
-	"time"
 	_ "unsafe"
 
 	"github.com/mhsanaei/3x-ui/v3/internal/config"
@@ -32,6 +31,10 @@ import (
 	"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.
 func runWebServer() {
 	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.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 {
 			fmt.Println("Failed to create a fallback API token:", err)
 			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)
 		return
 	}