From 8dd3b31ee887a57d21b0be4ee7ad1f7f1490dfaf Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 2 Jul 2026 09:36:07 +0200 Subject: [PATCH] fix(node): show the activated first-use deadline on the Clients page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With "start after first use" on a node inbound, the node activates the absolute deadline and the master adopts it into client_traffics via the sync CASE merge — but the client record (what the Clients page reads) was only refreshed by SyncInbound from the snapshot's settings JSON. A node whose JSON still carried the negative duration (stale conversion, older node build, or a mixed local+node attachment) kept rewriting the record back to "not started" even though the DB held the real deadline (#5714). Lift the activated deadline from client_traffics onto still-negative client records at the end of every node sync, after SyncInbound has run. Intentional resets back to delayed start are unaffected: editing a client also resets client_traffics to the negative duration, so the lift's expiry_time > 0 guard never matches. Closes #5714 --- internal/web/service/inbound_node.go | 15 +++++++ .../service/node_client_expiry_sync_test.go | 42 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index cb23d70a0..1f5464712 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -191,6 +191,17 @@ func mergeActivationExpiry(existing, node int64) int64 { return node } +// liftActivatedClientRecordExpiries copies a node-activated deadline from +// client_traffics onto client records still holding the negative duration (#5714). +func liftActivatedClientRecordExpiries(tx *gorm.DB) error { + return tx.Exec( + `UPDATE clients + SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1) + WHERE clients.expiry_time < 0 + AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`, + ).Error +} + func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) { var structuralChange bool err := submitTrafficWrite(func() error { @@ -847,6 +858,10 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi } } + if err := liftActivatedClientRecordExpiries(tx); err != nil { + logger.Warning("setRemoteTraffic: lift activated expiries failed:", err) + } + if err := tx.Commit().Error; err != nil { return false, err } diff --git a/internal/web/service/node_client_expiry_sync_test.go b/internal/web/service/node_client_expiry_sync_test.go index 21f2995c7..17eba9626 100644 --- a/internal/web/service/node_client_expiry_sync_test.go +++ b/internal/web/service/node_client_expiry_sync_test.go @@ -3,6 +3,7 @@ package service import ( "testing" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) @@ -139,3 +140,44 @@ func TestNodeRenewExtendsExpiry(t *testing.T) { t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed) } } + +// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates +// the deadline (positive ClientStats) while its settings JSON still carries the +// negative duration, so SyncInbound keeps writing the stale value into the +// client record and the Clients page shows "not started" forever. +func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) { + db := initTrafficTestDB(t) + createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed") + svc := &InboundService{} + + const email = "delayed" + const duration = int64(-2592000000) + const activated = int64(1798448344010) + negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}` + + if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil { + t.Fatalf("seed client record: %v", err) + } + + readRecordExpiry := func() int64 { + t.Helper() + var rec model.ClientRecord + if err := db.Where("email = ?", email).First(&rec).Error; err != nil { + t.Fatalf("read client record: %v", err) + } + return rec.ExpiryTime + } + + syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true}) + if got := readRecordExpiry(); got != duration { + t.Fatalf("before activation: record expiry = %d, want %d", got, duration) + } + + syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true}) + if got := readTraffic(t, db, email).ExpiryTime; got != activated { + t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated) + } + if got := readRecordExpiry(); got != activated { + t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated) + } +}