8 次代碼提交 09617f04f5 ... 17d7dd46b5

作者 SHA1 備註 提交日期
  MHSanaei 17d7dd46b5 docs(media): refresh panel screenshots for v3.8.5 16 小時之前
  MHSanaei feb8451bd1 fix(clients): zero traffic before re-enabling a client on reset 16 小時之前
  MHSanaei 63ffc083e4 fix(clients): stop client ops from reverting a renewal committed mid-op 16 小時之前
  MHSanaei 15d82a5e47 fix(inbounds): accept v2.x client fields on inbound import 23 小時之前
  MHSanaei 092cbd55e4 fix(x-ui.sh): read the service state without scanning the journal 1 天之前
  MHSanaei c8a182b6fb fix(wireguard): reject allowedIPs that overlap another client's range 1 天之前
  MHSanaei 4df570b3b0 fix(tgbot): build a client's individual links in-process 1 天之前
  MHSanaei 3308c816db fix(i18n): scope the empty Min Client Ver hint to cores that honour it 1 天之前
共有 58 個文件被更改,包括 967 次插入 和 231 次删除
  1. 14 10
      docs/content/docs/en/reference/api/clients.mdx
  2. 0 0
      docs/public/openapi.json
  3. 0 0
      frontend/public/openapi.json
  4. 0 0
      frontend/src/pages/api-docs/endpoints.ts
  5. 1 27
      internal/database/db.go
  6. 27 0
      internal/database/model/client_legacy_fields.go
  7. 4 4
      internal/web/service/client_amneziawg.go
  8. 6 4
      internal/web/service/client_bulk.go
  9. 18 16
      internal/web/service/client_inbound_apply.go
  10. 34 28
      internal/web/service/client_traffic.go
  11. 91 0
      internal/web/service/client_traffic_reset_order_test.go
  12. 59 14
      internal/web/service/client_wireguard.go
  13. 28 0
      internal/web/service/client_wireguard_test.go
  14. 1 0
      internal/web/service/inbound.go
  15. 44 0
      internal/web/service/inbound_import_legacy_fields_test.go
  16. 25 0
      internal/web/service/inbound_settings_clients.go
  17. 141 0
      internal/web/service/inbound_settings_commit.go
  18. 336 0
      internal/web/service/inbound_settings_stale_write_test.go
  19. 1 14
      internal/web/service/tgbot/tgbot.go
  20. 33 100
      internal/web/service/tgbot/tgbot_client.go
  21. 89 0
      internal/web/service/tgbot/tgbot_individual_links_test.go
  22. 1 1
      internal/web/translation/ar-EG.json
  23. 1 1
      internal/web/translation/en-US.json
  24. 1 1
      internal/web/translation/es-ES.json
  25. 1 1
      internal/web/translation/fa-IR.json
  26. 1 1
      internal/web/translation/id-ID.json
  27. 1 1
      internal/web/translation/ja-JP.json
  28. 1 1
      internal/web/translation/pt-BR.json
  29. 1 1
      internal/web/translation/ru-RU.json
  30. 1 1
      internal/web/translation/tr-TR.json
  31. 1 1
      internal/web/translation/uk-UA.json
  32. 1 1
      internal/web/translation/vi-VN.json
  33. 1 1
      internal/web/translation/zh-CN.json
  34. 1 1
      internal/web/translation/zh-TW.json
  35. 二進制
      media/01-overview-dark.png
  36. 二進制
      media/01-overview-light.png
  37. 二進制
      media/02-add-inbound-dark.png
  38. 二進制
      media/02-add-inbound-light.png
  39. 二進制
      media/02-inbounds-dark.png
  40. 二進制
      media/02-inbounds-light.png
  41. 二進制
      media/03-add-client-dark.png
  42. 二進制
      media/03-add-client-light.png
  43. 二進制
      media/03-client-dark.png
  44. 二進制
      media/03-client-light.png
  45. 二進制
      media/04-group-dark.png
  46. 二進制
      media/04-group-light.png
  47. 二進制
      media/05-add-nodes-dark.png
  48. 二進制
      media/05-add-nodes-light.png
  49. 二進制
      media/05-nodes-dark.png
  50. 二進制
      media/05-nodes-light.png
  51. 二進制
      media/06-settings-dark.png
  52. 二進制
      media/06-settings-light.png
  53. 二進制
      media/07-configs-dark.png
  54. 二進制
      media/07-configs-light.png
  55. 二進制
      media/08-api-docs-dark.png
  56. 二進制
      media/08-api-docs-light.png
  57. 二進制
      media/panel-bot-config.png
  58. 2 1
      x-ui.sh

+ 14 - 10
docs/content/docs/en/reference/api/clients.mdx

