mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user