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:
MHSanaei
2026-09-28 01:59:33 +02:00
parent 15d82a5e47
commit 63ffc083e4
4 changed files with 497 additions and 16 deletions
@@ -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", &current).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
}