Files
3x-ui/internal/web/service/node_sweep_guard_test.go
T
mrchatam 6f7a305239 fix(node): stop stale expiry sync from undoing client extensions (#6228) (#6231)
* fix(node): stop stale expiry sync from undoing client extensions (#6228)

After an expired client is extended on the master, a lagging node could
overwrite client_traffics with an older absolute expiry and latch
enable=false. Reject older absolute expiries on merge, ignore
expiry-stale disables when the master is not over quota, lift stale
lifecycle fields out of adopted settings, stamp reconcile fingerprints
from the pre-lift node blob, and mark the node dirty so the next tick
re-pushes.

* fix(node): lockstep client_traffics expiry/enable SQL with review fixes (#6228)

Make expiry merge keep any master absolute (node only activates when master
is unset/duration). Include this tick's up/down deltas in the enable
stale-disable quota check so a crossing-tick disable is not dropped.

* fix(node): add settings absolute helper for renew/lift guards (#6228)

Expose settingsClientAbsoluteExpiry so traffic merge can tell a real
node auto-renew (settings+stats later) from lagging ClientStats after a
master shorten. Trim lift godoc to the invariant.

* fix(node): authority-aware lifecycle merge for multi-node sync (#6228)

While config_dirty, accumulate traffic only — do not adopt node
expiry/enable/total/reset (and preserve dipped baselines so a false
renew cannot fire after clear). On clean ticks, master absolute expiry
wins; node auto-renew still goes through nodeClientRenewed when settings
also show the later deadline. Defer settings lifecycle lift until after
traffic deltas land, align SyncInbound via applyMasterClientLifecycle,
and avoid re-MarkNodeDirty when already dirty.

* fix(node): clear config_dirty only after the post-reconcile traffic merge (#6228)

After a successful ReconcileNode, keep the node dirty through the same
tick's SetRemoteTraffic so lagging ClientStats cannot clobber the
just-pushed master lifecycle, then ClearNodeDirty.

* test(node): cover dirty-gate, master-absolute, and renew false-positives (#6228)

Add regressions for extend/shorten while dirty, clean-sibling shorten,
settings vs lagging disable, renew recovery after dirty, renew with
matching settings, and shorten+Reset lagging stats not treated as renew.

* fix(node): address the review findings on the lifecycle merge (#6228)

The automated review on #6231 flagged a blocking regression and six smaller
issues. All of them are fixed here.

Blocking: making the master's absolute expiry always win left nodeClientRenewed
as the only channel for a node-side auto-renew, and that required a counter dip.
A client that used no traffic in the period never dips, so its renewal was
dropped, the master kept the expired deadline and disableInvalidClients removed
it with no way back (master-side autoRenewClients skips node inbounds). The node
bumps reset_count on every renewal, so that counter is now an independent
renewal signal and is persisted with the renewal so it keeps converging.

The deferred ClearNodeDirty made every reconcile-success tick merge in dirty
mode, which suppressed inbound adoption, new client_traffics rows, the orphan
sweeps and the whole SyncInbound record loop -- and left the node dirty forever
whenever SetRemoteTraffic errored. The clear goes back to where it was; a
separate justPushed flag now freezes only the client lifecycle merge for the
tick whose push just landed.

staleNodeDisable only recognised a lagging disable by an older expiry, so a
quota top-up (raise totalGB, leave the expiry alone) was re-latched to disabled
by the next lagging snapshot -- the #6228 symptom on a second axis. The
reviewer's suggestion of dropping the expiry precondition outright fails
TestNodeQuotaDisable_SameExpiryStillLatches, because the master's own counters
legitimately sit below a node's after a seeded-at-zero adoption. nodeDisableIsStale
instead compares the limits the node judged the client against with the master's
own: matching limits mean a genuine verdict that still latches (#4917), differing
limits mean the node has not seen the master's change yet. It also now measures
the master deadline against wall-clock now, so an expired master row no longer
looks "extended" merely because the node's copy is older still.

Also: the settings lift now writes enable in both directions, so a blob fetched
before a master disable cannot carry enable=true back into central settings and
on to the node; the renewal guard parses the inbound settings once per inbound
instead of once per renewing client; the adoption loop only writes settings when
they actually changed; and two comments that described mechanisms the code does
not use were corrected.

The test deadlines are now relative to the run: the merge compares against now,
so fixed timestamps would have rotted into the wrong side of it.

---------

Co-authored-by: mrchatam <287639636+mrchatam@users.noreply.github.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-08-23 23:17:56 +02:00

163 lines
5.9 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"fmt"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
"gorm.io/gorm"
)
func countClientRows(t *testing.T, db *gorm.DB, email string) (records, traffics int64) {
t.Helper()
if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&records).Error; err != nil {
t.Fatalf("count clients %q: %v", email, err)
}
if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&traffics).Error; err != nil {
t.Fatalf("count client_traffics %q: %v", email, err)
}
return records, traffics
}
func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) {
t.Helper()
if err := db.Create(n).Error; err != nil {
t.Fatalf("create node: %v", err)
}
}
func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}},
}
}
func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot {
t.Helper()
return snapshotWithClients(t, tag, `{"clients":[]}`)
}
func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot {
t.Helper()
return &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}},
{Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}},
},
}
}
// The job samples config_dirty before the snapshot round-trip; a client added
// in that window is deleted again unless the merge re-reads the flag itself.
func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true})
const email = "carol"
createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email)
settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email)
syncNodeWithSettings(t, svc, 1, "n1-in", settings,
xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true})
if rec, _ := countClientRows(t, db, email); rec != 1 {
t.Fatalf("setup: client not attached, got %d rows", rec)
}
if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil {
t.Fatalf("mark node dirty: %v", err)
}
if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
rec, traf := countClientRows(t, db, email)
if rec != 1 || traf != 1 {
t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
}
// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as
// "the node deleted it" wiped the master's copy of an inbound still running.
func TestDeselectedTagIsNotSwept(t *testing.T) {
db := initTrafficTestDB(t)
svc := &InboundService{}
seedNodeRow(t, db, &model.Node{
Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true,
InboundSyncMode: "selected", InboundTags: []string{"keep"},
})
createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x")
createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x")
keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}`
dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}`
snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x")
if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil {
t.Fatalf("seed sync: %v", err)
}
if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 {
t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf)
}
keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true})
if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false, false); err != nil {
t.Fatalf("post-deselect sync: %v", err)
}
rec, traf := countClientRows(t, db, "dropped@x")
if rec != 1 || traf != 1 {
t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf)
}
var inbounds int64
if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil {
t.Fatalf("count inbounds: %v", err)
}
if inbounds != 1 {
t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds)
}
}
func TestSyncInboundStoresTrimmedEmail(t *testing.T) {
db := initTrafficTestDB(t)
svc := &ClientService{}
ib := &model.Inbound{UserId: 1, Tag: "trim-in", Enable: true, Port: 41501, Protocol: model.VLESS}
if err := database.GetDB().Create(ib).Error; err != nil {
t.Fatalf("create inbound: %v", err)
}
padded := "bob "
clients := []model.Client{{Email: padded, Enable: true}}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("first SyncInbound: %v", err)
}
var stored []string
if err := db.Model(&model.ClientRecord{}).Pluck("email", &stored).Error; err != nil {
t.Fatalf("read clients: %v", err)
}
if len(stored) != 1 || stored[0] != "bob" {
t.Fatalf("stored email = %q, want the trimmed %q — the lookup key must match what is written", stored, "bob")
}
if err := svc.SyncInbound(nil, ib.Id, clients); err != nil {
t.Fatalf("second SyncInbound must not hit a unique-constraint on the untrimmed row: %v", err)
}
var links int64
if err := db.Model(&model.ClientInbound{}).Where("inbound_id = ?", ib.Id).Count(&links).Error; err != nil {
t.Fatalf("count links: %v", err)
}
if links != 1 {
t.Fatalf("links after re-sync = %d, want 1", links)
}
}