fix(node): show the activated first-use deadline on the Clients page

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
This commit is contained in:
MHSanaei
2026-07-02 09:36:07 +02:00
parent e5b56c9444
commit 8dd3b31ee8
2 changed files with 57 additions and 0 deletions
+15
View File
@@ -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
}
@@ -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)
}
}