mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-30 19:22:10 +03:00
fix(clients): stop client ops from reverting a renewal committed mid-op
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.
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user