diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 36463014e..8336ebcdc 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -1110,6 +1110,10 @@ "description": "Reset period in days", "type": "integer" }, + "resetMax": { + "description": "Max auto-renew count, 0 = unlimited", + "type": "integer" + }, "reverse": { "allOf": [ { @@ -1154,6 +1158,7 @@ "expiryTime", "limitIp", "reset", + "resetMax", "security", "subId", "tgId", @@ -1246,6 +1251,9 @@ "reset": { "type": "integer" }, + "resetMax": { + "type": "integer" + }, "reverse": {}, "secret": { "type": "string" @@ -1292,6 +1300,7 @@ "privateKey", "publicKey", "reset", + "resetMax", "reverse", "secret", "security", @@ -1357,6 +1366,16 @@ "example": 0, "type": "integer" }, + "resetCount": { + "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.", + "example": 0, + "type": "integer" + }, + "resetMax": { + "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.", + "example": 0, + "type": "integer" + }, "subId": { "example": "i7tvdpeffi0hvvf1", "type": "string" @@ -1386,6 +1405,8 @@ "lastOnline", "lastSubFetch", "reset", + "resetCount", + "resetMax", "subId", "total", "up", @@ -3327,6 +3348,8 @@ "lastOnline": 1735680000000, "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, @@ -8130,6 +8153,8 @@ "lastOnline": 1735680000000, "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, diff --git a/frontend/src/generated/examples.ts b/frontend/src/generated/examples.ts index 16fe4094e..7b7535ae3 100644 --- a/frontend/src/generated/examples.ts +++ b/frontend/src/generated/examples.ts @@ -256,6 +256,7 @@ export const EXAMPLES: Record = { "privateKey": "", "publicKey": "", "reset": 0, + "resetMax": 0, "reverse": null, "secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d", "security": "", @@ -290,6 +291,7 @@ export const EXAMPLES: Record = { "privateKey": "", "publicKey": "", "reset": 0, + "resetMax": 0, "reverse": null, "secret": "", "security": "", @@ -312,6 +314,8 @@ export const EXAMPLES: Record = { "lastOnline": 1735680000000, "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, @@ -478,6 +482,8 @@ export const EXAMPLES: Record = { "lastOnline": 1735680000000, "lastSubFetch": 1735680000000, "reset": 0, + "resetCount": 0, + "resetMax": 0, "subId": "i7tvdpeffi0hvvf1", "total": 10737418240, "up": 1048576, diff --git a/frontend/src/generated/schemas.ts b/frontend/src/generated/schemas.ts index 53acd2177..f4a8ab819 100644 --- a/frontend/src/generated/schemas.ts +++ b/frontend/src/generated/schemas.ts @@ -1084,6 +1084,10 @@ export const SCHEMAS: Record = { "description": "Reset period in days", "type": "integer" }, + "resetMax": { + "description": "Max auto-renew count, 0 = unlimited", + "type": "integer" + }, "reverse": { "allOf": [ { @@ -1128,6 +1132,7 @@ export const SCHEMAS: Record = { "expiryTime", "limitIp", "reset", + "resetMax", "security", "subId", "tgId", @@ -1220,6 +1225,9 @@ export const SCHEMAS: Record = { "reset": { "type": "integer" }, + "resetMax": { + "type": "integer" + }, "reverse": {}, "secret": { "type": "string" @@ -1266,6 +1274,7 @@ export const SCHEMAS: Record = { "privateKey", "publicKey", "reset", + "resetMax", "reverse", "secret", "security", @@ -1331,6 +1340,16 @@ export const SCHEMAS: Record = { "example": 0, "type": "integer" }, + "resetCount": { + "description": "ResetCount is how many have fired, so a prepaid plan stops on its own.", + "example": 0, + "type": "integer" + }, + "resetMax": { + "description": "ResetMax caps how many times auto-renew may fire; 0 means no cap.", + "example": 0, + "type": "integer" + }, "subId": { "example": "i7tvdpeffi0hvvf1", "type": "string" @@ -1360,6 +1379,8 @@ export const SCHEMAS: Record = { "lastOnline", "lastSubFetch", "reset", + "resetCount", + "resetMax", "subId", "total", "up", diff --git a/frontend/src/generated/types.ts b/frontend/src/generated/types.ts index bf02db678..695bbc945 100644 --- a/frontend/src/generated/types.ts +++ b/frontend/src/generated/types.ts @@ -266,6 +266,7 @@ export interface Client { privateKey?: string; publicKey?: string; reset: number; + resetMax: number; reverse?: ClientReverse | null; secret?: string; security: string; @@ -302,6 +303,7 @@ export interface ClientRecord { privateKey: string; publicKey: string; reset: number; + resetMax: number; reverse: unknown; secret: string; security: string; @@ -326,6 +328,8 @@ export interface ClientTraffic { lastOnline: number; lastSubFetch: number; reset: number; + resetCount: number; + resetMax: number; subId: string; total: number; up: number; diff --git a/frontend/src/generated/zod.ts b/frontend/src/generated/zod.ts index 74c5d2454..fe67d9b5d 100644 --- a/frontend/src/generated/zod.ts +++ b/frontend/src/generated/zod.ts @@ -286,6 +286,7 @@ export const ClientSchema = z.object({ privateKey: z.string().optional(), publicKey: z.string().optional(), reset: z.number().int(), + resetMax: z.number().int(), reverse: z.lazy(() => ClientReverseSchema).nullable().optional(), secret: z.string().optional(), security: z.string(), @@ -324,6 +325,7 @@ export const ClientRecordSchema = z.object({ privateKey: z.string(), publicKey: z.string(), reset: z.number().int(), + resetMax: z.number().int(), reverse: z.unknown(), secret: z.string(), security: z.string(), @@ -350,6 +352,8 @@ export const ClientTrafficSchema = z.object({ lastOnline: z.number().int(), lastSubFetch: z.number().int(), reset: z.number().int(), + resetCount: z.number().int(), + resetMax: z.number().int(), subId: z.string(), total: z.number().int(), up: z.number().int(), diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index c3be24234..679d23d61 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -37,6 +37,7 @@ const EMPTY: ClientBulkAddFormValues = { totalGB: 0, expiryTime: 0, reset: 0, + resetMax: 0, inboundIds: [], }; @@ -176,6 +177,7 @@ export default function ClientBulkAddModal({ totalGB: Math.round((current.totalGB || 0) * SizeFormatter.ONE_GB), expiryTime: current.expiryTime, reset: Number(current.reset) || 0, + resetMax: Number(current.resetMax) || 0, limitIp: Number(current.limitIp) || 0, limitHwid: Number(current.limitHwid) || 0, group: current.group, @@ -374,6 +376,15 @@ export default function ClientBulkAddModal({ > + + Number(v) || 0 }} + > + + diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 04096426c..f42e27fea 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -131,6 +131,7 @@ const EMPTY: Values = { delayedStart: false, delayedDays: 0, reset: 0, + resetMax: 0, limitIp: 0, limitHwid: 0, tgId: 0, @@ -250,6 +251,7 @@ export default function ClientFormModal({ reverseTag: client.reverse?.tag || '', totalGB: bytesToGB(client.totalGB || 0), reset: Number(client.reset) || 0, + resetMax: Number(client.resetMax) || 0, limitIp: client.limitIp || 0, limitHwid: client.limitHwid || 0, tgId: Number(client.tgId) || 0, @@ -538,6 +540,7 @@ email: values.email, delayedStart: values.delayedStart, delayedDays: values.delayedDays, reset: values.reset, + resetMax: values.resetMax, limitIp: values.limitIp, limitHwid: values.limitHwid, tgId: values.tgId, @@ -566,6 +569,7 @@ email: values.email, totalGB: totalBytes, expiryTime, reset: Number(values.reset) || 0, + resetMax: Number(values.resetMax) || 0, limitIp: Number(values.limitIp) || 0, limitHwid: Number(values.limitHwid) || 0, tgId: Number(values.tgId) || 0, @@ -785,6 +789,16 @@ reset: Number(values.reset) || 0, + + Number(v) || 0 }} + > + + + diff --git a/frontend/src/pages/clients/ClientInfoModal.tsx b/frontend/src/pages/clients/ClientInfoModal.tsx index d7dac19f9..e0eeb6816 100644 --- a/frontend/src/pages/clients/ClientInfoModal.tsx +++ b/frontend/src/pages/clients/ClientInfoModal.tsx @@ -325,6 +325,16 @@ export default function ClientInfoModal({ + {(traffic?.resetMax ?? 0) > 0 && ( + + {t('pages.clients.renewsUsed')} + + = (traffic?.resetMax ?? 0) ? 'red' : 'blue'}> + {traffic?.resetCount ?? 0} / {traffic?.resetMax} + + + + )} {t('pages.inbounds.createdAt')} {dateLabel(client.createdAt)} diff --git a/frontend/src/schemas/client.ts b/frontend/src/schemas/client.ts index 107793981..82fae2dbc 100644 --- a/frontend/src/schemas/client.ts +++ b/frontend/src/schemas/client.ts @@ -11,6 +11,8 @@ export const ClientTrafficSchema = z.object({ enable: z.boolean().optional(), lastOnline: z.number().optional(), lastSubFetch: z.number().optional(), + resetMax: z.number().optional(), + resetCount: z.number().optional(), }); export const ClientRecordSchema = z.object({ @@ -31,6 +33,7 @@ export const ClientRecordSchema = z.object({ comment: z.string().optional(), enable: z.boolean().optional(), reset: z.number().optional(), + resetMax: z.number().optional(), inboundIds: nullableNumberArray.optional(), traffic: ClientTrafficSchema.nullable().optional(), reverse: z.object({ tag: z.string().optional() }).loose().nullable().optional(), @@ -205,6 +208,7 @@ export const ClientFormSchema = z.object({ delayedStart: z.boolean(), delayedDays: z.number().int().min(0), reset: z.number().int().min(0), + resetMax: z.number().int().min(0), limitIp: z.number().int().min(0), limitHwid: z.number().int().min(0), tgId: z.number().int().min(0), @@ -244,6 +248,7 @@ export const ClientBulkAddFormSchema = z.object({ totalGB: z.number().min(0), expiryTime: z.number(), reset: z.number().int().min(0), + resetMax: z.number().int().min(0), inboundIds: z.array(z.number()).min(1, 'pages.clients.selectInbound'), }); diff --git a/internal/database/model/model.go b/internal/database/model/model.go index fe5aa6d7a..2813eb1e0 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -894,6 +894,7 @@ type Client struct { Group string `json:"group,omitempty" form:"group"` // Logical grouping label Comment string `json:"comment" form:"comment"` // Client comment Reset int `json:"reset" form:"reset"` // Reset period in days + ResetMax int `json:"resetMax" form:"resetMax"` // Max auto-renew count, 0 = unlimited CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp } @@ -924,6 +925,7 @@ type ClientRecord struct { Group string `json:"group" gorm:"column:group_name;default:'';index:idx_client_record_group"` Comment string `json:"comment"` Reset int `json:"reset" gorm:"default:0"` + ResetMax int `json:"resetMax" gorm:"column:reset_max;default:0"` CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"` UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"` // Owned solely by the node-snapshot sweep, which soft-orphans instead of @@ -1105,6 +1107,7 @@ func (c *Client) ToRecord() *ClientRecord { Group: c.Group, Comment: c.Comment, Reset: c.Reset, + ResetMax: c.ResetMax, CreatedAt: c.CreatedAt, UpdatedAt: c.UpdatedAt, @@ -1158,6 +1161,7 @@ func (r *ClientRecord) ToClient() *Client { Group: r.Group, Comment: r.Comment, Reset: r.Reset, + ResetMax: r.ResetMax, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, @@ -1306,6 +1310,12 @@ func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientM existing.Reset = incoming.Reset } } + if existing.ResetMax != incoming.ResetMax && incoming.ResetMax != 0 { + if incomingNewer || existing.ResetMax == 0 { + keep("resetMax", existing.ResetMax, incoming.ResetMax, incoming.ResetMax) + existing.ResetMax = incoming.ResetMax + } + } if existing.Reverse != incoming.Reverse && incoming.Reverse != "" { if incomingNewer || existing.Reverse == "" { keep("reverse", existing.Reverse, incoming.Reverse, incoming.Reverse) diff --git a/internal/web/service/client_crud.go b/internal/web/service/client_crud.go index e96346eeb..e1d6559df 100644 --- a/internal/web/service/client_crud.go +++ b/internal/web/service/client_crud.go @@ -43,6 +43,15 @@ func validateClientSubID(subID string) error { return nil } +// Rejected rather than coerced: a negative cap reads as "unlimited" to a caller +// but selects nothing, so the client would silently stop renewing. +func validateClientResetMax(resetMax int) error { + if resetMax < 0 { + return common.NewError("client resetMax must not be negative, got:", resetMax) + } + return nil +} + func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) { if payload == nil { return false, common.NewError("empty payload") @@ -57,6 +66,9 @@ func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreate if err := validateClientSubID(client.SubID); err != nil { return false, err } + if err := validateClientResetMax(client.ResetMax); err != nil { + return false, err + } if len(payload.InboundIds) == 0 { return false, common.NewError("at least one inbound is required") } @@ -344,6 +356,9 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model if err := validateClientSubID(updated.SubID); err != nil { return false, err } + if err := validateClientResetMax(updated.ResetMax); err != nil { + return false, err + } if updated.SubID == "" { updated.SubID = existing.SubID } @@ -466,6 +481,7 @@ func (s *ClientService) Update(inboundSvc *InboundService, id int, updated model "tg_id": merged.TgID, "comment": merged.Comment, "reset": merged.Reset, + "reset_max": merged.ResetMax, }).Error; err != nil { return needRestart, err } diff --git a/internal/web/service/client_link.go b/internal/web/service/client_link.go index 95b34e2d3..e336a1d97 100644 --- a/internal/web/service/client_link.go +++ b/internal/web/service/client_link.go @@ -63,6 +63,7 @@ func applyClientRecordMerge(row *model.ClientRecord, incoming *model.ClientRecor } row.Comment = incoming.Comment row.Reset = incoming.Reset + row.ResetMax = incoming.ResetMax if incoming.CreatedAt > 0 && (row.CreatedAt == 0 || incoming.CreatedAt < row.CreatedAt) { row.CreatedAt = incoming.CreatedAt } diff --git a/internal/web/service/client_paging.go b/internal/web/service/client_paging.go index a64c2f271..ab9f5300e 100644 --- a/internal/web/service/client_paging.go +++ b/internal/web/service/client_paging.go @@ -26,6 +26,7 @@ type ClientSlim struct { LimitIP int `json:"limitIp"` LimitHwid int `json:"limitHwid"` Reset int `json:"reset"` + ResetMax int `json:"resetMax"` Group string `json:"group,omitempty"` Comment string `json:"comment,omitempty"` InboundIds []int `json:"inboundIds"` @@ -605,6 +606,7 @@ func toClientSlim(c ClientWithAttachments) ClientSlim { LimitIP: c.LimitIP, LimitHwid: c.LimitHwid, Reset: c.Reset, + ResetMax: c.ResetMax, Group: c.Group, Comment: c.Comment, InboundIds: c.InboundIds, diff --git a/internal/web/service/inbound_autorenew_maxcount_test.go b/internal/web/service/inbound_autorenew_maxcount_test.go new file mode 100644 index 000000000..8978d53ef --- /dev/null +++ b/internal/web/service/inbound_autorenew_maxcount_test.go @@ -0,0 +1,287 @@ +package service + +import ( + "encoding/json" + "testing" + "time" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +// A prepaid plan must stop itself: once as many renewals have fired as the +// operator allowed, the client expires like any other (#5804). +func TestAutoRenewClients_StopsAtMaxCount(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-48 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "spent@x", ID: "11111111-1111-1111-1111-111111111111", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past}, + {Email: "left@x", ID: "22222222-2222-2222-2222-222222222222", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past}, + } + ib := mkInbound(t, 30101, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + rows := []xray.ClientTraffic{ + {InboundId: ib.Id, Email: "spent@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 2, ExpiryTime: past}, + {InboundId: ib.Id, Email: "left@x", Enable: false, Reset: 30, ResetMax: 2, ResetCount: 1, ExpiryTime: past}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } else if count != 1 { + t.Fatalf("renewed count = %d, want 1: only the client with an allowance left", count) + } + + var spent xray.ClientTraffic + if err := db.Where("email = ?", "spent@x").First(&spent).Error; err != nil { + t.Fatal(err) + } + if spent.ExpiryTime != past { + t.Fatalf("a client that used its allowance was renewed anyway: expiry %d", spent.ExpiryTime) + } + + var left xray.ClientTraffic + if err := db.Where("email = ?", "left@x").First(&left).Error; err != nil { + t.Fatal(err) + } + if left.ExpiryTime <= past { + t.Fatal("a client with an allowance left was not renewed") + } + if left.ResetCount != 2 { + t.Fatalf("reset count = %d after one renewal, want 2", left.ResetCount) + } +} + +// Catching up several missed periods spends one allowance per period: a client +// that was away for three cycles must not receive three of them for free. +func TestAutoRenewClients_CatchUpSpendsOneAllowancePerPeriod(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + // Three whole 30-day periods behind. + past := time.Now().Add(-95 * 24 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "away@x", ID: "33333333-3333-3333-3333-333333333333", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past}, + } + ib := mkInbound(t, 30102, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "away@x", Enable: false, Reset: 30, ResetMax: 2, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "away@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ResetCount != 2 { + t.Fatalf("reset count = %d, want the 2 the cap allowed", row.ResetCount) + } + // Two periods granted, three needed: the client stays expired rather than + // silently receiving the third. + want := past + 2*30*86400000 + if row.ExpiryTime != want { + t.Fatalf("expiry = %d, want %d: exactly the periods the cap paid for", row.ExpiryTime, want) + } + if row.ExpiryTime > time.Now().UnixMilli() { + t.Fatal("the capped catch-up handed out a future expiry it had not paid for") + } +} + +// No cap set is the existing behaviour: renew for as long as the client keeps +// expiring. +func TestAutoRenewClients_NoCapRenewsAsBefore(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + past := time.Now().Add(-48 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "forever@x", ID: "44444444-4444-4444-4444-444444444444", Enable: false, Reset: 30, ExpiryTime: past}, + } + ib := mkInbound(t, 30103, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "forever@x", Enable: false, Reset: 30, ResetCount: 99, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, count, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } else if count != 1 { + t.Fatalf("renewed count = %d, want 1: a client without a cap keeps renewing", count) + } +} + +// The cap has to survive the clients table, not just the settings JSON: an +// ordinary edit rebuilds the client from the record and writes it back (#5804). +func TestClientEditKeepsTheRenewalCap(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + clients := []model.Client{ + {Email: "cap@x", ID: "44444444-4444-4444-4444-444444444444", Enable: true, Reset: 30, ResetMax: 3, ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli()}, + } + ib := mkInbound(t, 30104, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + mkTraffic(t, ib.Id, "cap@x", 10, 20, 0, 0, true) + + rec, err := svc.clientService.GetRecordByEmail(nil, "cap@x") + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + if rec.ResetMax != 3 { + t.Fatalf("clients.reset_max = %d, want the 3 the client was created with", rec.ResetMax) + } + + // What the edit dialog does: hydrate the record, change something else, save. + edited := rec.ToClient() + edited.Comment = "renamed" + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + + var stored model.Inbound + if err := db.Where("id = ?", ib.Id).First(&stored).Error; err != nil { + t.Fatal(err) + } + var settings struct { + Clients []model.Client `json:"clients"` + } + if err := json.Unmarshal([]byte(stored.Settings), &settings); err != nil { + t.Fatalf("parse inbound settings: %v", err) + } + if len(settings.Clients) != 1 { + t.Fatalf("inbound holds %d clients, want 1", len(settings.Clients)) + } + if settings.Clients[0].ResetMax != 3 { + t.Fatalf("inbound settings resetMax = %d after an unrelated edit, want 3: the cap was silently lifted", settings.Clients[0].ResetMax) + } + + rec, err = svc.clientService.GetRecordByEmail(nil, "cap@x") + if err != nil { + t.Fatalf("GetRecordByEmail after edit: %v", err) + } + if rec.ResetMax != 3 { + t.Fatalf("clients.reset_max = %d after an unrelated edit, want 3", rec.ResetMax) + } +} + +// A cap that runs out mid-catch-up leaves the client expired, so the renewal +// side effects must not fire: disableInvalidClients would undo them at once. +func TestAutoRenewClients_TruncatedCatchUpLeavesTheClientDisabled(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + db := database.GetDB() + + // Five periods behind with one allowance left: one 30-day step cannot reach + // the present, so the client stays expired. + past := time.Now().Add(-150 * 24 * time.Hour).UnixMilli() + clients := []model.Client{ + {Email: "short@x", ID: "55555555-5555-5555-5555-555555555555", Enable: false, Reset: 30, ResetMax: 3, ExpiryTime: past}, + } + ib := mkInbound(t, 30105, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + if err := db.Create(&xray.ClientTraffic{ + InboundId: ib.Id, Email: "short@x", Enable: false, Reset: 30, ResetMax: 3, ResetCount: 2, + Up: 111, Down: 222, ExpiryTime: past, + }).Error; err != nil { + t.Fatalf("seed client_traffics: %v", err) + } + + if _, _, err := svc.autoRenewClients(db, newTrafficMutationBatch()); err != nil { + t.Fatalf("autoRenewClients: %v", err) + } + + var row xray.ClientTraffic + if err := db.Where("email = ?", "short@x").First(&row).Error; err != nil { + t.Fatal(err) + } + if row.ExpiryTime >= time.Now().UnixMilli() { + t.Fatalf("expiry %d reached the present: the cap did not truncate the catch-up", row.ExpiryTime) + } + if row.Enable { + t.Fatal("a client still expired after a truncated catch-up was enabled: xray gains a user only to lose it again") + } + if row.Up != 111 || row.Down != 222 { + t.Fatalf("counters zeroed for periods the client can never use: up=%d down=%d", row.Up, row.Down) + } +} + +// The cap is useless if it can only be chosen once. The test above passes even +// without the record write, because nothing overwrites the value it checks. +func TestClientEditChangesTheRenewalCap(t *testing.T) { + setupBulkDB(t) + svc := &InboundService{} + + clients := []model.Client{ + { + Email: "chg@x", ID: "77777777-7777-7777-7777-777777777777", Enable: true, Reset: 30, ResetMax: 3, + ExpiryTime: time.Now().Add(24 * time.Hour).UnixMilli(), + }, + } + ib := mkInbound(t, 30106, model.VLESS, clientsSettings(t, clients)) + if err := svc.clientService.SyncInbound(nil, ib.Id, clients); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + mkTraffic(t, ib.Id, "chg@x", 0, 0, 0, 0, true) + + rec, err := svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail: %v", err) + } + + // The customer buys another block of periods, which is the whole point of + // the field being editable. + edited := rec.ToClient() + edited.ResetMax = 6 + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update: %v", err) + } + + rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail after edit: %v", err) + } + if rec.ResetMax != 6 { + t.Fatalf("clients.reset_max = %d after the operator raised the cap to 6", rec.ResetMax) + } + + // Lifting the cap entirely has to work too. + edited = rec.ToClient() + edited.ResetMax = 0 + if _, err := svc.clientService.Update(svc, rec.Id, *edited, rec.LimitHwid); err != nil { + t.Fatalf("Update to uncapped: %v", err) + } + rec, err = svc.clientService.GetRecordByEmail(nil, "chg@x") + if err != nil { + t.Fatalf("GetRecordByEmail after lifting the cap: %v", err) + } + if rec.ResetMax != 0 { + t.Fatalf("clients.reset_max = %d after the operator lifted the cap", rec.ResetMax) + } +} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index 339329b4d..cbea2e647 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -334,6 +334,9 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut // local inbounds. The email-based join through client_inbounds is authoritative. err = tx.Model(xray.ClientTraffic{}). Where("reset > 0 and expiry_time > 0 and expiry_time <= ?", now). + // A prepaid plan stops itself: once as many renewals have fired as the + // operator allowed, the client is left to expire like any other. + Where("reset_max <= 0 or reset_count < reset_max"). Where("email IN (?)", tx.Table("client_inbounds ci"). Select("c.email"). Joins("JOIN clients c ON c.id = ci.client_id"). @@ -411,12 +414,29 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB, mutationBatch *trafficMut if !ok { continue } + // One allowance per period, not per tick: a client away for three + // cycles must not catch up three of them against a prepaid cap. newExpiryTime := traffic.ExpiryTime + renewals := 0 for newExpiryTime < now { + if traffic.ResetMax > 0 && traffic.ResetCount+renewals >= traffic.ResetMax { + break + } newExpiryTime += (int64(traffic.Reset) * 86400000) + renewals++ + } + if renewals == 0 { + continue } c["expiryTime"] = newExpiryTime traffic.ExpiryTime = newExpiryTime + traffic.ResetCount += renewals + if newExpiryTime <= now { + // Cap ran out mid-catch-up and the client is still expired: enabling it + // for disableInvalidClients to undo adds and removes an xray user for nothing. + clients[client_index] = any(c) + continue + } traffic.Down = 0 traffic.Up = 0 if !traffic.Enable { @@ -508,10 +528,11 @@ func (s *InboundService) AddClientStat(tx *gorm.DB, inboundId int, client *model ExpiryTime: client.ExpiryTime, Enable: client.Enable, Reset: client.Reset, + ResetMax: client.ResetMax, } return tx.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "email"}}, - DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset"}), + DoUpdates: clause.AssignmentColumns([]string{"inbound_id", "total", "expiry_time", "enable", "reset", "reset_max"}), }).Create(&clientTraffic).Error } @@ -524,6 +545,7 @@ func (s *InboundService) UpdateClientStat(tx *gorm.DB, email string, client *mod "total": client.TotalGB, "expiry_time": client.ExpiryTime, "reset": client.Reset, + "reset_max": client.ResetMax, }) err := result.Error return err diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index dc9b22cc7..b4f1e815c 100644 --- a/internal/web/translation/ar-EG.json +++ b/internal/web/translation/ar-EG.json @@ -869,7 +869,10 @@ "delOrphans": "تم حذف {count} عميل غير مرتبط", "imported": "تم استيراد {count} عميل", "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم" - } + }, + "renewMax": "الحد الأقصى للتجديدات", + "renewMaxDesc": "عدد المرات التي يمكن أن يعمل فيها التجديد التلقائي قبل ترك العميل ينتهي. القيمة 0 تعني بلا حد. تعويض عدة فترات فائتة يستهلك تجديدًا واحدًا لكل فترة.", + "renewsUsed": "التجديدات المستخدمة" }, "groups": { "name": "الاسم", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index d57e781ef..70c81c06b 100644 --- a/internal/web/translation/en-US.json +++ b/internal/web/translation/en-US.json @@ -869,7 +869,10 @@ "delOrphans": "{count} unattached clients deleted", "imported": "{count} clients imported", "importedMixed": "{ok} imported, {failed} skipped" - } + }, + "renewMax": "Max renewals", + "renewMaxDesc": "How many times auto-renew may fire before the client is left to expire. 0 means no limit. Catching up several missed periods spends one renewal per period.", + "renewsUsed": "Renewals used" }, "groups": { "name": "Name", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 002c1de6b..503a74abd 100644 --- a/internal/web/translation/es-ES.json +++ b/internal/web/translation/es-ES.json @@ -869,7 +869,10 @@ "delOrphans": "{count} clientes sin entrante eliminados", "imported": "{count} clientes importados", "importedMixed": "{ok} importados, {failed} omitidos" - } + }, + "renewMax": "Renovaciones máximas", + "renewMaxDesc": "Cuántas veces puede activarse la renovación automática antes de dejar que el cliente caduque. 0 significa sin límite. Recuperar varios periodos perdidos consume una renovación por periodo.", + "renewsUsed": "Renovaciones usadas" }, "groups": { "name": "Nombre", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index ef0e97fd5..c003cb1ce 100644 --- a/internal/web/translation/fa-IR.json +++ b/internal/web/translation/fa-IR.json @@ -869,7 +869,10 @@ "delOrphans": "{count} کلاینت بدون اینباند حذف شد", "imported": "{count} کلاینت وارد شد", "importedMixed": "{ok} وارد شد، {failed} رد شد" - } + }, + "renewMax": "حداکثر تعداد تمدید", + "renewMaxDesc": "تمدید خودکار حداکثر چند بار اجرا شود پیش از آنکه کلاینت منقضی بماند. مقدار ۰ یعنی بدون محدودیت. جبران چند دورهٔ ازدست‌رفته، برای هر دوره یک تمدید مصرف می‌کند.", + "renewsUsed": "تمدیدهای استفاده‌شده" }, "groups": { "name": "نام", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index 79978d648..3aad74345 100644 --- a/internal/web/translation/id-ID.json +++ b/internal/web/translation/id-ID.json @@ -869,7 +869,10 @@ "delOrphans": "{count} klien tanpa inbound dihapus", "imported": "{count} klien diimpor", "importedMixed": "{ok} diimpor, {failed} dilewati" - } + }, + "renewMax": "Maksimum perpanjangan", + "renewMaxDesc": "Berapa kali perpanjangan otomatis boleh berjalan sebelum klien dibiarkan kedaluwarsa. 0 berarti tanpa batas. Mengejar beberapa periode yang terlewat menghabiskan satu perpanjangan per periode.", + "renewsUsed": "Perpanjangan terpakai" }, "groups": { "name": "Nama", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index 64db9fdec..c308f6f32 100644 --- a/internal/web/translation/ja-JP.json +++ b/internal/web/translation/ja-JP.json @@ -869,7 +869,10 @@ "delOrphans": "未アタッチの {count} 件のクライアントを削除しました", "imported": "{count} 件のクライアントをインポートしました", "importedMixed": "{ok} 件インポート、{failed} 件スキップ" - } + }, + "renewMax": "最大更新回数", + "renewMaxDesc": "自動更新が実行される最大回数です。これを超えるとクライアントはそのまま失効します。0 は無制限。複数の未処理期間をまとめて処理する場合、1 期間につき 1 回消費します。", + "renewsUsed": "使用済み更新回数" }, "groups": { "name": "名前", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 51aed9bd2..0f1b5bb3c 100644 --- a/internal/web/translation/pt-BR.json +++ b/internal/web/translation/pt-BR.json @@ -869,7 +869,10 @@ "delOrphans": "{count} clientes sem inbound excluídos", "imported": "{count} clientes importados", "importedMixed": "{ok} importados, {failed} ignorados" - } + }, + "renewMax": "Renovações máximas", + "renewMaxDesc": "Quantas vezes a renovação automática pode ocorrer antes de o cliente ser deixado a expirar. 0 significa sem limite. Recuperar vários períodos perdidos consome uma renovação por período.", + "renewsUsed": "Renovações usadas" }, "groups": { "name": "Nome", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index 27ead6755..b51f0eb0a 100644 --- a/internal/web/translation/ru-RU.json +++ b/internal/web/translation/ru-RU.json @@ -869,7 +869,10 @@ "delOrphans": "Удалено клиентов без входящего: {count}", "imported": "Импортировано клиентов: {count}", "importedMixed": "Импортировано: {ok}, пропущено: {failed}" - } + }, + "renewMax": "Лимит продлений", + "renewMaxDesc": "Сколько раз автопродление может сработать, прежде чем клиент будет оставлен истекать. 0 — без ограничения. Догон нескольких пропущенных периодов расходует по одному продлению на период.", + "renewsUsed": "Продлений израсходовано" }, "groups": { "name": "Имя", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 1d08a421c..f5fd4428d 100644 --- a/internal/web/translation/tr-TR.json +++ b/internal/web/translation/tr-TR.json @@ -869,7 +869,10 @@ "delOrphans": "{count} bağsız kullanıcı silindi", "imported": "{count} kullanıcı içe aktarıldı", "importedMixed": "{ok} içe aktarıldı, {failed} atlandı" - } + }, + "renewMax": "En fazla yenileme", + "renewMaxDesc": "İstemcinin süresi dolmaya bırakılmadan önce otomatik yenilemenin kaç kez çalışabileceği. 0 sınırsız demektir. Kaçırılan birden fazla dönemi telafi etmek, dönem başına bir yenileme harcar.", + "renewsUsed": "Kullanılan yenileme" }, "groups": { "name": "İsim", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index 15d663033..6b661baf5 100644 --- a/internal/web/translation/uk-UA.json +++ b/internal/web/translation/uk-UA.json @@ -869,7 +869,10 @@ "delOrphans": "Видалено клієнтів без вхідного: {count}", "imported": "Імпортовано клієнтів: {count}", "importedMixed": "Імпортовано: {ok}, пропущено: {failed}" - } + }, + "renewMax": "Ліміт подовжень", + "renewMaxDesc": "Скільки разів автоподовження може спрацювати, перш ніж клієнта буде залишено спливати. 0 — без обмеження. Надолуження кількох пропущених періодів витрачає по одному подовженню на період.", + "renewsUsed": "Подовжень витрачено" }, "groups": { "name": "Назва", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index 2e1a8c211..644903726 100644 --- a/internal/web/translation/vi-VN.json +++ b/internal/web/translation/vi-VN.json @@ -869,7 +869,10 @@ "delOrphans": "Đã xóa {count} khách hàng không gắn inbound", "imported": "Đã nhập {count} khách hàng", "importedMixed": "Đã nhập {ok}, bỏ qua {failed}" - } + }, + "renewMax": "Số lần gia hạn tối đa", + "renewMaxDesc": "Gia hạn tự động được phép chạy bao nhiêu lần trước khi để khách hàng hết hạn. 0 nghĩa là không giới hạn. Bù lại nhiều kỳ đã bỏ lỡ sẽ tiêu tốn một lần gia hạn cho mỗi kỳ.", + "renewsUsed": "Số lần gia hạn đã dùng" }, "groups": { "name": "Tên", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 659724558..59bb3244c 100644 --- a/internal/web/translation/zh-CN.json +++ b/internal/web/translation/zh-CN.json @@ -869,7 +869,10 @@ "delOrphans": "已删除 {count} 个未关联的客户端", "imported": "已导入 {count} 个客户端", "importedMixed": "已导入 {ok} 个,跳过 {failed} 个" - } + }, + "renewMax": "最大续期次数", + "renewMaxDesc": "自动续期最多可触发的次数,达到后客户端将自然到期。填 0 表示不限制。补齐多个错过的周期时,每个周期消耗一次续期。", + "renewsUsed": "已用续期次数" }, "groups": { "name": "名称", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index 8c3a2a430..b08316d2a 100644 --- a/internal/web/translation/zh-TW.json +++ b/internal/web/translation/zh-TW.json @@ -869,7 +869,10 @@ "delOrphans": "已刪除 {count} 個未關聯的客戶端", "imported": "已匯入 {count} 個客戶端", "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個" - } + }, + "renewMax": "最大續期次數", + "renewMaxDesc": "自動續期最多可觸發的次數,達到後用戶端將自然到期。填 0 表示不限制。補齊多個錯過的週期時,每個週期消耗一次續期。", + "renewsUsed": "已用續期次數" }, "groups": { "name": "名稱", diff --git a/internal/xray/client_traffic.go b/internal/xray/client_traffic.go index 35016ec08..655567586 100644 --- a/internal/xray/client_traffic.go +++ b/internal/xray/client_traffic.go @@ -3,17 +3,21 @@ package xray // ClientTraffic represents traffic statistics and limits for a specific client. // It tracks upload/download usage, expiry times, and online status for inbound clients. type ClientTraffic struct { - Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"` - InboundId int `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"` - Enable bool `json:"enable" form:"enable" example:"true"` - Email string `json:"email" form:"email" gorm:"unique" example:"user1"` - UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"` - SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"` - Up int64 `json:"up" form:"up" example:"1048576"` - Down int64 `json:"down" form:"down" example:"2097152"` - ExpiryTime int64 `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"` - Total int64 `json:"total" form:"total" example:"10737418240"` - Reset int `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"` - LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"` - LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"` + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"` + InboundId int `json:"inboundId" form:"inboundId" gorm:"index:idx_client_traffics_inbound" example:"1"` + Enable bool `json:"enable" form:"enable" example:"true"` + Email string `json:"email" form:"email" gorm:"unique" example:"user1"` + UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"` + SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"` + Up int64 `json:"up" form:"up" example:"1048576"` + Down int64 `json:"down" form:"down" example:"2097152"` + ExpiryTime int64 `json:"expiryTime" form:"expiryTime" gorm:"index:idx_client_traffics_renew,priority:1" example:"1735689600000"` + Total int64 `json:"total" form:"total" example:"10737418240"` + Reset int `json:"reset" form:"reset" gorm:"default:0;index:idx_client_traffics_renew,priority:2" example:"0"` + // ResetMax caps how many times auto-renew may fire; 0 means no cap. + ResetMax int `json:"resetMax" form:"resetMax" gorm:"default:0" example:"0"` + // ResetCount is how many have fired, so a prepaid plan stops on its own. + ResetCount int `json:"resetCount" form:"resetCount" gorm:"default:0" example:"0"` + LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"` + LastSubFetch int64 `json:"lastSubFetch" form:"lastSubFetch" gorm:"default:0" example:"1735680000000"` }