mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 11:45:04 +03:00
fix(sync): preserve shared client lifecycle across nodes (#7038)
A client on inbounds of several nodes has one traffic row homed on one inbound, but the lifecycle merge looked it up in a node-scoped map. A node that was not the home therefore looked like it had no master state, so its stale enable or disable overwrote the shared record and adopted settings, flipping the client every tick, and a renewal on that node was missed. The merge now uses the global per-email traffic lookup, including clients reported only in settings.
This commit is contained in:
@@ -197,6 +197,23 @@ func (p *panel) deleteInboundRow(id int) {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *panel) setClientTrafficHome(email string, inboundID int) {
|
||||
p.t.Helper()
|
||||
if p.cmd != nil {
|
||||
p.t.Fatalf("%s: setClientTrafficHome on a running panel", p.name)
|
||||
}
|
||||
sharedDBMu.Lock()
|
||||
defer sharedDBMu.Unlock()
|
||||
if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
|
||||
p.t.Fatalf("%s: open db: %v", p.name, err)
|
||||
}
|
||||
defer func() { _ = database.CloseDB() }()
|
||||
result := database.GetDB().Table("client_traffics").Where("email = ?", email).Update("inbound_id", inboundID)
|
||||
if result.Error != nil || result.RowsAffected != 1 {
|
||||
p.t.Fatalf("%s: set traffic home: rows=%d error=%v", p.name, result.RowsAffected, result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *panel) seedClientIpAttribution(email, ips string) string {
|
||||
p.t.Helper()
|
||||
if p.cmd != nil {
|
||||
|
||||
@@ -230,6 +230,57 @@ func runNodeSyncScenarios(t *testing.T, bin, scope string) {
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("a stale node disable cannot overwrite a client homed on the master", func(t *testing.T) {
|
||||
pr.t = t
|
||||
master.stop()
|
||||
master.setClientTrafficHome("p3", localIb.Id)
|
||||
master.start()
|
||||
eventually(t, settleTimeout, "node has no pending master push", func() (bool, string) {
|
||||
var current struct {
|
||||
ConfigDirty bool `json:"configDirty"`
|
||||
}
|
||||
if err := json.Unmarshal(master.call(http.MethodGet, "/panel/api/nodes/get/"+strconv.Itoa(pr.nodeID), nil), ¤t); err != nil {
|
||||
t.Fatalf("decode node state: %v", err)
|
||||
}
|
||||
return !current.ConfigDirty, "master still has a pending node push"
|
||||
})
|
||||
nib, ok := pr.nodeInbound(madePort)
|
||||
entry := nib.client("p3")
|
||||
if !ok || entry == nil {
|
||||
t.Fatal("node has no shared p3 client")
|
||||
}
|
||||
entry["enable"] = false
|
||||
node.call(http.MethodPost, "/panel/api/clients/update/p3", entry)
|
||||
readEnabled := func() bool {
|
||||
var payload struct {
|
||||
Client struct {
|
||||
Enable bool `json:"enable"`
|
||||
} `json:"client"`
|
||||
}
|
||||
if err := json.Unmarshal(master.call(http.MethodGet, "/panel/api/clients/get/p3", nil), &payload); err != nil {
|
||||
t.Fatalf("decode shared client: %v", err)
|
||||
}
|
||||
return payload.Client.Enable
|
||||
}
|
||||
converged := func() bool {
|
||||
ib, exists := pr.nodeInbound(madePort)
|
||||
client := ib.client("p3")
|
||||
return readEnabled() && exists && client != nil && client["enable"] == true
|
||||
}
|
||||
eventually(t, settleTimeout, "the master lifecycle restores the stale node copy", func() (bool, string) {
|
||||
if !readEnabled() {
|
||||
t.Fatal("stale node disable overwrote the master's shared client record")
|
||||
}
|
||||
return converged(), "node copy is still disabled"
|
||||
})
|
||||
for until := time.Now().Add(12 * time.Second); time.Now().Before(until); {
|
||||
if !converged() {
|
||||
t.Fatal("shared client enable state drifted across later sync cycles")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("detaching a client from the node inbound removes it there", func(t *testing.T) {
|
||||
pr.t = t
|
||||
master.call(http.MethodPost, "/panel/api/clients/p2/detach", map[string]any{"inboundIds": []int{madeID}})
|
||||
|
||||
Reference in New Issue
Block a user