mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
63ffc083e4
Invariant: an operation on an inbound's clients writes back only what it changed, onto the settings as committed when it writes. Every client op (add, edit, delete, bulk adjust/detach/delete/set-enable) read the inbound outside the serial traffic writer and then tx.Save'd the whole row inside it. A traffic tick that committed in between - auto-renew re-enabling a client, the delayed-start conversion, a node adoption - was overwritten with the stale copy. A renewed neighbour ended up enable=false with its old expiry in settings while client_traffics said enabled, so the next runtime rebuild dropped a healthy client nobody had touched. The bulk ops then ran a full SyncInbound from those stale settings, copying enable=false into the client record too. Each site now commits through commitInboundClientSettings: inside the serialized tx it three-way merges the op's edit (read -> output, per client and per field) onto the committed settings and updates only the settings column, which also stops the stale up/down/enable inbound columns being written back. advancePushedInbound now records what the per-client push delivered rather than the merged settings, so the node's reconcile-skip fingerprint cannot claim a renewal it never received.
142 lines
3.6 KiB
Go
142 lines
3.6 KiB
Go
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", ¤t).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
|
|
}
|