fix(node): rewrite a node's settings blob without decoding all of it

Dedupe, tombstone stripping and the lifecycle lift decoded a node's
whole settings blob into generic maps and wrote it back indented, so a
deeply nested value anywhere in it cost the square of its depth on every
merge (10 KB became 50 MB). They now split the blob one level deep, read
only email, expiryTime and enable of each client entry, keep everything
else as raw text and write the result compactly.
This commit is contained in:
MHSanaei
2026-10-31 10:39:13 +01:00
parent 8ca321138b
commit a203056949
2 changed files with 123 additions and 99 deletions
@@ -2,12 +2,14 @@ package service
import (
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
"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"
)
func mustUnmarshal(t *testing.T, raw string, v any) {
@@ -93,3 +95,22 @@ func TestDedupeSettingsClients(t *testing.T) {
t.Fatal("invalid settings must not be rewritten")
}
}
// A node's settings blob may nest any value deeply beside its clients; decoding the whole
// blob and re-indenting it cost the square of the depth for every one of these rewrites.
func TestSettingsClientRewritesStayLinearInTheBlob(t *testing.T) {
deep := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
settings := `{"decoy":` + deep + `,"clients":[{"email":"a@x","expiryTime":-86400000,"enable":true},{"email":"A@x"}]}`
out, changed := dedupeSettingsClients(settings)
if !changed || len(out) > 2*len(settings) {
t.Fatalf("dedupe changed=%v, output %d bytes from %d", changed, len(out), len(settings))
}
lifted, changed := liftClientLifecycleInSettings(out, map[string]*xray.ClientTraffic{"a@x": {Email: "a@x", ExpiryTime: 1700000000000}})
if !changed || len(lifted) > 2*len(settings) || !strings.Contains(lifted, `"expiryTime":1700000000000`) || !strings.Contains(lifted, `"enable":false`) {
t.Fatalf("lift changed=%v, output %d bytes: %.120s", changed, len(lifted), lifted)
}
clients, err := ParseInboundSettingsClients(lifted)
if err != nil || len(clients) != 1 || clients[0].Email != "a@x" {
t.Fatalf("clients after the rewrites = %+v, %v", clients, err)
}
}
+102 -99
View File
@@ -2,6 +2,7 @@ package service
import (
"encoding/json"
"strconv"
"strings"
"sync"
"time"
@@ -168,46 +169,75 @@ func isClientEmailTombstoned(email string) bool {
return true
}
// settingsClientEntry is what these rewrites read of a client entry. A node's blob may
// nest any value deeply, so entries stay raw text and only these fields are decoded.
type settingsClientEntry struct {
Email string `json:"email"`
ExpiryTime json.RawMessage `json:"expiryTime"`
Enable json.RawMessage `json:"enable"`
}
// splitSettingsClients decodes a settings blob one level deep; ok is false when it has
// no client array to work on.
func splitSettingsClients(settings string) (members map[string]json.RawMessage, entries []json.RawMessage, ok bool) {
if settings == "" || json.Unmarshal([]byte(settings), &members) != nil {
return nil, nil, false
}
if json.Unmarshal(members["clients"], &entries) != nil {
return nil, nil, false
}
return members, entries, true
}
func readSettingsClient(entry json.RawMessage) (settingsClientEntry, bool) {
var c settingsClientEntry
if len(entry) == 0 || entry[0] != '{' || json.Unmarshal(entry, &c) != nil {
return c, false
}
return c, true
}
// joinSettingsClients writes the blob back compactly; indenting it again would cost the
// square of whatever depth the node nested into it.
func joinSettingsClients(settings string, members map[string]json.RawMessage, entries []json.RawMessage) (string, bool) {
clients, err := json.Marshal(entries)
if err != nil {
return settings, false
}
members["clients"] = clients
b, err := json.Marshal(members)
if err != nil {
return settings, false
}
return string(b), true
}
// dedupeSettingsClients collapses duplicate same-email client entries inside a
// settings JSON blob, keeping the first occurrence. Node snapshots produced by
// builds without the addInboundClient duplicate guard can carry duplicates
// (#5770); adopting them verbatim would copy the duplication into the central
// inbound. Returns the filtered JSON and whether anything was removed.
func dedupeSettingsClients(settings string) (string, bool) {
if settings == "" {
members, entries, ok := splitSettingsClients(settings)
if !ok || len(entries) < 2 {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, _ := parsed["clients"].([]any)
if len(clients) < 2 {
return settings, false
}
seen := make(map[string]struct{}, len(clients))
kept := make([]any, 0, len(clients))
for _, c := range clients {
if cm, ok := c.(map[string]any); ok {
if email, _ := cm["email"].(string); email != "" {
key := strings.ToLower(email)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
seen := make(map[string]struct{}, len(entries))
kept := make([]json.RawMessage, 0, len(entries))
for _, entry := range entries {
if c, ok := readSettingsClient(entry); ok && c.Email != "" {
key := strings.ToLower(c.Email)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
}
kept = append(kept, c)
kept = append(kept, entry)
}
if len(kept) == len(clients) {
if len(kept) == len(entries) {
return settings, false
}
parsed["clients"] = kept
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
return joinSettingsClients(settings, members, kept)
}
// stripTombstonedClients drops just-deleted client entries from a node
@@ -215,125 +245,98 @@ func dedupeSettingsClients(settings string) (string, bool) {
// the central inbound while the delete tombstone is live. Returns the filtered
// JSON and whether anything was removed.
func stripTombstonedClients(settings string) (string, bool) {
if settings == "" {
members, entries, ok := splitSettingsClients(settings)
if !ok || len(entries) == 0 {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, _ := parsed["clients"].([]any)
if len(clients) == 0 {
return settings, false
}
kept := make([]any, 0, len(clients))
for _, c := range clients {
if cm, ok := c.(map[string]any); ok {
if email, _ := cm["email"].(string); email != "" && isClientEmailTombstoned(email) {
continue
}
kept := make([]json.RawMessage, 0, len(entries))
for _, entry := range entries {
if c, ok := readSettingsClient(entry); ok && c.Email != "" && isClientEmailTombstoned(c.Email) {
continue
}
kept = append(kept, c)
kept = append(kept, entry)
}
if len(kept) == len(clients) {
if len(kept) == len(entries) {
return settings, false
}
parsed["clients"] = kept
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
return joinSettingsClients(settings, members, kept)
}
// liftClientLifecycleInSettings rewrites adopted settings from master traffic
// so a lagging node blob cannot store pre-extension expiry/enable (#6228).
func liftClientLifecycleInSettings(settings string, trafficByEmail map[string]*xray.ClientTraffic) (string, bool) {
if settings == "" || len(trafficByEmail) == 0 {
if len(trafficByEmail) == 0 {
return settings, false
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return settings, false
}
clients, _ := parsed["clients"].([]any)
if len(clients) == 0 {
members, entries, ok := splitSettingsClients(settings)
if !ok || len(entries) == 0 {
return settings, false
}
changed := false
for i := range clients {
cm, ok := clients[i].(map[string]any)
if !ok {
for i, entry := range entries {
c, ok := readSettingsClient(entry)
if !ok || c.Email == "" {
continue
}
email, _ := cm["email"].(string)
if email == "" {
continue
}
tr := trafficByEmail[email]
tr := trafficByEmail[c.Email]
if tr == nil {
continue
}
nodeExpiry, hasExpiry := jsonClientInt64(cm["expiryTime"])
nodeExpiry, hasExpiry := jsonClientInt64(c.ExpiryTime)
if !hasExpiry {
continue
}
merged := mergeActivationExpiry(tr.ExpiryTime, nodeExpiry)
if merged != nodeExpiry {
cm["expiryTime"] = merged
changed = true
}
var nodeEnable bool
_ = json.Unmarshal(c.Enable, &nodeEnable)
// tr is the already-merged master row, authoritative in both directions:
// a lagging blob must not re-enable a disabled client either (#4917).
if nodeEnable, _ := cm["enable"].(bool); nodeEnable != tr.Enable {
cm["enable"] = tr.Enable
changed = true
if merged == nodeExpiry && nodeEnable == tr.Enable {
continue
}
clients[i] = cm
var fields map[string]json.RawMessage
if json.Unmarshal(entry, &fields) != nil {
continue
}
fields["expiryTime"] = json.RawMessage(strconv.FormatInt(merged, 10))
fields["enable"] = json.RawMessage(strconv.FormatBool(tr.Enable))
rewritten, err := json.Marshal(fields)
if err != nil {
continue
}
entries[i] = rewritten
changed = true
}
if !changed {
return settings, false
}
parsed["clients"] = clients
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
return joinSettingsClients(settings, members, entries)
}
// settingsClientAbsoluteExpiries indexes the absolute (>0) expiryTime of every
// client in a settings blob. Never nil, so callers can cache it per inbound.
func settingsClientAbsoluteExpiries(settings string) map[string]int64 {
out := map[string]int64{}
if settings == "" {
_, entries, ok := splitSettingsClients(settings)
if !ok {
return out
}
var parsed map[string]any
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
return out
}
clients, _ := parsed["clients"].([]any)
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
for _, entry := range entries {
c, ok := readSettingsClient(entry)
if !ok || c.Email == "" {
continue
}
email, _ := cm["email"].(string)
if email == "" {
continue
}
if exp, has := jsonClientInt64(cm["expiryTime"]); has && exp > 0 {
out[email] = exp
if exp, has := jsonClientInt64(c.ExpiryTime); has && exp > 0 {
out[c.Email] = exp
}
}
return out
}
func jsonClientInt64(v any) (int64, bool) {
// json.Unmarshal into map[string]any yields float64 for numbers.
n, ok := v.(float64)
if !ok {
// jsonClientInt64 reads a JSON number, as encoding/json would into a float64.
func jsonClientInt64(raw json.RawMessage) (int64, bool) {
var n float64
if len(raw) == 0 || json.Unmarshal(raw, &n) != nil {
return 0, false
}
return int64(n), true