diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index 0c49ba2c8..b896f1d55 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -196,6 +196,18 @@ func mergeActivationExpiry(existing, node int64) int64 { return node } +// nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved +// forward while the node's cumulative counter fell below the stored baseline. +func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool { + if cs.Reset <= 0 || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 { + return false + } + if cs.ExpiryTime <= existing.ExpiryTime { + return false + } + return canon.Up < base.Up || canon.Down < base.Down +} + // liftActivatedClientRecordExpiries copies a node-activated deadline from // client_traffics onto client records still holding the negative duration (#5714). func liftActivatedClientRecordExpiries(tx *gorm.DB) error { @@ -728,7 +740,8 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi continue } - if existing := centralCSByEmail[cs.Email]; existing != nil && + existing := centralCSByEmail[cs.Email] + if existing != nil && (existing.Enable != cs.Enable || existing.Total != cs.Total || existing.ExpiryTime != mergeActivationExpiry(existing.ExpiryTime, cs.ExpiryTime) || @@ -736,31 +749,53 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi structuralChange = true } - enableExpr := database.ClientTrafficEnableMergeExpr() - // expiry_time merge mirrors mergeActivationExpiry: a node that has not - // yet seen the client's first connection keeps reporting the negative - // "start after first connect" duration, which must never reset the - // absolute deadline another node already activated. A positive node - // value is still adopted (e.g. auto-renew moves the deadline forward). - // CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise - // infer int4 from the literal and overflow on real expiry values. - if err := tx.Exec( - fmt.Sprintf( - `UPDATE client_traffics - SET up = %s, down = %s, enable = %s, total = ?, - expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END, - reset = ?, last_online = %s - WHERE email = ?`, - database.ClampedAddExpr("up"), - database.ClampedAddExpr("down"), - enableExpr, - database.GreatestExpr("last_online", "?"), - ), - deltaUp, deltaDown, cs.Enable, cs.Total, - cs.ExpiryTime, cs.ExpiryTime, cs.Reset, - cs.LastOnline, cs.Email, - ).Error; err != nil { - return false, err + if seen && existing != nil && nodeClientRenewed(existing, cs, canon, base) { + // A renewal starts a fresh quota window: adopt the node's counters + // and enable state, drop stale pushes (mirrors autoRenewClients). + if err := tx.Exec( + fmt.Sprintf( + `UPDATE client_traffics + SET up = ?, down = ?, enable = ?, total = ?, + expiry_time = ?, reset = ?, last_online = %s + WHERE email = ?`, + database.GreatestExpr("last_online", "?"), + ), + canon.Up, canon.Down, cs.Enable, cs.Total, + cs.ExpiryTime, cs.Reset, + cs.LastOnline, cs.Email, + ).Error; err != nil { + return false, err + } + if err := clearGlobalTraffic(tx, cs.Email); err != nil { + return false, err + } + } else { + enableExpr := database.ClientTrafficEnableMergeExpr() + // expiry_time merge mirrors mergeActivationExpiry: a node that has not + // yet seen the client's first connection keeps reporting the negative + // "start after first connect" duration, which must never reset the + // absolute deadline another node already activated. A positive node + // value is still adopted (e.g. auto-renew moves the deadline forward). + // CAST(? AS BIGINT): in the `<= 0` comparison Postgres would otherwise + // infer int4 from the literal and overflow on real expiry values. + if err := tx.Exec( + fmt.Sprintf( + `UPDATE client_traffics + SET up = %s, down = %s, enable = %s, total = ?, + expiry_time = CASE WHEN expiry_time > 0 AND CAST(? AS BIGINT) <= 0 THEN expiry_time ELSE CAST(? AS BIGINT) END, + reset = ?, last_online = %s + WHERE email = ?`, + database.ClampedAddExpr("up"), + database.ClampedAddExpr("down"), + enableExpr, + database.GreatestExpr("last_online", "?"), + ), + deltaUp, deltaDown, cs.Enable, cs.Total, + cs.ExpiryTime, cs.ExpiryTime, cs.Reset, + cs.LastOnline, cs.Email, + ).Error; err != nil { + return false, err + } } if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil { return false, err diff --git a/internal/web/service/node_client_renew_sync_test.go b/internal/web/service/node_client_renew_sync_test.go new file mode 100644 index 000000000..42486d291 --- /dev/null +++ b/internal/web/service/node_client_renew_sync_test.go @@ -0,0 +1,109 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +const ( + renewFirstExpiry = int64(1893456000000) + renewSecondExpiry = renewFirstExpiry + int64(2592000000) + renewPeriodDays = 30 +) + +// TestNodeRenew_ResetsMasterTraffic reproduces #5843: a node-side auto-renew +// extends the deadline and resets the node's counters, and the master must +// start a fresh quota window instead of keeping the old period's usage. +func TestNodeRenew_ResetsMasterTraffic(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + svc := &InboundService{} + + const email = "renew-reset" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 950, 150, "before renewal") + + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 5, Down: 2, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) + ct := readTraffic(t, db, email) + assertUpDown(t, ct, 5, 2, "after renewal") + if ct.ExpiryTime != renewSecondExpiry { + t.Errorf("renewal expiry = %d, want %d", ct.ExpiryTime, renewSecondExpiry) + } + + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 15, Down: 12, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 15, 12, "accumulating in the new period") +} + +// TestNodeRenew_ReenablesDepletedClient: the node re-enables a client it +// renews; the master's depleted-disable must lift with the fresh window. +func TestNodeRenew_ReenablesDepletedClient(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + svc := &InboundService{} + + const email = "renew-enable" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("enable", false).Error; err != nil { + t.Fatalf("force-disable master row: %v", err) + } + + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) + if ct := readTraffic(t, db, email); !ct.Enable { + t.Error("renewal must re-enable the master row when the node reports the client enabled") + } +} + +// TestNodeRenew_ClearsGlobalTraffic mirrors autoRenewClients: stale cross-panel +// pushes for the renewed email must not re-deplete the fresh window. +func TestNodeRenew_ClearsGlobalTraffic(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + svc := &InboundService{} + + const email = "renew-global" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + if err := db.Create(&model.ClientGlobalTraffic{MasterGuid: "m1", Email: email, Up: 800, Down: 90}).Error; err != nil { + t.Fatalf("seed global traffic: %v", err) + } + + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 3, Down: 1, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) + + var cnt int64 + if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { + t.Fatalf("count global traffic: %v", err) + } + if cnt != 0 { + t.Errorf("renewal must clear stale global-traffic rows, found %d", cnt) + } +} + +// TestNodeCounterDip_SameExpiry_KeepsTraffic: a plain counter dip (#5456) with +// no deadline movement stays on the clamp path even for a renewable client. +func TestNodeCounterDip_SameExpiry_KeepsTraffic(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + svc := &InboundService{} + + const email = "dip-only" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 10, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 950, 150, "dip without renewal") +} + +// TestNodeExpiryAdvance_RisingCounters_Accumulates: a manual deadline extension +// while traffic keeps flowing is not a renewal and must keep accumulating. +func TestNodeExpiryAdvance_RisingCounters_Accumulates(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInbound(t, db, 1, "n1-in", 41001) + svc := &InboundService{} + + const email = "extend-only" + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 50, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) + syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 160, Down: 90, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) + assertUpDown(t, readTraffic(t, db, email), 160, 90, "extension while accumulating") +}