@@ -592,10 +592,14 @@ _openapi:
           the search to the containing /16 before giving up with `inbound <id>:
           wireguard: no free address available in <scope>`, and an `allowedIPs`
           supplied by the caller is validated instead of allocated: `inbound
-          <id>: wireguard: allowedIPs entry already used by another client:
-          <address>` when a different client of that same inbound already holds
-          it. The check is per inbound, so the same address on two different
-          inbounds is accepted. The same validation runs on POST
+          <id>: wireguard: allowedIPs entry <entry> overlaps <address> used by
+          another client` when its range overlaps an address or prefix a
+          different client of that same inbound holds, or `... used by a client
+          on <inbound>` when the holder sits on another WireGuard or AmneziaWG
+          inbound. Ranges are compared, not strings, so `10.0.0.9/24` collides
+          with `10.0.0.5/32`; a `0.0.0.0/0` or `::/0` default route claims no
+          address. Allocation likewise skips every address inside a prefix
+          another client holds. The same validation runs on POST
           /panel/api/clients/{email}/attach, where a client that already carries
           an address brings it along.
 
@@ -640,12 +644,12 @@ _openapi:
         heading: delete-a-client-by-email-removes-it-from-every-attached-inbound-and-drops-its-traffic-record-unless-keeptraffic1-is-passed
       - content: 'A WireGuard client brings its stored `allowedIPs` into the new inbound
           instead of being given a fresh address, so the call fails with
-          `inbound <id>: wireguard: allowedIPs entry already used by another
-          client: <address>` when a different client of the target inbound
-          already holds it. Free the address on that inbound first — see POST
-          /panel/api/clients/add for the full rule. Inbounds are applied
-          independently, so the remaining ones are still attached and a
-          `success:false` response can be partial.'
+          `inbound <id>: wireguard: allowedIPs entry <entry> overlaps <address>
+          used by another client` when its range overlaps an address or prefix a
+          different client of the target inbound holds. Free the address on that
+          inbound first — see POST /panel/api/clients/add for the full rule.
+          Inbounds are applied independently, so the remaining ones are still
+          attached and a `success:false` response can be partial.'
         heading: attach-an-existing-client-to-one-or-more-additional-inbounds-body-is-json
       - content: 'The inbounds are applied concurrently and independently: one that
           fails no longer stops the others. Every inbound error names the

File diff suppressed because it is too large
+ 0 - 0
docs/public/openapi.json


File diff suppressed because it is too large
+ 0 - 0
frontend/public/openapi.json


File diff suppressed because it is too large
+ 0 - 0
frontend/src/pages/api-docs/endpoints.ts


+ 1 - 27
internal/database/db.go

@@ -2521,32 +2521,6 @@ func isAllowOnlyFinalRules(v any) bool {
 	return true
 }
 
-func normalizeClientJSONFields(obj map[string]any) {
-	normalizeInt := func(key string) {
-		raw, exists := obj[key]
-		if !exists {
-			return
-		}
-		s, ok := raw.(string)
-		if !ok {
-			return
-		}
-		trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
-		if trimmed == "" {
-			delete(obj, key)
-			return
-		}
-		if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
-			obj[key] = n
-		} else {
-			delete(obj, key)
-		}
-	}
-	for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
-		normalizeInt(k)
-	}
-}
-
 func seedClientsFromInboundJSON() error {
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
@@ -2583,7 +2557,7 @@ func seedClientsFromInboundJSON() error {
 				if !ok {
 					continue
 				}
-				normalizeClientJSONFields(obj)
+				model.NormalizeLegacyClientFields(obj)
 				blob, err := json.Marshal(obj)
 				if err != nil {
 					continue

+ 27 - 0
internal/database/model/client_legacy_fields.go

@@ -0,0 +1,27 @@
+package model
+
+import (
+	"strconv"
+	"strings"
+)
+
+var legacyClientIntFields = []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"}
+
+// NormalizeLegacyClientFields turns the string numbers older panels stored on a
+// client object into integers; an empty or unparseable value is dropped.
+func NormalizeLegacyClientFields(obj map[string]any) (changed bool) {
+	for _, key := range legacyClientIntFields {
+		s, ok := obj[key].(string)
+		if !ok {
+			continue
+		}
+		changed = true
+		trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
+		if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
+			obj[key] = n
+		} else {
+			delete(obj, key)
+		}
+	}
+	return changed
+}

+ 4 - 4
internal/web/service/client_amneziawg.go

@@ -93,11 +93,11 @@ func defaultAmneziaWGClients(settingsJSON string, existing, clients []model.Clie
 			if len(normalized) == 0 {
 				return common.NewError("amneziawg: allowedIPs has no usable entry")
 			}
-			if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
-				if where := crossInboundUsed[hit]; where != "" {
-					return common.NewError("amneziawg: allowedIPs entry", hit, "is already used by a client on", where)
+			if entry, taken := wireguardAllowedIPsOverlap(normalized, used); taken != "" {
+				if where := crossInboundUsed[taken]; where != "" {
+					return common.NewError("amneziawg: allowedIPs entry", entry, "overlaps", taken, "used by a client on", where)
 				}
-				return common.NewError("amneziawg: allowedIPs entry already used by another client:", hit)
+				return common.NewError("amneziawg: allowedIPs entry", entry, "overlaps", taken, "used by another client")
 			}
 			c.AllowedIPs = normalized
 		}

+ 6 - 4
internal/web/service/client_bulk.go

@@ -795,6 +795,7 @@ 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
@@ -807,7 +808,7 @@ func (s *ClientService) bulkAdjustInboundClients(
 	// 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 {
+		if err := commitInboundClientSettings(tx, oldInbound, prevSettings); err != nil {
 			return err
 		}
 		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
@@ -1112,6 +1113,7 @@ func (s *ClientService) bulkDelInboundClients(
 		}
 		return res
 	}
+	prevSettings := oldInbound.Settings
 	oldInbound.Settings = string(newSettings)
 
 	foundList := make([]string, 0, len(foundEmails))
@@ -1181,7 +1183,7 @@ func (s *ClientService) bulkDelInboundClients(
 	// 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 {
+		if err := commitInboundClientSettings(tx, oldInbound, prevSettings); err != nil {
 			return err
 		}
 		finalClients, err := inboundSvc.GetClients(oldInbound)
@@ -1780,7 +1782,7 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 	}
 
 	txErr := runSerializedTx(func(tx *gorm.DB) error {
-		if e := tx.Save(oldInbound).Error; e != nil {
+		if e := commitInboundClientSettings(tx, oldInbound, prevSettings); e != nil {
 			return e
 		}
 		finalClients, gcErr := inboundSvc.GetClients(oldInbound)
@@ -1850,7 +1852,7 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService,
 			}
 		}
 		if !pushFailed {
-			advancePushedInbound(rt, prevSettings, oldInbound)
+			advancePushedInbound(rt, prevSettings, string(newSettings), oldInbound)
 		}
 	}
 

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

@@ -39,16 +39,18 @@ func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool {
 	return aerr == nil && berr == nil && string(an) == string(bn)
 }
 
-// advancePushedInbound advances the node's reconcile-skip fingerprint from the
-// pre-edit settings to the saved ones after every per-client push succeeded.
-func advancePushedInbound(rt runtime.Runtime, prevSettings string, ib *model.Inbound) {
+// advancePushedInbound advances the node's reconcile-skip fingerprint to what the
+// per-client pushes delivered, not the saved settings a traffic tick may have extended.
+func advancePushedInbound(rt runtime.Runtime, prevSettings, pushedSettings string, ib *model.Inbound) {
 	rem, ok := rt.(*runtime.Remote)
 	if !ok {
 		return
 	}
 	prev := *ib
 	prev.Settings = prevSettings
-	rem.AdvancePushedInbound(&prev, ib)
+	pushed := *ib
+	pushed.Settings = pushedSettings
+	rem.AdvancePushedInbound(&prev, &pushed)
 }
 
 // delInboundClients removes several clients from a single inbound in one pass:
@@ -184,7 +186,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 				}
 			}
 		}
-		if e := tx.Save(oldInbound).Error; e != nil {
+		if e := commitInboundClientSettings(tx, oldInbound, prevSettings); e != nil {
 			return e
 		}
 		detached := make([]string, 0, len(targets))
@@ -249,7 +251,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
 		}
 	}
 	if nodePush && !nodePushFailed {
-		advancePushedInbound(nodeRt, prevSettings, oldInbound)
+		advancePushedInbound(nodeRt, prevSettings, string(newSettings), oldInbound)
 	}
 
 	return needRestart, nil
@@ -539,8 +541,8 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 				crossAddrs = append(crossAddrs, addr)
 			}
 			for i := range clients {
-				if hit := wireguardAllowedIPsCollision(clients[i].AllowedIPs, crossAddrs); hit != "" {
-					return common.NewError("allowedIPs entry", hit, "is already used by a client on", crossUsed[hit])
+				if entry, taken := wireguardAllowedIPsOverlap(clients[i].AllowedIPs, crossAddrs); taken != "" {
+					return common.NewError("allowedIPs entry", entry, "overlaps", taken, "used by a client on", crossUsed[taken])
 				}
 			}
 		}
@@ -563,7 +565,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 				return e
 			}
 		}
-		if e := tx.Save(oldInbound).Error; e != nil {
+		if e := commitInboundClientSettings(tx, oldInbound, prevSettings); e != nil {
 			return e
 		}
 		if err := s.ApplyInboundClientDelta(tx, oldInbound.Id, addedClients, nil); err != nil {
@@ -648,7 +650,7 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model
 			}
 		}
 		if push {
-			advancePushedInbound(rt, prevSettings, oldInbound)
+			advancePushedInbound(rt, prevSettings, string(newSettings), oldInbound)
 		}
 	}
 
@@ -760,8 +762,8 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 					}
 					peers = append(peers, oldClients[i].AllowedIPs...)
 				}
-				if hit := wireguardAllowedIPsCollision(normalized, peers); hit != "" {
-					return false, common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
+				if entry, taken := wireguardAllowedIPsOverlap(normalized, peers); taken != "" {
+					return false, common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client")
 				}
 				clients[0].AllowedIPs = normalized
 			}
@@ -980,7 +982,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 			}
 		}
 
-		if e := tx.Save(oldInbound).Error; e != nil {
+		if e := commitInboundClientSettings(tx, oldInbound, prevSettings); e != nil {
 			return e
 		}
 		// Rename the client record in the same transaction as the settings JSON
@@ -1074,7 +1076,7 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
 			if err1 != nil {
 				logger.Warning("Error in updating client on", rt.Name(), ":", err1)
 			} else {
-				advancePushedInbound(rt, prevSettings, oldInbound)
+				advancePushedInbound(rt, prevSettings, string(newSettings), oldInbound)
 			}
 		}
 	} else {
@@ -1184,7 +1186,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 				return e
 			}
 		}
-		if e := tx.Save(oldInbound).Error; e != nil {
+		if e := commitInboundClientSettings(tx, oldInbound, prevSettings); e != nil {
 			return e
 		}
 		if err := s.ApplyInboundClientDelta(tx, inboundId, nil, []string{email}); err != nil {
@@ -1248,7 +1250,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
 				if err1 != nil {
 					logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
 				} else {
-					advancePushedInbound(rt, prevSettings, oldInbound)
+					advancePushedInbound(rt, prevSettings, string(newSettings), oldInbound)
 				}
 			}
 		}

+ 34 - 28
internal/web/service/client_traffic.go

@@ -26,6 +26,26 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 	}
 
 	needRestart := false
+	if len(inboundIds) == 0 {
+		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
+			return false, rErr
+		}
+	} else {
+		applies := make([]inboundApply, 0, len(inboundIds))
+		for _, ibId := range inboundIds {
+			applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
+				return inboundSvc.ResetClientTraffic(ibId, email)
+			}})
+		}
+		nr, applyErr := fanoutInboundApplies(applies)
+		if applyErr != nil {
+			return nr, applyErr
+		}
+		needRestart = nr
+	}
+
+	// Enable only once the counters are zero: a still-depleted client enabled
+	// first is switched off again by the next traffic tick.
 	if !rec.Enable {
 		updated := rec.ToClient()
 		updated.Enable = true
@@ -37,22 +57,7 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
 			needRestart = true
 		}
 	}
-
-	if len(inboundIds) == 0 {
-		if rErr := inboundSvc.ResetClientTrafficByEmail(email); rErr != nil {
-			return false, rErr
-		}
-		return needRestart, nil
-	}
-
-	applies := make([]inboundApply, 0, len(inboundIds))
-	for _, ibId := range inboundIds {
-		applies = append(applies, inboundApply{id: ibId, run: func() (bool, error) {
-			return inboundSvc.ResetClientTraffic(ibId, email)
-		}})
-	}
-	nr, applyErr := fanoutInboundApplies(applies)
-	return needRestart || nr, applyErr
+	return needRestart, nil
 }
 
 func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []string) (int, error) {
@@ -68,18 +73,6 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 	if err != nil {
 		return 0, err
 	}
-	for _, e := range cleanEmails {
-		rec := recordsByEmail[e]
-		if rec == nil || rec.Enable {
-			continue
-		}
-		updated := rec.ToClient()
-		updated.Enable = true
-		if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
-			logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
-		}
-	}
-
 	affected := 0
 	err = submitTrafficWrite(func() error {
 		db := database.GetDB()
@@ -110,6 +103,19 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
 	if err != nil {
 		return 0, err
 	}
+	// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
+	// client first lets the next traffic tick switch it off again.
+	for _, e := range cleanEmails {
+		rec := recordsByEmail[e]
+		if rec == nil || rec.Enable {
+			continue
+		}
+		updated := rec.ToClient()
+		updated.Enable = true
+		if _, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid); uErr != nil {
+			logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
+		}
+	}
 	return affected, nil
 }
 

+ 91 - 0
internal/web/service/client_traffic_reset_order_test.go

@@ -0,0 +1,91 @@
+package service
+
+import (
+	"testing"
+	"time"
+
+	"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"
+)
+
+// A reset of a quota-disabled client must end enabled even when a traffic tick
+// runs between its steps: enabling before zeroing lets the tick re-disable it.
+func TestResetTrafficOfDepletedClientSurvivesTickMidReset(t *testing.T) {
+	resets := []struct {
+		name string
+		run  func() error
+	}{
+		{"single", func() error {
+			_, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, "d@stale")
+			return err
+		}},
+		{"bulk", func() error {
+			_, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{"d@stale"})
+			return err
+		}},
+	}
+	for _, reset := range resets {
+		t.Run(reset.name, func(t *testing.T) {
+			requireResetSurvivesTick(t, reset.run)
+		})
+	}
+}
+
+func requireResetSurvivesTick(t *testing.T, reset func() error) {
+	t.Helper()
+	setupBulkDB(t)
+	clients := []model.Client{{Email: "d@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000d", SubID: "sub-d", Enable: false, TotalGB: 1000}}
+	ib := mkInbound(t, 23140, model.VLESS, clientsSettings(t, clients))
+	if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	row := xray.ClientTraffic{InboundId: ib.Id, Email: "d@stale", Enable: false, Up: 600, Down: 400, Total: 1000}
+	if err := database.GetDB().Create(&row).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+
+	resetTrafficWriterForTest(t)
+	StartTrafficWriter()
+	parked := make(chan struct{})
+	release := make(chan struct{})
+	go func() {
+		_ = submitTrafficWrite(func() error {
+			close(parked)
+			<-release
+			return nil
+		})
+	}()
+	<-parked
+
+	resetDone := make(chan error, 1)
+	go func() { resetDone <- reset() }()
+	waitTrafficWriterQueued(t)
+	tickDone := make(chan error, 1)
+	go func() {
+		_, _, err := (&InboundService{}).AddTraffic(nil, nil)
+		tickDone <- err
+	}()
+	deadline := time.Now().Add(5 * time.Second)
+	for len(twQueue) < 2 && time.Now().Before(deadline) {
+		time.Sleep(time.Millisecond)
+	}
+	close(release)
+	if err := <-resetDone; err != nil {
+		t.Fatalf("reset: %v", err)
+	}
+	if err := <-tickDone; err != nil {
+		t.Fatalf("AddTraffic: %v", err)
+	}
+
+	var after xray.ClientTraffic
+	if err := database.GetDB().Where("email = ?", "d@stale").First(&after).Error; err != nil {
+		t.Fatalf("read client_traffics: %v", err)
+	}
+	c, _ := settingsClient(t, ib.Id, "d@stale")
+	rec := lookupClientRecord(t, "d@stale")
+	if !after.Enable || !c.Enable || !rec.Enable || after.Up+after.Down != 0 {
+		t.Fatalf("after reset: traffic enable=%v used=%d, settings enable=%v, record enable=%v; want all enabled at 0",
+			after.Enable, after.Up+after.Down, c.Enable, rec.Enable)
+	}
+}

+ 59 - 14
internal/web/service/client_wireguard.go

@@ -105,11 +105,29 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s
 		hostBits = "128"
 	}
 	taken := make(map[netip.Addr]struct{}, len(used))
+	var wide []netip.Prefix
 	for _, u := range used {
-		if a := wireguardHostAddr(u); a.IsValid() {
-			taken[a] = struct{}{}
+		p, ok := wireguardClaimedPrefix(u)
+		if !ok {
+			continue
+		}
+		if p.IsSingleIP() {
+			taken[p.Addr()] = struct{}{}
+		} else {
+			wide = append(wide, p)
 		}
 	}
+	isTaken := func(a netip.Addr) bool {
+		if _, ok := taken[a]; ok {
+			return true
+		}
+		for _, p := range wide {
+			if p.Contains(a) {
+				return true
+			}
+		}
+		return false
+	}
 	scopes := []netip.Prefix{prefix}
 	if allowWidening && prefix.Addr().Is4() && prefix.Bits() > wireguardPoolFloorBits {
 		if wider, wErr := prefix.Addr().Prefix(wireguardPoolFloorBits); wErr == nil {
@@ -119,7 +137,7 @@ func allocateWireguardAddress(used []string, base string, allowWidening bool) (s
 	for _, scope := range scopes {
 		addr := scope.Masked().Addr().Next().Next()
 		for scope.Contains(addr) {
-			if _, ok := taken[addr]; !ok {
+			if !isTaken(addr) {
 				return addr.String() + "/" + hostBits, nil
 			}
 			addr = addr.Next()
@@ -156,17 +174,44 @@ func normalizeWireguardAllowedIPs(values []string) ([]string, error) {
 	return out, nil
 }
 
-func wireguardAllowedIPsCollision(entries, used []string) string {
-	taken := make(map[string]struct{}, len(used))
-	for _, u := range used {
-		taken[strings.TrimSpace(u)] = struct{}{}
+// wireguardClaimedPrefix is the masked range an allowedIPs entry claims, as xray
+// reads it. A /0 default route claims no tunnel address, as legacy peers carry it.
+func wireguardClaimedPrefix(s string) (netip.Prefix, bool) {
+	s = strings.TrimSpace(s)
+	p, err := netip.ParsePrefix(s)
+	if err != nil {
+		a, aErr := netip.ParseAddr(s)
+		if aErr != nil {
+			return netip.Prefix{}, false
+		}
+		p = netip.PrefixFrom(a, a.BitLen())
+	}
+	if p.Bits() == 0 {
+		return netip.Prefix{}, false
+	}
+	return p.Masked(), true
+}
+
+// wireguardAllowedIPsOverlap returns the first entry whose range overlaps a used
+// one, and that used entry; xray routes and attributes by containment, not equality.
+func wireguardAllowedIPsOverlap(entries, used []string) (entry, taken string) {
+	usedPrefixes := make([]netip.Prefix, len(used))
+	usedOK := make([]bool, len(used))
+	for i, u := range used {
+		usedPrefixes[i], usedOK[i] = wireguardClaimedPrefix(u)
 	}
 	for _, e := range entries {
-		if _, ok := taken[e]; ok {
-			return e
+		ep, ok := wireguardClaimedPrefix(e)
+		if !ok {
+			continue
+		}
+		for i, up := range usedPrefixes {
+			if usedOK[i] && ep.Overlaps(up) {
+				return e, used[i]
+			}
 		}
 	}
-	return ""
+	return "", ""
 }
 
 // defaultWireguardClients fills in blank WireGuard credentials for newly added
@@ -231,11 +276,11 @@ func defaultWireguardClients(settingsJSON string, existing, clients []model.Clie
 			if len(normalized) == 0 {
 				return common.NewError("wireguard: allowedIPs has no usable entry")
 			}
-			if hit := wireguardAllowedIPsCollision(normalized, used); hit != "" {
-				if where := crossInboundUsed[hit]; where != "" {
-					return common.NewError("wireguard: allowedIPs entry", hit, "is already used by a client on", where)
+			if entry, taken := wireguardAllowedIPsOverlap(normalized, used); taken != "" {
+				if where := crossInboundUsed[taken]; where != "" {
+					return common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by a client on", where)
 				}
-				return common.NewError("wireguard: allowedIPs entry already used by another client:", hit)
+				return common.NewError("wireguard: allowedIPs entry", entry, "overlaps", taken, "used by another client")
 			}
 			c.AllowedIPs = normalized
 		}

+ 28 - 0
internal/web/service/client_wireguard_test.go

@@ -362,3 +362,31 @@ func TestDefaultWireguardClientsFallsBackWhenNoExplicitSubnet(t *testing.T) {
 		t.Fatalf("with no explicit subnet, inference from existing clients must still apply; got %v", got)
 	}
 }
+
+// xray credits a packet to the first peer whose allowedIPs contain its source,
+// so a prefix covering another client's address steals that client's traffic.
+func TestDefaultWireguardClientsRejectsOverlappingAllowedIPs(t *testing.T) {
+	existing := []model.Client{{Email: "a@wg", AllowedIPs: []string{"10.10.2.9/24"}}}
+	clients := []model.Client{{Email: "b@wg", AllowedIPs: []string{"10.10.2.52/24"}}}
+	err := defaultWireguardClients("", existing, clients, []any{map[string]any{"email": "b@wg"}}, nil)
+	if err == nil || !strings.Contains(err.Error(), "10.10.2.52/24") || !strings.Contains(err.Error(), "10.10.2.9/24") {
+		t.Fatalf("overlapping allowedIPs must be rejected naming both entries, got: %v", err)
+	}
+
+	crossUsed := map[string]string{"10.8.1.0/24": "inbound 'awg' (#10)"}
+	inside := []model.Client{{Email: "c@wg", AllowedIPs: []string{"10.8.1.21/32"}}}
+	err = defaultWireguardClients("", nil, inside, []any{map[string]any{"email": "c@wg"}}, crossUsed)
+	if err == nil || !strings.Contains(err.Error(), "inbound 'awg' (#10)") {
+		t.Fatalf("an address inside another inbound's prefix must be rejected naming that inbound, got: %v", err)
+	}
+}
+
+func TestAllocateWireguardAddressSkipsAddressesInsideUsedPrefixes(t *testing.T) {
+	got, err := allocateWireguardAddress([]string{"10.0.0.2/31"}, "10.0.0.0/24", true)
+	if err != nil {
+		t.Fatalf("allocateWireguardAddress: %v", err)
+	}
+	if got != "10.0.0.4/32" {
+		t.Fatalf("got %s, want 10.0.0.4/32: .2 and .3 are both inside the used 10.0.0.2/31", got)
+	}
+}

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

@@ -1129,6 +1129,7 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
 	}
 	inbound.Tag = tag
 
+	normalizeLegacyClientSettings(inbound)
 	clients, err := s.GetClients(inbound)
 	if err != nil {
 		return inbound, false, err

+ 44 - 0
internal/web/service/inbound_import_legacy_fields_test.go

@@ -0,0 +1,44 @@
+package service
+
+import (
+	"encoding/json"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// Exports from v2.x store tgId as a string ("" when unset); the stored copies
+// are healed by a startup migration, but an imported export never passes it.
+func TestAddInboundAcceptsLegacyStringClientFields(t *testing.T) {
+	setupConflictDB(t)
+	settings := `{"clients":[` +
+		`{"id":"11111111-1111-1111-1111-111111111111","email":"legacy-a","tgId":"","subId":"s-a","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0},` +
+		`{"id":"22222222-2222-2222-2222-222222222222","email":"legacy-b","tgId":"123456","subId":"s-b","enable":true,"limitIp":0,"totalGB":0,"expiryTime":0,"reset":0}` +
+		`],"decryption":"none","fallbacks":[]}`
+	in := makeImportInbound("in-9201-tcp", 9201, settings, nil)
+
+	saved, _, err := (&InboundService{}).AddInbound(in)
+	if err != nil {
+		t.Fatalf("AddInbound: %v", err)
+	}
+
+	var rec model.ClientRecord
+	if err := database.GetDB().Where("email = ?", "legacy-b").First(&rec).Error; err != nil {
+		t.Fatalf("read client: %v", err)
+	}
+	if rec.TgID != 123456 {
+		t.Fatalf("client tgId = %d, want 123456 parsed from the string", rec.TgID)
+	}
+	var stored struct {
+		Clients []map[string]any `json:"clients"`
+	}
+	if err := json.Unmarshal([]byte(saved.Settings), &stored); err != nil {
+		t.Fatalf("stored settings: %v", err)
+	}
+	for _, c := range stored.Clients {
+		if _, isString := c["tgId"].(string); isString {
+			t.Fatalf("stored settings keep a string tgId for %v: %v", c["email"], c["tgId"])
+		}
+	}
+}

+ 25 - 0
internal/web/service/inbound_settings_clients.go

@@ -1,6 +1,7 @@
 package service
 
 import (
+	"bytes"
 	"encoding/json"
 	"strings"
 
@@ -48,3 +49,27 @@ func settingsEntriesToClients(entries []any) ([]model.Client, error) {
 	}
 	return clients, nil
 }
+
+// normalizeLegacyClientSettings rewrites settings whose clients still carry the
+// string numbers of a v2.x export, so parsing and storage see a single shape.
+func normalizeLegacyClientSettings(inbound *model.Inbound) {
+	dec := json.NewDecoder(bytes.NewReader([]byte(inbound.Settings)))
+	dec.UseNumber()
+	var settings map[string]any
+	if err := dec.Decode(&settings); err != nil {
+		return
+	}
+	clients, _ := settings["clients"].([]any)
+	changed := false
+	for _, raw := range clients {
+		if obj, ok := raw.(map[string]any); ok && model.NormalizeLegacyClientFields(obj) {
+			changed = true
+		}
+	}
+	if !changed {
+		return
+	}
+	if out, err := json.MarshalIndent(settings, "", "  "); err == nil {
+		inbound.Settings = string(out)
+	}
+}

+ 141 - 0
internal/web/service/inbound_settings_commit.go

@@ -0,0 +1,141 @@
+package service
+
+import (
+	"encoding/json"
+	"reflect"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+
+	"gorm.io/gorm"
+)
+
+// commitInboundClientSettings writes a client op's edit (base → ib.Settings) onto
+// the settings committed now: a traffic tick after the op's read must survive.
+func commitInboundClientSettings(tx *gorm.DB, ib *model.Inbound, base string) error {
+	var current []string
+	if err := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Pluck("settings", &current).Error; err != nil {
+		return err
+	}
+	if len(current) == 1 && current[0] != base {
+		merged, err := rebaseClientSettings(base, ib.Settings, current[0])
+		if err != nil {
+			return err
+		}
+		ib.Settings = merged
+	}
+	return tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", ib.Settings).Error
+}
+
+// rebaseClientSettings three-way merges settings JSON: every key and client field
+// ours left as base had it takes current's value; clients are matched by email.
+func rebaseClientSettings(base, ours, current string) (string, error) {
+	var baseM, oursM, curM map[string]any
+	for _, p := range []struct {
+		raw string
+		dst *map[string]any
+	}{{base, &baseM}, {ours, &oursM}, {current, &curM}} {
+		if err := json.Unmarshal([]byte(p.raw), p.dst); err != nil {
+			return "", err
+		}
+	}
+	out := mergeFields(baseM, oursM, curM)
+	baseClients, _ := baseM["clients"].([]any)
+	oursClients, _ := oursM["clients"].([]any)
+	curClients, _ := curM["clients"].([]any)
+	if _, has := oursM["clients"]; has {
+		out["clients"] = mergeClientLists(baseClients, oursClients, curClients)
+	}
+	b, err := json.MarshalIndent(out, "", "  ")
+	if err != nil {
+		return "", err
+	}
+	return string(b), nil
+}
+
+func mergeFields(base, ours, current map[string]any) map[string]any {
+	out := make(map[string]any, len(current)+len(ours))
+	for k, v := range current {
+		out[k] = v
+	}
+	keys := make(map[string]struct{}, len(base)+len(ours))
+	for k := range base {
+		keys[k] = struct{}{}
+	}
+	for k := range ours {
+		keys[k] = struct{}{}
+	}
+	for k := range keys {
+		bv, inBase := base[k]
+		ov, inOurs := ours[k]
+		if inBase == inOurs && reflect.DeepEqual(bv, ov) {
+			continue
+		}
+		if inOurs {
+			out[k] = ov
+		} else {
+			delete(out, k)
+		}
+	}
+	return out
+}
+
+func clientEntryEmail(entry any) (map[string]any, string) {
+	m, ok := entry.(map[string]any)
+	if !ok {
+		return nil, ""
+	}
+	email, _ := m["email"].(string)
+	return m, email
+}
+
+func indexClientsByEmail(list []any) map[string]map[string]any {
+	out := make(map[string]map[string]any, len(list))
+	for _, entry := range list {
+		if m, email := clientEntryEmail(entry); email != "" {
+			out[email] = m
+		}
+	}
+	return out
+}
+
+// mergeClientLists keeps ours' order. A client ours removed stays removed; one a
+// concurrent writer added or removed keeps that change unless ours edited it.
+func mergeClientLists(base, ours, current []any) []any {
+	baseBy := indexClientsByEmail(base)
+	curBy := indexClientsByEmail(current)
+	out := make([]any, 0, len(ours)+len(current))
+	placed := make(map[string]struct{}, len(ours))
+	for _, entry := range ours {
+		o, email := clientEntryEmail(entry)
+		if email == "" {
+			out = append(out, entry)
+			continue
+		}
+		placed[email] = struct{}{}
+		b, inBase := baseBy[email]
+		c, inCur := curBy[email]
+		switch {
+		case !inBase:
+			out = append(out, o)
+		case !inCur:
+			if !reflect.DeepEqual(b, o) {
+				out = append(out, o)
+			}
+		default:
+			out = append(out, mergeFields(b, o, c))
+		}
+	}
+	for _, entry := range current {
+		c, email := clientEntryEmail(entry)
+		if email == "" {
+			continue
+		}
+		if _, done := placed[email]; done {
+			continue
+		}
+		if _, inBase := baseBy[email]; !inBase {
+			out = append(out, c)
+		}
+	}
+	return out
+}

+ 336 - 0
internal/web/service/inbound_settings_stale_write_test.go

@@ -0,0 +1,336 @@
+package service
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"reflect"
+	"strconv"
+	"testing"
+	"time"
+
+	"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"
+
+	"gorm.io/gorm"
+)
+
+// commitTickBetweenReadAndWrite parks the serial writer, lets op read the
+// inbound and queue its transaction, then commits tick ahead of that transaction.
+func commitTickBetweenReadAndWrite(t *testing.T, tick func(tx *gorm.DB) error, op func()) {
+	t.Helper()
+	resetTrafficWriterForTest(t)
+	StartTrafficWriter()
+
+	parked := make(chan struct{})
+	release := make(chan struct{})
+	tickErr := make(chan error, 1)
+	go func() {
+		tickErr <- submitTrafficWrite(func() error {
+			close(parked)
+			<-release
+			return database.GetDB().Transaction(tick)
+		})
+	}()
+	<-parked
+
+	opDone := make(chan struct{})
+	go func() {
+		defer close(opDone)
+		op()
+	}()
+	waitTrafficWriterQueued(t)
+	close(release)
+	if err := <-tickErr; err != nil {
+		t.Fatalf("tick: %v", err)
+	}
+	<-opDone
+}
+
+// seedRenewableNeighbour builds an inbound holding a healthy client X and a
+// quota-disabled client Y whose auto-renew is due, as the traffic job sees them.
+func seedRenewableNeighbour(t *testing.T, port int, nodeID *int) *model.Inbound {
+	t.Helper()
+	past := time.Now().Add(-time.Hour).UnixMilli()
+	future := time.Now().Add(30 * 24 * time.Hour).UnixMilli()
+	clients := []model.Client{
+		{Email: "x@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000a", SubID: "sub-x", Enable: true, ExpiryTime: future},
+		{Email: "y@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000b", SubID: "sub-y", Enable: false, Reset: 30, ExpiryTime: past, TotalGB: 1000},
+	}
+	ib := &model.Inbound{
+		Tag: "stale-" + strconv.Itoa(port), Enable: true, Port: port, Protocol: model.VLESS,
+		Settings: clientsSettings(t, clients), NodeID: nodeID,
+	}
+	if err := database.GetDB().Create(ib).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	if err := (&ClientService{}).SyncInbound(nil, ib.Id, clients); err != nil {
+		t.Fatalf("SyncInbound: %v", err)
+	}
+	rows := []xray.ClientTraffic{
+		{InboundId: ib.Id, Email: "x@stale", Enable: true, ExpiryTime: future},
+		{InboundId: ib.Id, Email: "y@stale", Enable: false, Up: 600, Down: 400, Total: 1000, Reset: 30, ExpiryTime: past},
+	}
+	if err := database.GetDB().Create(&rows).Error; err != nil {
+		t.Fatalf("seed client_traffics: %v", err)
+	}
+	return ib
+}
+
+func autoRenewTick(tx *gorm.DB) error {
+	_, _, err := (&InboundService{}).autoRenewClients(tx, newTrafficMutationBatch())
+	return err
+}
+
+// renewYTick writes the renewal autoRenewClients would commit for y@stale; it
+// skips clients hosted only on a node, so the node case applies it directly.
+func renewYTick(inboundId int) func(tx *gorm.DB) error {
+	return func(tx *gorm.DB) error {
+		var ib model.Inbound
+		if err := tx.First(&ib, inboundId).Error; err != nil {
+			return err
+		}
+		var settings map[string]any
+		if err := json.Unmarshal([]byte(ib.Settings), &settings); err != nil {
+			return err
+		}
+		for _, c := range settings["clients"].([]any) {
+			if m := c.(map[string]any); m["email"] == "y@stale" {
+				m["enable"] = true
+				m["expiryTime"] = time.Now().Add(30 * 24 * time.Hour).UnixMilli()
+			}
+		}
+		b, err := json.MarshalIndent(settings, "", "  ")
+		if err != nil {
+			return err
+		}
+		return tx.Model(&model.Inbound{}).Where("id = ?", inboundId).Update("settings", string(b)).Error
+	}
+}
+
+func settingsClient(t *testing.T, inboundId int, email string) (model.Client, bool) {
+	t.Helper()
+	ib, err := (&InboundService{}).GetInbound(inboundId)
+	if err != nil {
+		t.Fatalf("GetInbound: %v", err)
+	}
+	clients, err := (&InboundService{}).GetClients(ib)
+	if err != nil {
+		t.Fatalf("GetClients: %v", err)
+	}
+	for _, c := range clients {
+		if c.Email == email {
+			return c, true
+		}
+	}
+	return model.Client{}, false
+}
+
+func requireNeighbourRenewed(t *testing.T, inboundId int) model.Client {
+	t.Helper()
+	y, ok := settingsClient(t, inboundId, "y@stale")
+	if !ok {
+		t.Fatal("neighbour y@stale missing from settings")
+	}
+	if now := time.Now().UnixMilli(); !y.Enable || y.ExpiryTime <= now {
+		t.Fatalf("renewed neighbour rolled back in settings: enable=%v expiryTime=%d (now %d)", y.Enable, y.ExpiryTime, now)
+	}
+	return y
+}
+
+type staleClientOp struct {
+	name string
+	// advancesNodeFingerprint: on a node inbound the op pushes per client and
+	// then advances the reconcile-skip fingerprint.
+	advancesNodeFingerprint bool
+	run                     func(t *testing.T, ib *model.Inbound) error
+}
+
+var staleClientOps = []staleClientOp{
+	{"edit", true, func(t *testing.T, ib *model.Inbound) error {
+		rec := lookupClientRecord(t, "x@stale")
+		edited := rec.ToClient()
+		edited.Comment = "edited"
+		_, err := (&ClientService{}).UpdateInboundClient(&InboundService{}, &model.Inbound{
+			Id: ib.Id, Settings: clientsSettings(t, []model.Client{*edited}),
+		}, "x@stale")
+		return err
+	}},
+	{"add", true, func(t *testing.T, ib *model.Inbound) error {
+		_, err := (&ClientService{}).AddInboundClient(&InboundService{}, &model.Inbound{
+			Id: ib.Id, Settings: clientsSettings(t, []model.Client{{Email: "z@stale", ID: "aaaaaaaa-0000-0000-0000-00000000000c", Enable: true}}),
+		})
+		return err
+	}},
+	{"delete", true, func(t *testing.T, ib *model.Inbound) error {
+		_, err := (&ClientService{}).DelInboundClientByEmail(&InboundService{}, ib.Id, "x@stale", false, true)
+		return err
+	}},
+	{"bulk detach", true, func(t *testing.T, ib *model.Inbound) error {
+		_, _, err := (&ClientService{}).BulkDetach(&InboundService{}, []string{"x@stale"}, []int{ib.Id})
+		return err
+	}},
+	{"bulk adjust", false, func(t *testing.T, ib *model.Inbound) error {
+		_, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{"x@stale"}, 1, 0, "", nil, "")
+		return err
+	}},
+	{"bulk delete", false, func(t *testing.T, ib *model.Inbound) error {
+		_, _, err := (&ClientService{}).BulkDelete(&InboundService{}, []string{"x@stale"}, false)
+		return err
+	}},
+	{"bulk set enable", true, func(t *testing.T, ib *model.Inbound) error {
+		_, _, err := (&ClientService{}).BulkSetEnable(&InboundService{}, []string{"x@stale"}, false)
+		return err
+	}},
+}
+
+// Each client op reads the inbound before queueing its write; a renewal the
+// traffic writer commits in between must not be reverted to enable=false.
+func TestClientOpsKeepNeighbourRenewedMidOp(t *testing.T) {
+	for i, op := range staleClientOps {
+		t.Run(op.name, func(t *testing.T) {
+			setupBulkDB(t)
+			ib := seedRenewableNeighbour(t, 23101+i, nil)
+			commitTickBetweenReadAndWrite(t, autoRenewTick, func() {
+				if err := op.run(t, ib); err != nil {
+					t.Errorf("%s: %v", op.name, err)
+				}
+			})
+			requireNeighbourRenewed(t, ib.Id)
+		})
+	}
+}
+
+// An op on the renewed client itself keeps the fields it did not change.
+func TestBulkAdjustOnRenewedClientKeepsRenewal(t *testing.T) {
+	setupBulkDB(t)
+	ib := seedRenewableNeighbour(t, 23120, nil)
+	commitTickBetweenReadAndWrite(t, autoRenewTick, func() {
+		if _, _, err := (&ClientService{}).BulkAdjust(&InboundService{}, []string{"y@stale"}, 0, 500, "", nil, ""); err != nil {
+			t.Errorf("BulkAdjust: %v", err)
+		}
+	})
+	if y := requireNeighbourRenewed(t, ib.Id); y.TotalGB != 1500 {
+		t.Fatalf("y@stale totalGB = %d, want 1500 (the adjust itself was lost)", y.TotalGB)
+	}
+}
+
+// The node got only the per-client push, so the skip fingerprint must not claim
+// it also holds the renewal the traffic writer committed mid-op.
+func TestNodeClientOpsMidRenewalStillReconcileRenewal(t *testing.T) {
+	for i, op := range staleClientOps {
+		if !op.advancesNodeFingerprint {
+			continue
+		}
+		t.Run(op.name, func(t *testing.T) {
+			setupBulkDB(t)
+			srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+				w.Header().Set("Content-Type", "application/json")
+				_, _ = w.Write([]byte(`{"success":true}`))
+			}))
+			t.Cleanup(srv.Close)
+			u, err := url.Parse(srv.URL)
+			if err != nil {
+				t.Fatalf("parse url: %v", err)
+			}
+			port, _ := strconv.Atoi(u.Port())
+			node := &model.Node{
+				Name: "stale-node", Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/",
+				ApiToken: "tok", Enable: true, Status: "online", AllowPrivateAddress: true,
+			}
+			if err := database.GetDB().Create(node).Error; err != nil {
+				t.Fatalf("create node: %v", err)
+			}
+			remote := runtime.NewRemote(node, nil)
+			useTestRuntimeManager(t).SetRuntimeOverride(node.Id, remote)
+
+			ib := seedRenewableNeighbour(t, 23131+i, &node.Id)
+			remote.AdoptInboundAlias(ib, runtime.RemoteInboundOption{Id: 7, Tag: ib.Tag})
+
+			commitTickBetweenReadAndWrite(t, renewYTick(ib.Id), func() {
+				if err := op.run(t, ib); err != nil {
+					t.Errorf("%s: %v", op.name, err)
+				}
+			})
+			requireNeighbourRenewed(t, ib.Id)
+
+			saved, err := (&InboundService{}).GetInbound(ib.Id)
+			if err != nil {
+				t.Fatalf("GetInbound: %v", err)
+			}
+			pushed, err := remote.ReconcileInbound(context.Background(), saved, true)
+			if err != nil {
+				t.Fatalf("ReconcileInbound: %v", err)
+			}
+			if !pushed {
+				t.Fatal("reconcile skipped the inbound: the node never receives y@stale's renewal")
+			}
+		})
+	}
+}
+
+func TestRebaseClientSettings(t *testing.T) {
+	const base = `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`
+	cases := []struct {
+		name, ours, current, want string
+	}{
+		{
+			name:    "untouched client takes the committed version",
+			ours:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":2},{"email":"b","enable":false,"expiryTime":1}]}`,
+			current: `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":true,"expiryTime":9}]}`,
+			want:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":2},{"email":"b","enable":true,"expiryTime":9}]}`,
+		},
+		{
+			name:    "edited client keeps committed changes to fields the op left alone",
+			ours:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1,"comment":"x"}]}`,
+			current: `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":true,"expiryTime":9}]}`,
+			want:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":true,"expiryTime":9,"comment":"x"}]}`,
+		},
+		{
+			name:    "client the op removed stays removed",
+			ours:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1}]}`,
+			current: `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":true,"expiryTime":9}]}`,
+			want:    `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1}]}`,
+		},
+		{
+			name:    "client committed after the read is kept",
+			ours:    `{"decryption":"none","clients":[{"email":"a","enable":false,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`,
+			current: `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1},{"email":"c"}]}`,
+			want:    `{"decryption":"none","clients":[{"email":"a","enable":false,"totalGB":1},{"email":"b","enable":false,"expiryTime":1},{"email":"c"}]}`,
+		},
+		{
+			name:    "untouched client removed after the read stays removed",
+			ours:    `{"decryption":"none","clients":[{"email":"a","enable":false,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`,
+			current: `{"decryption":"none","clients":[{"email":"a","enable":true,"totalGB":1}]}`,
+			want:    `{"decryption":"none","clients":[{"email":"a","enable":false,"totalGB":1}]}`,
+		},
+		{
+			name:    "top-level key follows whichever side changed it",
+			ours:    `{"decryption":"none","testseed":[1],"clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`,
+			current: `{"decryption":"mlkem","clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`,
+			want:    `{"decryption":"mlkem","testseed":[1],"clients":[{"email":"a","enable":true,"totalGB":1},{"email":"b","enable":false,"expiryTime":1}]}`,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := rebaseClientSettings(base, tc.ours, tc.current)
+			if err != nil {
+				t.Fatalf("rebaseClientSettings: %v", err)
+			}
+			var gotV, wantV any
+			if err := json.Unmarshal([]byte(got), &gotV); err != nil {
+				t.Fatalf("unmarshal got: %v", err)
+			}
+			if err := json.Unmarshal([]byte(tc.want), &wantV); err != nil {
+				t.Fatalf("unmarshal want: %v", err)
+			}
+			if !reflect.DeepEqual(gotV, wantV) {
+				t.Fatalf("rebase = %s\nwant    %s", got, tc.want)
+			}
+		})
+	}
+}

