fix(clients): bound the auto-renew interval at a century

An interval renewal turned the client's reset days into milliseconds
with no bound, so a value that wraps int64 (a node reports it unchecked)
sent the catch-up loop stepping through time for seconds per client
while the traffic writer held the database. Intervals above 36500 days
no longer renew, and every client write refuses them.
This commit is contained in:
MHSanaei
2026-10-31 14:32:31 +01:00
parent 47e9ef1a07
commit 351a01ff0c
4 changed files with 29 additions and 0 deletions
+7
View File
@@ -93,6 +93,10 @@ func localCalendarDateStart(date time.Time, loc *time.Location) (time.Time, bool
return start, localDate(start).Equal(date)
}
// maxClientResetDays bounds an interval renewal at a century: a larger one, which a node can
// report, wraps int64 once turned into milliseconds.
const maxClientResetDays = 36500
func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location) int64 {
if day > 0 {
return nextCalendarRenewal(time.UnixMilli(expiry), day, loc).UnixMilli()
@@ -100,6 +104,9 @@ func nextClientRenewal(expiry int64, reset, day, weekday int, loc *time.Location
if weekday > 0 {
return nextWeeklyRenewal(time.UnixMilli(expiry), weekday, loc).UnixMilli()
}
if reset > maxClientResetDays {
return expiry
}
return expiry + int64(reset)*86400000
}
@@ -76,3 +76,21 @@ func TestWeeklyRenewalSearchFailsClosed(t *testing.T) {
})
}
}
// A node reports the renewal interval unchecked; one that wraps int64 when turned into
// milliseconds made the catch-up loop step through time for seconds per client.
func TestCatchUpClientRenewalRefusesAnOversizedInterval(t *testing.T) {
now := time.Now().UnixMilli()
for _, reset := range []int{106751991167, maxClientResetDays + 1} {
traffic := &xray.ClientTraffic{Email: "c", Reset: reset, ExpiryTime: now - 86400000}
start := time.Now()
expiry, renewals := catchUpClientRenewal(traffic, now, time.UTC)
if expiry != traffic.ExpiryTime || renewals != 0 || time.Since(start) > 100*time.Millisecond {
t.Fatalf("reset %d: expiry %d, renewals %d after %v; want no renewal", reset, expiry, renewals, time.Since(start))
}
}
traffic := &xray.ClientTraffic{Email: "c", Reset: maxClientResetDays, ExpiryTime: now - 86400000}
if _, renewals := catchUpClientRenewal(traffic, now, time.UTC); renewals != 1 {
t.Fatalf("a %d-day interval renewed %d times, want once", maxClientResetDays, renewals)
}
}
+3
View File
@@ -86,6 +86,9 @@ func validateClientRenewal(client model.Client) error {
if err := validateClientResetDay(client.ResetDay); err != nil {
return err
}
if client.Reset > maxClientResetDays {
return common.NewError("client reset must not exceed", maxClientResetDays, "days")
}
if client.ResetWeekday < 0 || client.ResetWeekday > 7 {
return common.NewError("client resetWeekday must be between 0 and 7, got:", client.ResetWeekday)
}
@@ -20,6 +20,7 @@ func TestClientRenewalWriteValidation(t *testing.T) {
{"weekly and interval", 7, 0, 3, "client weekly renewal cannot be combined with reset or resetDay"},
{"weekly and monthly", 0, 1, 3, "client weekly renewal cannot be combined with reset or resetDay"},
{"invalid weekday", 0, 0, 8, "client resetWeekday must be between 0 and 7, got: 8"},
{"oversized interval", maxClientResetDays + 1, 0, 0, "client reset must not exceed 36500 days"},
} {
t.Run(operation+"/"+schedule.name, func(t *testing.T) {
setupConflictDB(t)