diff --git a/internal/web/service/client_add_dedupe_test.go b/internal/web/service/client_add_dedupe_test.go index ae3c95f5c..3d7054475 100644 --- a/internal/web/service/client_add_dedupe_test.go +++ b/internal/web/service/client_add_dedupe_test.go @@ -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) + } +} diff --git a/internal/web/service/client_locks.go b/internal/web/service/client_locks.go index e77bfce6e..faf5976fe 100644 --- a/internal/web/service/client_locks.go +++ b/internal/web/service/client_locks.go @@ -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