Files
3x-ui/internal/web/service/calendar_renew.go
T
MHSanaei 351a01ff0c 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.
2026-10-31 14:32:31 +01:00

145 lines
4.3 KiB
Go

package service
import (
"time"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
// nextCalendarRenewal returns the next renewal strictly after from, at midnight
// in loc; a missing day clamps to the month's last, so the 31st comes back (#6106).
func nextCalendarRenewal(from time.Time, day int, loc *time.Location) time.Time {
if loc == nil {
loc = time.UTC
}
if day < 1 {
day = 1
}
if day > 31 {
day = 31
}
local := from.In(loc)
candidate := calendarDay(local.Year(), local.Month(), day, loc)
if !candidate.After(local) {
year, month := local.Year(), local.Month()+1
if month > time.December {
year, month = year+1, time.January
}
candidate = calendarDay(year, month, day, loc)
}
return candidate
}
// Clamped rather than normalized: time.Date rolls 31 February into March, which
// is the drift this mode exists to avoid.
func calendarDay(year int, month time.Month, day int, loc *time.Location) time.Time {
last := daysInMonth(year, month)
if day > last {
day = last
}
return time.Date(year, month, day, 0, 0, 0, 0, loc)
}
func daysInMonth(year int, month time.Month) int {
return time.Date(year, month+1, 0, 0, 0, 0, 0, time.UTC).Day()
}
// Advance by calendar dates, not 168 hours; a repeated midnight must not spend
// another weekly allowance on the same local date.
func nextWeeklyRenewal(from time.Time, weekday int, loc *time.Location) time.Time {
if loc == nil {
loc = time.UTC
}
local := from.In(loc)
days := (min(7, max(1, weekday))%7 - int(local.Weekday()) + 7) % 7
if days == 0 {
days = 7
}
date := time.Date(local.Year(), local.Month(), local.Day()+days, 0, 0, 0, 0, time.UTC)
// A corrupt or unusual zone must not stall the single traffic writer.
// Returning from lets the catch-up forward-progress guard fail closed.
for range 8 {
candidate, exists := localCalendarDateStart(date, loc)
if exists && candidate.After(local) {
return candidate
}
date = date.AddDate(0, 0, 7)
}
return from
}
// Find the first valid instant of a local date: Date can pick a repeated
// midnight or normalize a nonexistent midnight into the preceding day.
func localCalendarDateStart(date time.Time, loc *time.Location) (time.Time, bool) {
localDate := func(at time.Time) time.Time {
local := at.In(loc)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.UTC)
}
candidate := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
if localDate(candidate).Equal(date) && localDate(candidate.Add(-time.Second)).Before(date) {
return candidate, true
}
low, high := candidate.Unix()-86400, candidate.Unix()+86400
for low < high {
mid := low + (high-low)/2
if localDate(time.Unix(mid, 0)).Before(date) {
low = mid + 1
} else {
high = mid
}
}
start := time.Unix(low, 0).In(loc)
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()
}
if weekday > 0 {
return nextWeeklyRenewal(time.UnixMilli(expiry), weekday, loc).UnixMilli()
}
if reset > maxClientResetDays {
return expiry
}
return expiry + int64(reset)*86400000
}
func canonicalRenewalExpiry(expiry int64, reset, day, weekday int, loc *time.Location) int64 {
if day > 0 || weekday > 0 {
boundary := nextClientRenewal(expiry, reset, day, weekday, loc)
if expiry >= boundary-1000 && expiry < boundary {
return boundary
}
}
return expiry
}
func catchUpClientRenewal(traffic *xray.ClientTraffic, now int64, loc *time.Location) (int64, int) {
if traffic.ResetDay <= 0 && traffic.ResetWeekday <= 0 && traffic.Reset <= 0 {
return traffic.ExpiryTime, 0
}
expiry := canonicalRenewalExpiry(traffic.ExpiryTime, traffic.Reset, traffic.ResetDay, traffic.ResetWeekday, loc)
renewals := 0
for expiry < now {
if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax {
break
}
next := nextClientRenewal(expiry, traffic.Reset, traffic.ResetDay, traffic.ResetWeekday, loc)
if next <= expiry {
return traffic.ExpiryTime, 0
}
expiry = next
renewals++
}
if renewals == 0 {
return traffic.ExpiryTime, 0
}
return expiry, renewals
}