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 ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
) )
func mustUnmarshal(t *testing.T, raw string, v any) { 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") 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 ( import (
"encoding/json" "encoding/json"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -168,46 +169,75 @@ func isClientEmailTombstoned(email string) bool {
return true 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 // dedupeSettingsClients collapses duplicate same-email client entries inside a
// settings JSON blob, keeping the first occurrence. Node snapshots produced by // settings JSON blob, keeping the first occurrence. Node snapshots produced by
// builds without the addInboundClient duplicate guard can carry duplicates // builds without the addInboundClient duplicate guard can carry duplicates
// (#5770); adopting them verbatim would copy the duplication into the central // (#5770); adopting them verbatim would copy the duplication into the central
// inbound. Returns the filtered JSON and whether anything was removed. // inbound. Returns the filtered JSON and whether anything was removed.
func dedupeSettingsClients(settings string) (string, bool) { func dedupeSettingsClients(settings string) (string, bool) {
if settings == "" { members, entries, ok := splitSettingsClients(settings)
if !ok || len(entries) < 2 {
return settings, false return settings, false
} }
var parsed map[string]any seen := make(map[string]struct{}, len(entries))
if err := json.Unmarshal([]byte(settings), &parsed); err != nil { kept := make([]json.RawMessage, 0, len(entries))
return settings, false for _, entry := range entries {
} if c, ok := readSettingsClient(entry); ok && c.Email != "" {
clients, _ := parsed["clients"].([]any) key := strings.ToLower(c.Email)
if len(clients) < 2 { if _, dup := seen[key]; dup {
return settings, false continue
}
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[key] = struct{}{}
} }
kept = append(kept, c) kept = append(kept, entry)
} }
if len(kept) == len(clients) { if len(kept) == len(entries) {
return settings, false return settings, false
} }
parsed["clients"] = kept return joinSettingsClients(settings, members, kept)
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
} }
// stripTombstonedClients drops just-deleted client entries from a node // 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 // the central inbound while the delete tombstone is live. Returns the filtered
// JSON and whether anything was removed. // JSON and whether anything was removed.
func stripTombstonedClients(settings string) (string, bool) { func stripTombstonedClients(settings string) (string, bool) {
if settings == "" { members, entries, ok := splitSettingsClients(settings)
if !ok || len(entries) == 0 {
return settings, false return settings, false
} }
var parsed map[string]any kept := make([]json.RawMessage, 0, len(entries))
if err := json.Unmarshal([]byte(settings), &parsed); err != nil { for _, entry := range entries {
return settings, false if c, ok := readSettingsClient(entry); ok && c.Email != "" && isClientEmailTombstoned(c.Email) {
} continue
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 = append(kept, c) kept = append(kept, entry)
} }
if len(kept) == len(clients) { if len(kept) == len(entries) {
return settings, false return settings, false
} }
parsed["clients"] = kept return joinSettingsClients(settings, members, kept)
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
} }
// liftClientLifecycleInSettings rewrites adopted settings from master traffic // liftClientLifecycleInSettings rewrites adopted settings from master traffic
// so a lagging node blob cannot store pre-extension expiry/enable (#6228). // so a lagging node blob cannot store pre-extension expiry/enable (#6228).
func liftClientLifecycleInSettings(settings string, trafficByEmail map[string]*xray.ClientTraffic) (string, bool) { func liftClientLifecycleInSettings(settings string, trafficByEmail map[string]*xray.ClientTraffic) (string, bool) {
if settings == "" || len(trafficByEmail) == 0 { if len(trafficByEmail) == 0 {
return settings, false return settings, false
} }
var parsed map[string]any members, entries, ok := splitSettingsClients(settings)
if err := json.Unmarshal([]byte(settings), &parsed); err != nil { if !ok || len(entries) == 0 {
return settings, false
}
clients, _ := parsed["clients"].([]any)
if len(clients) == 0 {
return settings, false return settings, false
} }
changed := false changed := false
for i := range clients { for i, entry := range entries {
cm, ok := clients[i].(map[string]any) c, ok := readSettingsClient(entry)
if !ok { if !ok || c.Email == "" {
continue continue
} }
email, _ := cm["email"].(string) tr := trafficByEmail[c.Email]
if email == "" {
continue
}
tr := trafficByEmail[email]
if tr == nil { if tr == nil {
continue continue
} }
nodeExpiry, hasExpiry := jsonClientInt64(cm["expiryTime"]) nodeExpiry, hasExpiry := jsonClientInt64(c.ExpiryTime)
if !hasExpiry { if !hasExpiry {
continue continue
} }
merged := mergeActivationExpiry(tr.ExpiryTime, nodeExpiry) merged := mergeActivationExpiry(tr.ExpiryTime, nodeExpiry)
if merged != nodeExpiry { var nodeEnable bool
cm["expiryTime"] = merged _ = json.Unmarshal(c.Enable, &nodeEnable)
changed = true
}
// tr is the already-merged master row, authoritative in both directions: // tr is the already-merged master row, authoritative in both directions:
// a lagging blob must not re-enable a disabled client either (#4917). // a lagging blob must not re-enable a disabled client either (#4917).
if nodeEnable, _ := cm["enable"].(bool); nodeEnable != tr.Enable { if merged == nodeExpiry && nodeEnable == tr.Enable {
cm["enable"] = tr.Enable continue
changed = true
} }
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 { if !changed {
return settings, false return settings, false
} }
parsed["clients"] = clients return joinSettingsClients(settings, members, entries)
b, err := json.MarshalIndent(parsed, "", " ")
if err != nil {
return settings, false
}
return string(b), true
} }
// settingsClientAbsoluteExpiries indexes the absolute (>0) expiryTime of every // settingsClientAbsoluteExpiries indexes the absolute (>0) expiryTime of every
// client in a settings blob. Never nil, so callers can cache it per inbound. // client in a settings blob. Never nil, so callers can cache it per inbound.
func settingsClientAbsoluteExpiries(settings string) map[string]int64 { func settingsClientAbsoluteExpiries(settings string) map[string]int64 {
out := map[string]int64{} out := map[string]int64{}
if settings == "" { _, entries, ok := splitSettingsClients(settings)
if !ok {
return out return out
} }
var parsed map[string]any for _, entry := range entries {
if err := json.Unmarshal([]byte(settings), &parsed); err != nil { c, ok := readSettingsClient(entry)
return out if !ok || c.Email == "" {
}
clients, _ := parsed["clients"].([]any)
for _, c := range clients {
cm, ok := c.(map[string]any)
if !ok {
continue continue
} }
email, _ := cm["email"].(string) if exp, has := jsonClientInt64(c.ExpiryTime); has && exp > 0 {
if email == "" { out[c.Email] = exp
continue
}
if exp, has := jsonClientInt64(cm["expiryTime"]); has && exp > 0 {
out[email] = exp
} }
} }
return out return out
} }
func jsonClientInt64(v any) (int64, bool) { // jsonClientInt64 reads a JSON number, as encoding/json would into a float64.
// json.Unmarshal into map[string]any yields float64 for numbers. func jsonClientInt64(raw json.RawMessage) (int64, bool) {
n, ok := v.(float64) var n float64
if !ok { if len(raw) == 0 || json.Unmarshal(raw, &n) != nil {
return 0, false return 0, false
} }
return int64(n), true return int64(n), true