+ 1 - 14
internal/web/service/tgbot/tgbot.go

@@ -5,7 +5,6 @@ import (
 	"crypto/rand"
 	"embed"
 	"math/big"
-	"net/http"
 	"net/url"
 	"os"
 	"regexp"
@@ -48,8 +47,7 @@ var (
 	EventBus *eventbus.Bus
 
 	// Performance improvements
-	messageWorkerPool   chan struct{} // Semaphore for limiting concurrent message processing
-	optimizedHTTPClient *http.Client  // HTTP client with connection pooling and timeouts
+	messageWorkerPool chan struct{} // Semaphore for limiting concurrent message processing
 
 	// Simple cache for frequently accessed data
 	statusCache struct {
@@ -306,17 +304,6 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
 	// Initialize worker pool for concurrent message processing (max 10 concurrent handlers)
 	messageWorkerPool = make(chan struct{}, 10)
 
-	// Initialize optimized HTTP client with connection pooling
-	optimizedHTTPClient = &http.Client{
-		Timeout: 15 * time.Second,
-		Transport: &http.Transport{
-			MaxIdleConns:        100,
-			MaxIdleConnsPerHost: 10,
-			IdleConnTimeout:     30 * time.Second,
-			DisableKeepAlives:   false,
-		},
-	}
-
 	t.SetHostname()
 
 	// Get Telegram bot token

+ 33 - 100
internal/web/service/tgbot/tgbot_client.go

@@ -2,13 +2,11 @@ package tgbot
 
 import (
 	"context"
-	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"html"
-	"io"
-	"net/http"
+	"net/url"
 	"slices"
 	"strconv"
 	"strings"
@@ -248,76 +246,42 @@ func (t *Tgbot) sendClientSubLinks(chatId int64, email string) {
 	t.SendMsgToTgbot(chatId, msg, inlineKeyboard)
 }
 
-// sendClientIndividualLinks fetches the subscription content (individual links) and sends it to the user
-func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
-	// Build the HTML sub page URL; we'll call it with header Accept to get raw content
-	subURL, _, err := t.buildSubscriptionURLs(email)
+// clientSubLinks builds the subscription's links in-process for the host subURL
+// names; fetching subURL instead fails whenever that host does not resolve here.
+func (t *Tgbot) clientSubLinks(email, subURL string) ([]string, error) {
+	u, err := url.Parse(subURL)
 	if err != nil {
-		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
-		return
+		return nil, err
 	}
-
-	// Try to fetch raw subscription links. Prefer plain text response.
-	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subURL, nil)
-	if err != nil {
-		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
-		return
+	_, client, err := t.inboundService.GetClientByEmail(email)
+	if err != nil || client == nil {
+		return nil, errors.New("client not found")
 	}
-	// Force plain text to avoid HTML page; controller respects Accept header
-	req.Header.Set("Accept", "text/plain, */*;q=0.1")
-
-	// Use optimized client with connection pooling
-	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-	defer cancel()
-	req = req.WithContext(ctx)
+	return t.inboundService.GetSubLinks(u.Hostname(), client.SubID)
+}
 
-	resp, err := optimizedHTTPClient.Do(req)
+// sendClientIndividualLinks sends the subscription's individual links to the user
+func (t *Tgbot) sendClientIndividualLinks(chatId int64, email string) {
+	subURL, _, err := t.buildSubscriptionURLs(email)
 	if err != nil {
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
 		return
 	}
-	defer resp.Body.Close()
-
-	bodyBytes, err := io.ReadAll(resp.Body)
+	links, err := t.clientSubLinks(email, subURL)
 	if err != nil {
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.answers.errorOperation")+"\r\n"+err.Error())
 		return
 	}
-
-	// If service is configured to encode (Base64), decode it
-	encoded, _ := t.settingService.GetSubEncrypt()
-	var content string
-	if encoded {
-		decoded, err := base64.StdEncoding.DecodeString(string(bodyBytes))
-		if err != nil {
-			// fallback to raw text
-			content = string(bodyBytes)
-		} else {
-			content = string(decoded)
-		}
-	} else {
-		content = string(bodyBytes)
-	}
-
-	// Normalize line endings and trim
-	lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
-	var cleaned []string
-	for _, l := range lines {
-		l = strings.TrimSpace(l)
-		if l != "" {
-			cleaned = append(cleaned, l)
-		}
-	}
-	if len(cleaned) == 0 {
+	if len(links) == 0 {
 		t.SendMsgToTgbot(chatId, t.I18nBot("tgbot.noResult"))
 		return
 	}
 
 	// Send in chunks to respect message length; use monospace formatting
 	const maxPerMessage = 50
-	for i := 0; i < len(cleaned); i += maxPerMessage {
-		j := min(i+maxPerMessage, len(cleaned))
-		chunk := cleaned[i:j]
+	for i := 0; i < len(links); i += maxPerMessage {
+		j := min(i+maxPerMessage, len(links))
+		chunk := links[i:j]
 		var msg strings.Builder
 		msg.WriteString(t.I18nBot("subscription.individualLinks"))
 		msg.WriteString(":\r\n")
@@ -375,51 +339,20 @@ func (t *Tgbot) sendClientQRLinks(chatId int64, email string) {
 	}
 
 	// Also generate a few individual links' QRs (first up to 5)
-	subPageURL := subURL
-	req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, subPageURL, nil)
-	if err == nil {
-		req.Header.Set("Accept", "text/plain, */*;q=0.1")
-		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-		defer cancel()
-		req = req.WithContext(ctx)
-		if resp, err := optimizedHTTPClient.Do(req); err == nil {
-			body, _ := io.ReadAll(resp.Body)
-			_ = resp.Body.Close()
-			encoded, _ := t.settingService.GetSubEncrypt()
-			var content string
-			if encoded {
-				if dec, err := base64.StdEncoding.DecodeString(string(body)); err == nil {
-					content = string(dec)
-				} else {
-					content = string(body)
-				}
-			} else {
-				content = string(body)
-			}
-			lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")
-			var cleaned []string
-			for _, l := range lines {
-				l = strings.TrimSpace(l)
-				if l != "" {
-					cleaned = append(cleaned, l)
-				}
-			}
-			if len(cleaned) > 0 {
-				max := min(len(cleaned), 5)
-				for i := range max {
-					if png, err := createQR(cleaned[i], 320); err == nil {
-						// Use the email as filename for individual link QR
-						filename := email + ".png"
-						document := tu.Document(
-							tu.ID(chatId),
-							tu.FileFromBytes(png, filename),
-						)
-						_, _ = bot.SendDocument(context.Background(), document)
-						// Reduced delay for better performance
-						if i < max-1 { // Only delay between documents, not after the last one
-							time.Sleep(50 * time.Millisecond)
-						}
-					}
+	if links, err := t.clientSubLinks(email, subURL); err == nil {
+		max := min(len(links), 5)
+		for i := range max {
+			if png, err := createQR(links[i], 320); err == nil {
+				// Use the email as filename for individual link QR
+				filename := email + ".png"
+				document := tu.Document(
+					tu.ID(chatId),
+					tu.FileFromBytes(png, filename),
+				)
+				_, _ = bot.SendDocument(context.Background(), document)
+				// Reduced delay for better performance
+				if i < max-1 { // Only delay between documents, not after the last one
+					time.Sleep(50 * time.Millisecond)
 				}
 			}
 		}

+ 89 - 0
internal/web/service/tgbot/tgbot_individual_links_test.go

@@ -0,0 +1,89 @@
+package tgbot
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"path/filepath"
+	"strings"
+	"sync"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/sub"
+	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// recordingBotServer answers sendMessage and records every text it was asked to send.
+func recordingBotServer(t *testing.T) func() []string {
+	t.Helper()
+	var mu sync.Mutex
+	var texts []string
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path != "/bot"+testBotToken+"/sendMessage" {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		var body struct {
+			Text string `json:"text"`
+		}
+		_ = json.NewDecoder(r.Body).Decode(&body)
+		mu.Lock()
+		texts = append(texts, body.Text)
+		mu.Unlock()
+		w.Header().Set("Content-Type", "application/json")
+		_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "result": map[string]any{
+			"message_id": 1, "date": 0, "chat": map[string]any{"id": ownerTgID, "type": "private"},
+		}})
+	}))
+	t.Cleanup(srv.Close)
+	swapTestBot(t, srv.URL)
+	return func() []string {
+		mu.Lock()
+		defer mu.Unlock()
+		return append([]string(nil), texts...)
+	}
+}
+
+// With no sub or web domain set, the only host the bot knows is the machine
+// name, which need not resolve; the links must not depend on reaching it.
+func TestIndividualLinksDoNotNeedAResolvableHost(t *testing.T) {
+	sent := recordingBotServer(t)
+	dbtest.InitDB(t, filepath.Join(t.TempDir(), "x-ui.db"))
+	service.RegisterSubLinkProvider(sub.NewLinkProvider())
+
+	const uuid = "11111111-2222-4333-8444-555555555555"
+	db := database.GetDB()
+	ib := &model.Inbound{
+		UserId: 1, Tag: "in-443", Enable: true, Listen: "203.0.113.5", Port: 443,
+		Protocol: model.VLESS, Remark: "in",
+		Settings: `{"clients":[{"id":"` + uuid + `","email":"` + ownerMail + `","tgId":4242,"subId":"sub-owned","enable":true}],"decryption":"none"}`,
+	}
+	if err := db.Create(ib).Error; err != nil {
+		t.Fatalf("seed inbound: %v", err)
+	}
+	rec := &model.ClientRecord{Email: ownerMail, SubID: "sub-owned", UUID: uuid, TgID: ownerTgID, Enable: true}
+	if err := db.Create(rec).Error; err != nil {
+		t.Fatalf("seed client: %v", err)
+	}
+	if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil {
+		t.Fatalf("seed client_inbound: %v", err)
+	}
+	if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: ownerMail, Enable: true}).Error; err != nil {
+		t.Fatalf("seed traffic: %v", err)
+	}
+
+	origHost, origRunning := hostname, isRunning
+	t.Cleanup(func() { hostname, isRunning = origHost, origRunning })
+	hostname, isRunning = "unresolvable-panel-host.invalid", true
+
+	tapClientLinks(t, &Tgbot{}, ownerTgID, "client_individual_links "+ownerMail)
+
+	got := strings.Join(sent(), "\n")
+	if !strings.Contains(got, "vless://"+uuid+"@203.0.113.5:443") {
+		t.Fatalf("bot sent %q, want the client's vless link", got)
+	}
+}

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

@@ -618,7 +618,7 @@
         "maxTimeDiff": "أقصى فرق زمن (ms)",
         "minClientVer": "أدنى إصدار للعميل",
         "maxClientVer": "أقصى إصدار للعميل",
-        "minClientVerHint": "تركه فارغًا يعني بلا حد أدنى: يقبل Xray-core أي إصدار عميل، بما في ذلك النوى الخارجية مثل Mihomo وsing-box. عيّن إصدارًا لرفض العملاء الذين يبلغون عن إصدار أقدم.",
+        "minClientVerHint": "في Xray-core v26.9.8 وما بعده، تركه فارغًا يعني بلا حد أدنى: يُقبل أي إصدار عميل، بما في ذلك النوى الخارجية مثل Mihomo وsing-box. أما النوى من v26.7.11 إلى v26.9.7 فتفرض عند تركه فارغًا حدًا أدنى مدمجًا هو 26.3.27؛ عيّن 1.0.0 عليها لقبول هؤلاء العملاء. عيّن إصدارًا لرفض العملاء الذين يبلغون عن إصدار أقدم.",
         "maxClientVerHint": "تركه فارغًا يعني بلا حد أقصى. إذا عُيّن، يجب ألا يقل عن أدنى إصدار للعميل — وإلا سيُرفض جميع العملاء.",
         "clientVerInvalid": "يجب أن يتكون إصدار العميل من ثلاثة أرقام كحد أقصى مفصولة بنقاط، كل منها 0-255 (مثل 26.3.27)",
         "maxClientVerBelowMin": "أقصى إصدار للعميل يجب ألا يقل عن أدنى إصدار للعميل",

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

@@ -632,7 +632,7 @@
         "maxTimeDiff": "Max Time Diff (ms)",
         "minClientVer": "Min Client Ver",
         "maxClientVer": "Max Client Ver",
-        "minClientVerHint": "Empty means no minimum: Xray-core accepts every client version, including third-party cores such as Mihomo and sing-box. Set a version to reject clients that report an older one.",
+        "minClientVerHint": "On Xray-core v26.9.8+ empty means no minimum: every client version is accepted, including third-party cores such as Mihomo and sing-box. Cores v26.7.11 to v26.9.7 instead enforce a built-in 26.3.27 minimum when empty; set 1.0.0 there to accept those clients. Set a version to reject clients that report an older one.",
         "maxClientVerHint": "Empty means no upper limit. If set, it must not be lower than Min Client Ver — otherwise every client is rejected.",
         "clientVerInvalid": "Client version must be up to three dot-separated numbers, each 0-255 (e.g. 26.3.27)",
         "maxClientVerBelowMin": "Max Client Ver must not be lower than Min Client Ver",

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

@@ -639,7 +639,7 @@
         "maxTimeDiff": "Máx. diferencia de tiempo (ms)",
         "minClientVer": "Mín. versión cliente",
         "maxClientVer": "Máx. versión cliente",
-        "minClientVerHint": "Vacío significa sin mínimo: Xray-core acepta cualquier versión de cliente, incluidos núcleos de terceros como Mihomo y sing-box. Establece una versión para rechazar a los clientes que informen una más antigua.",
+        "minClientVerHint": "En Xray-core v26.9.8+ vacío significa sin mínimo: se acepta cualquier versión de cliente, incluidos núcleos de terceros como Mihomo y sing-box. Los núcleos v26.7.11 a v26.9.7, en cambio, aplican un mínimo integrado de 26.3.27 cuando está vacío; establece 1.0.0 en ellos para aceptar a esos clientes. Establece una versión para rechazar a los clientes que informen una más antigua.",
         "maxClientVerHint": "Vacío significa sin límite superior. Si se establece, no debe ser inferior a la versión mínima del cliente; de lo contrario, se rechaza a todos los clientes.",
         "clientVerInvalid": "La versión del cliente debe tener hasta tres números separados por puntos, cada uno 0-255 (p. ej. 26.3.27)",
         "maxClientVerBelowMin": "La versión máxima del cliente no debe ser inferior a la versión mínima",

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

@@ -630,7 +630,7 @@
         "maxTimeDiff": "حداکثر اختلاف زمان (ms)",
         "minClientVer": "حداقل نسخه کلاینت",
         "maxClientVer": "حداکثر نسخه کلاینت",
-        "minClientVerHint": "خالی یعنی بدون حداقل: Xray-core هر نسخهٔ کلاینتی را می‌پذیرد، از جمله هسته‌های شخص ثالث مانند Mihomo و sing-box. برای رد کردن کلاینت‌هایی که نسخهٔ قدیمی‌تری گزارش می‌دهند، یک نسخه تعیین کنید.",
+        "minClientVerHint": "در Xray-core نسخهٔ v26.9.8 به بعد، خالی یعنی بدون حداقل: هر نسخهٔ کلاینتی پذیرفته می‌شود، از جمله هسته‌های شخص ثالث مانند Mihomo و sing-box. هسته‌های v26.7.11 تا v26.9.7 در حالت خالی، حداقل داخلی 26.3.27 را اعمال می‌کنند؛ روی آن‌ها 1.0.0 را تنظیم کنید تا این کلاینت‌ها پذیرفته شوند. برای رد کردن کلاینت‌هایی که نسخهٔ قدیمی‌تری گزارش می‌دهند، یک نسخه تعیین کنید.",
         "maxClientVerHint": "خالی یعنی بدون سقف. در صورت تنظیم، نباید از حداقل نسخه کلاینت کمتر باشد — در غیر این صورت همهٔ کلاینت‌ها رد می‌شوند.",
         "clientVerInvalid": "نسخهٔ کلاینت باید حداکثر سه عدد جداشده با نقطه باشد، هر یک 0-255 (مثلاً 26.3.27)",
         "maxClientVerBelowMin": "حداکثر نسخهٔ کلاینت نباید از حداقل نسخهٔ کلاینت کمتر باشد",

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

@@ -618,7 +618,7 @@
         "maxTimeDiff": "Maks. selisih waktu (ms)",
         "minClientVer": "Min. versi klien",
         "maxClientVer": "Maks. versi klien",
-        "minClientVerHint": "Kosong berarti tanpa minimum: Xray-core menerima semua versi klien, termasuk core pihak ketiga seperti Mihomo dan sing-box. Isi versi untuk menolak klien yang melaporkan versi lebih lama.",
+        "minClientVerHint": "Pada Xray-core v26.9.8+ kosong berarti tanpa minimum: semua versi klien diterima, termasuk core pihak ketiga seperti Mihomo dan sing-box. Core v26.7.11 hingga v26.9.7 justru memberlakukan minimum bawaan 26.3.27 saat kosong; isi 1.0.0 di sana untuk menerima klien tersebut. Isi versi untuk menolak klien yang melaporkan versi lebih lama.",
         "maxClientVerHint": "Kosong berarti tanpa batas atas. Jika diisi, tidak boleh lebih rendah dari versi klien minimum — jika tidak, semua klien ditolak.",
         "clientVerInvalid": "Versi klien harus berupa maksimal tiga angka dipisah titik, masing-masing 0-255 (mis. 26.3.27)",
         "maxClientVerBelowMin": "Versi klien maksimum tidak boleh lebih rendah dari versi klien minimum",

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

@@ -639,7 +639,7 @@
         "maxTimeDiff": "最大時間差 (ms)",
         "minClientVer": "最小クライアントバージョン",
         "maxClientVer": "最大クライアントバージョン",
-        "minClientVerHint": "空欄は下限なしを意味します。Xray-core は Mihomo や sing-box などのサードパーティコアを含め、あらゆるクライアントバージョンを受け入れます。古いバージョンを申告するクライアントを拒否するにはバージョンを設定してください。",
+        "minClientVerHint": "Xray-core v26.9.8 以降では、空欄は下限なしを意味し、Mihomo や sing-box などのサードパーティコアを含むあらゆるクライアントバージョンを受け入れます。v26.7.11〜v26.9.7 のコアは空欄時に組み込みの最低バージョン 26.3.27 を適用するため、これらのクライアントを受け入れるには 1.0.0 を設定してください。古いバージョンを申告するクライアントを拒否するにはバージョンを設定してください。",
         "maxClientVerHint": "空欄は上限なしを意味します。設定する場合は最小クライアントバージョンを下回ってはいけません。下回るとすべてのクライアントが拒否されます。",
         "clientVerInvalid": "クライアントバージョンはドット区切りの数値(最大 3 つ、各 0-255)で指定してください(例:26.3.27)",
         "maxClientVerBelowMin": "最大クライアントバージョンは最小クライアントバージョンを下回れません",

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

@@ -639,7 +639,7 @@
         "maxTimeDiff": "Máx. diferença de tempo (ms)",
         "minClientVer": "Mín. versão cliente",
         "maxClientVer": "Máx. versão cliente",
-        "minClientVerHint": "Vazio significa sem mínimo: o Xray-core aceita qualquer versão de cliente, incluindo núcleos de terceiros como Mihomo e sing-box. Defina uma versão para rejeitar clientes que informem uma mais antiga.",
+        "minClientVerHint": "No Xray-core v26.9.8+ vazio significa sem mínimo: qualquer versão de cliente é aceita, incluindo núcleos de terceiros como Mihomo e sing-box. Já os núcleos v26.7.11 a v26.9.7 aplicam um mínimo embutido de 26.3.27 quando vazio; defina 1.0.0 neles para aceitar esses clientes. Defina uma versão para rejeitar clientes que informem uma mais antiga.",
         "maxClientVerHint": "Vazio significa sem limite superior. Se definido, não deve ser menor que a versão mínima do cliente; caso contrário, todos os clientes são rejeitados.",
         "clientVerInvalid": "A versão do cliente deve ter até três números separados por pontos, cada um 0-255 (ex.: 26.3.27)",
         "maxClientVerBelowMin": "A versão máxima do cliente não deve ser menor que a versão mínima",

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

@@ -641,7 +641,7 @@
         "maxTimeDiff": "Макс. разница во времени (мс)",
         "minClientVer": "Мин. версия клиента",
         "maxClientVer": "Макс. версия клиента",
-        "minClientVerHint": "Пустое поле — без нижней границы: Xray-core принимает любую версию клиента, включая сторонние ядра вроде Mihomo и sing-box. Укажите версию, чтобы отклонять клиентов, сообщающих более старую.",
+        "minClientVerHint": "В Xray-core v26.9.8+ пустое поле — без нижней границы: принимается любая версия клиента, включая сторонние ядра вроде Mihomo и sing-box. Ядра с v26.7.11 по v26.9.7 при пустом поле применяют встроенный минимум 26.3.27; укажите 1.0.0, чтобы принимать таких клиентов. Укажите версию, чтобы отклонять клиентов, сообщающих более старую.",
         "maxClientVerHint": "Пустое поле — без верхнего предела. Если задано, значение не должно быть ниже минимальной версии клиента — иначе будут отклонены все клиенты.",
         "clientVerInvalid": "Версия клиента — до трёх чисел через точку, каждое 0-255 (например 26.3.27)",
         "maxClientVerBelowMin": "Макс. версия клиента не должна быть ниже минимальной версии клиента",

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

@@ -618,7 +618,7 @@
         "maxTimeDiff": "Maks. Zaman Farkı (ms)",
         "minClientVer": "Min. Kullanıcı Sürümü",
         "maxClientVer": "Maks. Kullanıcı Sürümü",
-        "minClientVerHint": "Boş bırakmak alt sınır yok demektir: Xray-core, Mihomo ve sing-box gibi üçüncü taraf çekirdekler dahil her istemci sürümünü kabul eder. Daha eski sürüm bildiren istemcileri reddetmek için bir sürüm girin.",
+        "minClientVerHint": "Xray-core v26.9.8+ sürümünde boş bırakmak alt sınır yok demektir: Mihomo ve sing-box gibi üçüncü taraf çekirdekler dahil her istemci sürümü kabul edilir. v26.7.11 ile v26.9.7 arasındaki çekirdekler ise boşken yerleşik 26.3.27 alt sınırını uygular; bu istemcileri kabul etmek için orada 1.0.0 girin. Daha eski sürüm bildiren istemcileri reddetmek için bir sürüm girin.",
         "maxClientVerHint": "Boş, üst sınır yok demektir. Ayarlanırsa Min. İstemci Sürümü'nden düşük olmamalıdır; aksi halde tüm istemciler reddedilir.",
         "clientVerInvalid": "İstemci sürümü noktayla ayrılmış en fazla üç sayıdan oluşmalıdır, her biri 0-255 (örn. 26.3.27)",
         "maxClientVerBelowMin": "Maks. istemci sürümü, en düşük istemci sürümünün altında olamaz",

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

@@ -618,7 +618,7 @@
         "maxTimeDiff": "Макс. різниця в часі (мс)",
         "minClientVer": "Мін. версія клієнта",
         "maxClientVer": "Макс. версія клієнта",
-        "minClientVerHint": "Порожнє поле — без нижньої межі: Xray-core приймає будь-яку версію клієнта, зокрема сторонні ядра на кшталт Mihomo та sing-box. Вкажіть версію, щоб відхиляти клієнтів, які повідомляють старішу.",
+        "minClientVerHint": "У Xray-core v26.9.8+ порожнє поле — без нижньої межі: приймається будь-яка версія клієнта, зокрема сторонні ядра на кшталт Mihomo та sing-box. Ядра з v26.7.11 по v26.9.7 за порожнього поля застосовують вбудований мінімум 26.3.27; вкажіть 1.0.0, щоб приймати таких клієнтів. Вкажіть версію, щоб відхиляти клієнтів, які повідомляють старішу.",
         "maxClientVerHint": "Порожнє поле — без верхньої межі. Якщо задано, значення не має бути нижчим за мінімальну версію клієнта — інакше буде відхилено всіх клієнтів.",
         "clientVerInvalid": "Версія клієнта — до трьох чисел через крапку, кожне 0-255 (наприклад 26.3.27)",
         "maxClientVerBelowMin": "Макс. версія клієнта не має бути нижчою за мінімальну версію клієнта",

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

@@ -639,7 +639,7 @@
         "maxTimeDiff": "Chênh lệch thời gian tối đa (ms)",
         "minClientVer": "Phiên bản client tối thiểu",
         "maxClientVer": "Phiên bản client tối đa",
-        "minClientVerHint": "Để trống nghĩa là không có mức tối thiểu: Xray-core chấp nhận mọi phiên bản client, kể cả các core bên thứ ba như Mihomo và sing-box. Đặt một phiên bản để từ chối các client báo phiên bản cũ hơn.",
+        "minClientVerHint": "Trên Xray-core v26.9.8+, để trống nghĩa là không có mức tối thiểu: mọi phiên bản client đều được chấp nhận, kể cả các core bên thứ ba như Mihomo và sing-box. Các core từ v26.7.11 đến v26.9.7 thì áp dụng mức tối thiểu tích hợp 26.3.27 khi để trống; hãy đặt 1.0.0 để chấp nhận các client đó. Đặt một phiên bản để từ chối các client báo phiên bản cũ hơn.",
         "maxClientVerHint": "Để trống nghĩa là không có giới hạn trên. Nếu đặt, không được thấp hơn phiên bản client tối thiểu — nếu không mọi client đều bị từ chối.",
         "clientVerInvalid": "Phiên bản client phải gồm tối đa ba số cách nhau bằng dấu chấm, mỗi số 0-255 (ví dụ 26.3.27)",
         "maxClientVerBelowMin": "Phiên bản client tối đa không được thấp hơn phiên bản client tối thiểu",

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

@@ -638,7 +638,7 @@
         "maxTimeDiff": "最大时间差 (ms)",
         "minClientVer": "最小客户端版本",
         "maxClientVer": "最大客户端版本",
-        "minClientVerHint": "留空表示不设下限:Xray-core 接受任何客户端版本,包括 Mihomo、sing-box 等第三方内核。填写版本可拒绝自报版本更低的客户端。",
+        "minClientVerHint": "在 Xray-core v26.9.8 及以上版本中,留空表示不设下限:接受任何客户端版本,包括 Mihomo、sing-box 等第三方内核。v26.7.11 至 v26.9.7 的内核在留空时会改用内置最低值 26.3.27;在这些内核上填 1.0.0 即可放行这类客户端。填写版本可拒绝自报版本更低的客户端。",
         "maxClientVerHint": "留空表示无上限。若填写,不得低于最小客户端版本,否则所有客户端都会被拒绝。",
         "clientVerInvalid": "客户端版本须为最多三段以点分隔的数字,每段 0-255(例如 26.3.27)",
         "maxClientVerBelowMin": "最大客户端版本不得低于最小客户端版本",

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

@@ -618,7 +618,7 @@
         "maxTimeDiff": "最大時間差 (ms)",
         "minClientVer": "最小客戶端版本",
         "maxClientVer": "最大客戶端版本",
-        "minClientVerHint": "留空表示不設下限:Xray-core 接受任何客戶端版本,包括 Mihomo、sing-box 等第三方核心。填寫版本可拒絕自報版本較低的客戶端。",
+        "minClientVerHint": "在 Xray-core v26.9.8 及以上版本中,留空表示不設下限:接受任何客戶端版本,包括 Mihomo、sing-box 等第三方核心。v26.7.11 至 v26.9.7 的核心在留空時會改用內建最低值 26.3.27;在這些核心上填 1.0.0 即可放行這類客戶端。填寫版本可拒絕自報版本較低的客戶端。",
         "maxClientVerHint": "留空表示無上限。若填寫,不得低於最小客戶端版本,否則所有客戶端都會被拒絕。",
         "clientVerInvalid": "客戶端版本須為最多三段以點分隔的數字,每段 0-255(例如 26.3.27)",
         "maxClientVerBelowMin": "最大客戶端版本不得低於最小客戶端版本",

二進制
media/01-overview-dark.png


二進制
media/01-overview-light.png


二進制
media/02-add-inbound-dark.png


二進制
media/02-add-inbound-light.png


二進制
media/02-inbounds-dark.png


二進制
media/02-inbounds-light.png


二進制
media/03-add-client-dark.png


二進制
media/03-add-client-light.png


二進制
media/03-client-dark.png


二進制
media/03-client-light.png


二進制
media/04-group-dark.png


二進制
media/04-group-light.png


二進制
media/05-add-nodes-dark.png


二進制
media/05-add-nodes-light.png


二進制
media/05-nodes-dark.png


二進制
media/05-nodes-light.png


二進制
media/06-settings-dark.png


二進制
media/06-settings-light.png


二進制
media/07-configs-dark.png


二進制
media/07-configs-light.png


二進制
media/08-api-docs-dark.png


二進制
media/08-api-docs-light.png


二進制
media/panel-bot-config.png


+ 2 - 1
x-ui.sh

@@ -897,7 +897,8 @@ check_status() {
         if [[ ! -f ${xui_service}/x-ui.service ]]; then
             return 2
         fi
-        temp=$(systemctl status x-ui | grep Active | awk '{print $3}' | cut -d "(" -f2 | cut -d ")" -f1)
+        temp=$(systemctl show --property=SubState x-ui)
+        temp=${temp#SubState=}
         if [[ "${temp}" == "running" ]]; then
             return 0
         else

Some files were not shown because too many files changed in this diff