mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 03:05:02 +03:00
fix(clients): re-enable only quota-disabled clients on a traffic reset
Every traffic reset re-enabled its clients unconditionally, in the client record and in the traffic row a master treats as the client's state. A master forwards an inbound's scheduled reset and "reset all" to its nodes through the per-client reset calls, so a client disabled by hand on a node inbound came back on at the next scheduled reset. Each reset now decides, before it zeroes the counters, which of its clients the quota switched off, counting cross-panel usage, and re-enables only those; a client disabled below its quota keeps its record and traffic row off. The reset API docs say so.
This commit is contained in:
@@ -182,10 +182,11 @@ _openapi:
|
|||||||
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
|
url: '#mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Zero up/down counters for many clients in one call. Loops the
|
title: Zero up/down counters for many clients in one call. Loops the
|
||||||
single-reset path so each client is re-enabled across its attached
|
single-reset path so each client its quota had disabled is re-enabled
|
||||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||||
successfully reset clients.
|
disabled by hand stays disabled. Returns the count of successfully reset
|
||||||
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients'
|
clients.
|
||||||
|
url: '#zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: List all client groups with their member counts. Merges persisted groups
|
title: List all client groups with their member counts. Merges persisted groups
|
||||||
(rows in client_groups, including empty placeholders) with the distinct
|
(rows in client_groups, including empty placeholders) with the distinct
|
||||||
@@ -223,10 +224,11 @@ _openapi:
|
|||||||
client_groups row if the group exists only as a derived label.
|
client_groups row if the group exists only as a derived label.
|
||||||
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
|
url: '#reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Zero out a single client’s up/down counters. Re-enables the client across
|
title: Zero out a single client’s up/down counters. Re-enables a client its
|
||||||
every attached inbound and pushes the change to Xray (or the remote
|
quota had disabled across every attached inbound and pushes the change
|
||||||
node) so depleted users can connect again immediately.
|
to Xray (or the remote node) so depleted users can connect again
|
||||||
url: '#zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately'
|
immediately; a client disabled by hand stays disabled.
|
||||||
|
url: '#zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled'
|
||||||
- depth: 2
|
- depth: 2
|
||||||
title: Manually adjust a client’s upload + download counters. Useful for
|
title: Manually adjust a client’s upload + download counters. Useful for
|
||||||
migrations from external accounting systems.
|
migrations from external accounting systems.
|
||||||
@@ -457,10 +459,11 @@ _openapi:
|
|||||||
any target inbound was running."
|
any target inbound was running."
|
||||||
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
|
id: mirror-of-bulkattach-detach-many-existing-clients-from-many-inbounds-in-one-call-for-each-email-intersects-the-clients-current-inbounds-with-the-requested-set-and-detaches-from-those-only-email-inbound-pairs-where-the-client-is-not-currently-attached-are-silently-no-ops-emails-not-attached-to-any-of-the-requested-inbounds-are-reported-under-skipped-client-records-are-kept-even-if-they-become-orphaned--use-bulkdel-for-full-removal-returns-per-email-detachedskippederrors-lists-and-triggers-a-single-xray-restart-if-any-target-inbound-was-running
|
||||||
- content: Zero up/down counters for many clients in one call. Loops the
|
- content: Zero up/down counters for many clients in one call. Loops the
|
||||||
single-reset path so each client is re-enabled across its attached
|
single-reset path so each client its quota had disabled is re-enabled
|
||||||
inbounds and pushed to Xray/remote nodes. Returns the count of
|
across its attached inbounds and pushed to Xray/remote nodes; a client
|
||||||
successfully reset clients.
|
disabled by hand stays disabled. Returns the count of successfully
|
||||||
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-returns-the-count-of-successfully-reset-clients
|
reset clients.
|
||||||
|
id: zero-updown-counters-for-many-clients-in-one-call-loops-the-single-reset-path-so-each-client-its-quota-had-disabled-is-re-enabled-across-its-attached-inbounds-and-pushed-to-xrayremote-nodes-a-client-disabled-by-hand-stays-disabled-returns-the-count-of-successfully-reset-clients
|
||||||
- content: List all client groups with their member counts. Merges persisted
|
- content: List all client groups with their member counts. Merges persisted
|
||||||
groups (rows in client_groups, including empty placeholders) with the
|
groups (rows in client_groups, including empty placeholders) with the
|
||||||
distinct group_name values currently set on clients. Sorted
|
distinct group_name values currently set on clients. Sorted
|
||||||
@@ -492,10 +495,11 @@ _openapi:
|
|||||||
Creates the client_groups row if the group exists only as a derived
|
Creates the client_groups row if the group exists only as a derived
|
||||||
label.
|
label.
|
||||||
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
|
id: reset-only-the-group-level-traffic-counter-shown-on-the-groups-page-snapshots-the-current-updown-sum-of-the-groups-members-as-a-baseline-so-the-group-total-reads-zero-while-leaving-each-clients-own-counters-and-their-quotas-untouched-no-xray-restart-is-triggered-creates-the-client_groups-row-if-the-group-exists-only-as-a-derived-label
|
||||||
- content: Zero out a single client’s up/down counters. Re-enables the client
|
- content: Zero out a single client’s up/down counters. Re-enables a client its
|
||||||
across every attached inbound and pushes the change to Xray (or the
|
quota had disabled across every attached inbound and pushes the change
|
||||||
remote node) so depleted users can connect again immediately.
|
to Xray (or the remote node) so depleted users can connect again
|
||||||
id: zero-out-a-single-clients-updown-counters-re-enables-the-client-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately
|
immediately; a client disabled by hand stays disabled.
|
||||||
|
id: zero-out-a-single-clients-updown-counters-re-enables-a-client-its-quota-had-disabled-across-every-attached-inbound-and-pushes-the-change-to-xray-or-the-remote-node-so-depleted-users-can-connect-again-immediately-a-client-disabled-by-hand-stays-disabled
|
||||||
- content: Manually adjust a client’s upload + download counters. Useful for
|
- content: Manually adjust a client’s upload + download counters. Useful for
|
||||||
migrations from external accounting systems.
|
migrations from external accounting systems.
|
||||||
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
|
id: manually-adjust-a-clients-upload--download-counters-useful-for-migrations-from-external-accounting-systems
|
||||||
|
|||||||
@@ -10163,7 +10163,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -10504,7 +10504,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10163,7 +10163,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.",
|
"summary": "Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.",
|
||||||
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
"operationId": "post_panel_api_clients_bulkResetTraffic",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -10504,7 +10504,7 @@
|
|||||||
"tags": [
|
"tags": [
|
||||||
"Clients"
|
"Clients"
|
||||||
],
|
],
|
||||||
"summary": "Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.",
|
"summary": "Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.",
|
||||||
"operationId": "post_panel_api_clients_resetTraffic_email",
|
"operationId": "post_panel_api_clients_resetTraffic_email",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1497,7 +1497,7 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/bulkResetTraffic',
|
path: '/panel/api/clients/bulkResetTraffic',
|
||||||
summary:
|
summary:
|
||||||
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client is re-enabled across its attached inbounds and pushed to Xray/remote nodes. Returns the count of successfully reset clients.',
|
'Zero up/down counters for many clients in one call. Loops the single-reset path so each client its quota had disabled is re-enabled across its attached inbounds and pushed to Xray/remote nodes; a client disabled by hand stays disabled. Returns the count of successfully reset clients.',
|
||||||
body: '{\n "emails": ["alice", "bob"]\n}',
|
body: '{\n "emails": ["alice", "bob"]\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "affected": 2\n }\n}',
|
||||||
},
|
},
|
||||||
@@ -1553,7 +1553,7 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/resetTraffic/:email',
|
path: '/panel/api/clients/resetTraffic/:email',
|
||||||
summary:
|
summary:
|
||||||
'Zero out a single client’s up/down counters. Re-enables the client across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately.',
|
'Zero out a single client’s up/down counters. Re-enables a client its quota had disabled across every attached inbound and pushes the change to Xray (or the remote node) so depleted users can connect again immediately; a client disabled by hand stays disabled.',
|
||||||
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
|
params: [{ name: 'email', in: 'path', type: 'string', desc: 'Client email.' }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ func TestBulkResetTrafficZeroesUsageAndReenables(t *testing.T) {
|
|||||||
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
if err := svc.SyncInbound(nil, ib.Id, source); err != nil {
|
||||||
t.Fatalf("seed linkage: %v", err)
|
t.Fatalf("seed linkage: %v", err)
|
||||||
}
|
}
|
||||||
mkTraffic(t, ib.Id, "alice@x", 10, 20, 0, 0, false)
|
mkTraffic(t, ib.Id, "alice@x", 10, 20, 30, 0, false)
|
||||||
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
|
mkTraffic(t, ib.Id, "bob@x", 5, 5, 0, 0, true)
|
||||||
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
|
mkTraffic(t, ib.Id, "carol@x", 7, 0, 0, 0, true)
|
||||||
|
|
||||||
@@ -231,3 +232,104 @@ func TestDelDepletedIgnoresPaddedUsageRow(t *testing.T) {
|
|||||||
t.Fatalf("alice was deleted through a look-alike usage row: %v", err)
|
t.Fatalf("alice was deleted through a look-alike usage row: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// trafficResets is every way to reset a client's traffic; a master forwards its
|
||||||
|
// scheduled and "reset all" resets to a node through the first two.
|
||||||
|
var trafficResets = []struct {
|
||||||
|
name string
|
||||||
|
run func(ib *model.Inbound, email string) error
|
||||||
|
}{
|
||||||
|
{"ResetTrafficByEmail", func(_ *model.Inbound, email string) error {
|
||||||
|
_, err := (&ClientService{}).ResetTrafficByEmail(&InboundService{}, email)
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"BulkResetTraffic", func(_ *model.Inbound, email string) error {
|
||||||
|
_, err := (&ClientService{}).BulkResetTraffic(&InboundService{}, []string{email})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"ResetAllClientTraffics", func(ib *model.Inbound, _ string) error {
|
||||||
|
_, err := (&ClientService{}).ResetAllClientTraffics(&InboundService{}, ib.Id)
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"ResetAllTraffics", func(*model.Inbound, string) error {
|
||||||
|
_, err := (&ClientService{}).ResetAllTraffics()
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"ResetClientTraffic", func(ib *model.Inbound, email string) error {
|
||||||
|
_, err := (&InboundService{}).ResetClientTraffic(ib.Id, email)
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{"ResetClientTrafficByEmail", func(_ *model.Inbound, email string) error {
|
||||||
|
return (&InboundService{}).ResetClientTrafficByEmail(email)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficReset_LeavesOperatorDisabledClientOff(t *testing.T) {
|
||||||
|
const email = "banned@x"
|
||||||
|
for _, total := range []int64{0, 1000} {
|
||||||
|
for _, reset := range trafficResets {
|
||||||
|
t.Run(fmt.Sprintf("%s/quota=%d", reset.name, total), func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
// Disabled with usage below quota: nothing but a human did that.
|
||||||
|
ib := seedLocalDisabledClient(t, svc, 52020, "", email, total, 0, 10, 10)
|
||||||
|
|
||||||
|
if err := reset.run(ib, email); err != nil {
|
||||||
|
t.Fatalf("%s: %v", reset.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEnableEverywhere(t, svc, &InboundService{}, ib.Id, email, false)
|
||||||
|
if tr := trafficOf(t, email); tr.Up != 0 || tr.Down != 0 {
|
||||||
|
t.Fatalf("traffic after reset up=%d down=%d, want 0/0", tr.Up, tr.Down)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTrafficReset_RestoresQuotaDisabledClient(t *testing.T) {
|
||||||
|
const email = "depleted@x"
|
||||||
|
for _, reset := range trafficResets {
|
||||||
|
t.Run(reset.name, func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
ib := seedLocalDisabledClient(t, &ClientService{}, 52021, "", email, 1000, 0, 600, 500)
|
||||||
|
|
||||||
|
if err := reset.run(ib, email); err != nil {
|
||||||
|
t.Fatalf("%s: %v", reset.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tr := trafficOf(t, email); !tr.Enable || tr.Up != 0 || tr.Down != 0 {
|
||||||
|
t.Fatalf("traffic after reset enable=%v up=%d down=%d, want true 0/0", tr.Enable, tr.Up, tr.Down)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A node cuts a client on the usage its master pushed while the node's own
|
||||||
|
// share is still below the quota; a reset must still count that as depleted.
|
||||||
|
func TestTrafficReset_RestoresClientDisabledByGlobalUsage(t *testing.T) {
|
||||||
|
const email = "shared@x"
|
||||||
|
for i, reset := range trafficResets {
|
||||||
|
t.Run(reset.name, func(t *testing.T) {
|
||||||
|
setupBulkDB(t)
|
||||||
|
svc := &ClientService{}
|
||||||
|
inboundSvc := &InboundService{}
|
||||||
|
ib := seedLocalDisabledClient(t, svc, 52022, "", email, 1000, 0, 100, 100)
|
||||||
|
if err := inboundSvc.AcceptGlobalTraffic("master-a", []*xray.ClientTraffic{{Email: email, Up: 800, Down: 700}}); err != nil {
|
||||||
|
t.Fatalf("AcceptGlobalTraffic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := reset.run(ib, email); err != nil {
|
||||||
|
t.Fatalf("%s: %v", reset.name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !trafficOf(t, email).Enable {
|
||||||
|
t.Fatal("client_traffics.enable = false: the pushed usage was not counted as depleted")
|
||||||
|
}
|
||||||
|
// Only the ClientService per-client resets restore the record and settings.
|
||||||
|
if i < 2 {
|
||||||
|
assertEnableEverywhere(t, svc, inboundSvc, ib.Id, email, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -160,6 +160,15 @@ func quotaDisabledEmails(tx *gorm.DB, within []string) ([]string, error) {
|
|||||||
if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil {
|
if err := clientResetCycles(tx).Where("c.enable = ?", false).Scan(&cycles).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Cross-panel usage depletes too: a client cut off on pushed usage was the quota's doing.
|
||||||
|
rows := make([]xray.ClientTraffic, len(cycles))
|
||||||
|
for i, c := range cycles {
|
||||||
|
rows[i] = xray.ClientTraffic{Email: c.Email, Up: c.Up, Down: c.Down, Total: c.Total}
|
||||||
|
}
|
||||||
|
overlayGlobalTrafficValues(tx, rows)
|
||||||
|
for i := range cycles {
|
||||||
|
cycles[i].Up, cycles[i].Down = rows[i].Up, rows[i].Down
|
||||||
|
}
|
||||||
var in map[string]bool
|
var in map[string]bool
|
||||||
if within != nil {
|
if within != nil {
|
||||||
in = make(map[string]bool, len(within))
|
in = make(map[string]bool, len(within))
|
||||||
|
|||||||
@@ -137,12 +137,12 @@ func depleteClientTraffic(t *testing.T, inboundId int, email string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
|
res := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).
|
||||||
Updates(map[string]any{"enable": false, "up": 1, "down": 1})
|
Updates(map[string]any{"enable": false, "up": 1, "down": 1, "total": 2})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
t.Fatalf("deplete traffic: %v", res.Error)
|
t.Fatalf("deplete traffic: %v", res.Error)
|
||||||
}
|
}
|
||||||
if res.RowsAffected == 0 {
|
if res.RowsAffected == 0 {
|
||||||
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1}).Error; err != nil {
|
if err := db.Create(&xray.ClientTraffic{InboundId: inboundId, Email: email, Enable: false, Up: 1, Down: 1, Total: 2}).Error; err != nil {
|
||||||
t.Fatalf("create depleted traffic: %v", err)
|
t.Fatalf("create depleted traffic: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
@@ -24,6 +25,11 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
// Decided before the resets below zero the counters the rule reads.
|
||||||
|
quotaDisabled, err := quotaDisabledEmails(database.GetDB(), []string{email})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if len(inboundIds) == 0 {
|
if len(inboundIds) == 0 {
|
||||||
@@ -44,9 +50,9 @@ func (s *ClientService) ResetTrafficByEmail(inboundSvc *InboundService, email st
|
|||||||
needRestart = nr
|
needRestart = nr
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable only once the counters are zero: a still-depleted client enabled
|
// Enable only a client the quota switched off, once the counters are zero: a
|
||||||
// first is switched off again by the next traffic tick.
|
// hand-disabled one stays off, and a still-depleted one would be switched off again.
|
||||||
if !rec.Enable {
|
if !rec.Enable && len(quotaDisabled) > 0 {
|
||||||
updated := rec.ToClient()
|
updated := rec.ToClient()
|
||||||
updated.Enable = true
|
updated.Enable = true
|
||||||
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
nr, uErr := s.Update(inboundSvc, rec.Id, *updated, rec.LimitHwid)
|
||||||
@@ -73,22 +79,25 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
affected := 0
|
var affected int64
|
||||||
|
var restore []string
|
||||||
var resetNodes []int
|
var resetNodes []int
|
||||||
err = submitTrafficWrite(func() error {
|
err = submitTrafficWrite(func() error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
return db.Transaction(func(tx *gorm.DB) error {
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
var err error
|
||||||
|
if restore, err = quotaDisabledEmails(tx, cleanEmails); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keepOff, err := handDisabledEmails(tx, cleanEmails)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
|
if err := adjustGroupBaselinesForRemovedTraffic(tx, cleanEmails); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, batch := range chunkStrings(cleanEmails, sqlInChunk) {
|
if affected, err = zeroClientTraffics(tx, cleanEmails, keepOff); err != nil {
|
||||||
res := tx.Model(xray.ClientTraffic{}).
|
return err
|
||||||
Where("email IN ?", batch).
|
|
||||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
|
||||||
if res.Error != nil {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
affected += int(res.RowsAffected)
|
|
||||||
}
|
}
|
||||||
if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
|
if err := clearGlobalTraffic(tx, cleanEmails...); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -110,7 +119,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
|||||||
inboundSvc.deliverNodeResetsNow(resetNodes)
|
inboundSvc.deliverNodeResetsNow(resetNodes)
|
||||||
// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
|
// After the zeroing, as in ResetTrafficByEmail: enabling a still-depleted
|
||||||
// client first lets the next traffic tick switch it off again.
|
// client first lets the next traffic tick switch it off again.
|
||||||
for _, e := range cleanEmails {
|
for _, e := range restore {
|
||||||
rec := recordsByEmail[e]
|
rec := recordsByEmail[e]
|
||||||
if rec == nil || rec.Enable {
|
if rec == nil || rec.Enable {
|
||||||
continue
|
continue
|
||||||
@@ -121,7 +130,7 @@ func (s *ClientService) BulkResetTraffic(inboundSvc *InboundService, emails []st
|
|||||||
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
|
logger.Warning("Failed to auto-enable client during bulk traffic reset:", uErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return affected, nil
|
return int(affected), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) {
|
func (s *ClientService) ResetAllClientTraffics(inboundSvc *InboundService, id int) (bool, error) {
|
||||||
@@ -180,17 +189,17 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
|
|||||||
if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil {
|
if restore, qErr = quotaDisabledEmails(tx, resetEmails); qErr != nil {
|
||||||
return qErr
|
return qErr
|
||||||
}
|
}
|
||||||
|
keepOff, qErr := handDisabledEmails(tx, resetEmails)
|
||||||
|
if qErr != nil {
|
||||||
|
return qErr
|
||||||
|
}
|
||||||
|
|
||||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
|
if err := adjustGroupBaselinesForRemovedTraffic(tx, resetEmails); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := tx.Model(xray.ClientTraffic{}).
|
if _, err := zeroClientTraffics(tx, resetEmails, keepOff); err != nil {
|
||||||
Where("email IN ?", resetEmails).
|
return err
|
||||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return result.Error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
|
if err := clearGlobalTraffic(tx, resetEmails...); err != nil {
|
||||||
@@ -213,7 +222,7 @@ func (s *ClientService) resetAllClientTrafficsLocked(id int) ([]string, []string
|
|||||||
inboundWhereText += " = ?"
|
inboundWhereText += " = ?"
|
||||||
}
|
}
|
||||||
|
|
||||||
result = tx.Model(model.Inbound{}).
|
result := tx.Model(model.Inbound{}).
|
||||||
Where(inboundWhereText, id).
|
Where(inboundWhereText, id).
|
||||||
Update("last_traffic_reset_time", now)
|
Update("last_traffic_reset_time", now)
|
||||||
|
|
||||||
@@ -234,13 +243,13 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
|
|||||||
if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil {
|
if restore, qErr = quotaDisabledEmails(tx, nil); qErr != nil {
|
||||||
return qErr
|
return qErr
|
||||||
}
|
}
|
||||||
res := tx.Model(&xray.ClientTraffic{}).
|
keepOff, qErr := handDisabledEmails(tx, nil)
|
||||||
Where("1 = 1").
|
if qErr != nil {
|
||||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0})
|
return qErr
|
||||||
if res.Error != nil {
|
}
|
||||||
return res.Error
|
if affected, qErr = zeroClientTraffics(tx, nil, keepOff); qErr != nil {
|
||||||
|
return qErr
|
||||||
}
|
}
|
||||||
affected = res.RowsAffected
|
|
||||||
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
|
if err := tx.Where("1 = 1").Delete(&model.ClientGlobalTraffic{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -266,3 +275,59 @@ func (s *ClientService) ResetAllTraffics() (bool, error) {
|
|||||||
}
|
}
|
||||||
return affected > 0 || needRestart, nil
|
return affected > 0 || needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handDisabledEmails lists, among emails (every client when nil), the clients switched off by
|
||||||
|
// hand: a disabled record whose quota is not spent. Decide it before a reset zeroes the counters.
|
||||||
|
func handDisabledEmails(tx *gorm.DB, emails []string) ([]string, error) {
|
||||||
|
var disabled []string
|
||||||
|
q := func() *gorm.DB { return tx.Model(&model.ClientRecord{}).Where("enable = ?", false) }
|
||||||
|
if emails == nil {
|
||||||
|
if err := q().Pluck("email", &disabled).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||||
|
var page []string
|
||||||
|
if err := q().Where("email IN ?", batch).Pluck("email", &page).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
disabled = append(disabled, page...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(disabled) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
quota, err := quotaDisabledEmails(tx, disabled)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return slices.DeleteFunc(disabled, func(e string) bool { return slices.Contains(quota, e) }), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zeroClientTraffics zeroes the counters of emails (every row when nil) and lifts their traffic
|
||||||
|
// gate, except for keepOff: a master pushes that flag to its nodes as the client's state.
|
||||||
|
func zeroClientTraffics(tx *gorm.DB, emails, keepOff []string) (int64, error) {
|
||||||
|
var affected int64
|
||||||
|
reset := map[string]any{"enable": true, "up": 0, "down": 0}
|
||||||
|
if emails == nil {
|
||||||
|
res := tx.Model(&xray.ClientTraffic{}).Where("1 = 1").Updates(reset)
|
||||||
|
if res.Error != nil {
|
||||||
|
return 0, res.Error
|
||||||
|
}
|
||||||
|
affected = res.RowsAffected
|
||||||
|
} else {
|
||||||
|
for _, batch := range chunkStrings(emails, sqlInChunk) {
|
||||||
|
res := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Updates(reset)
|
||||||
|
if res.Error != nil {
|
||||||
|
return 0, res.Error
|
||||||
|
}
|
||||||
|
affected += res.RowsAffected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, batch := range chunkStrings(keepOff, sqlInChunk) {
|
||||||
|
if err := tx.Model(&xray.ClientTraffic{}).Where("email IN ?", batch).Update("enable", false).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return affected, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -103,12 +103,12 @@ func TestResetTrafficByEmail_LeavesRecordEnableTrue(t *testing.T) {
|
|||||||
inboundSvc := &InboundService{}
|
inboundSvc := &InboundService{}
|
||||||
|
|
||||||
email := "r-attached@x"
|
email := "r-attached@x"
|
||||||
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false}
|
c := model.Client{Email: email, ID: "11111111-1111-1111-1111-111111111111", SubID: email, Enable: false, TotalGB: 30}
|
||||||
ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c}))
|
ib := mkInbound(t, 53003, model.VLESS, clientsSettings(t, []model.Client{c}))
|
||||||
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
if err := svc.SyncInbound(nil, ib.Id, []model.Client{c}); err != nil {
|
||||||
t.Fatalf("seed linkage: %v", err)
|
t.Fatalf("seed linkage: %v", err)
|
||||||
}
|
}
|
||||||
mkTraffic(t, ib.Id, email, 10, 20, 0, 0, false)
|
mkTraffic(t, ib.Id, email, 10, 20, 30, 0, false)
|
||||||
|
|
||||||
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
||||||
t.Fatalf("ResetTrafficByEmail: %v", err)
|
t.Fatalf("ResetTrafficByEmail: %v", err)
|
||||||
@@ -142,6 +142,7 @@ func TestResetTrafficByEmail_NoInbound_LeavesRecordEnableTrue(t *testing.T) {
|
|||||||
t.Fatalf("create record: %v", err)
|
t.Fatalf("create record: %v", err)
|
||||||
}
|
}
|
||||||
forceRecordDisabled(t, svc, email)
|
forceRecordDisabled(t, svc, email)
|
||||||
|
mkTraffic(t, 0, email, 10, 20, 30, 0, false)
|
||||||
|
|
||||||
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
if _, err := svc.ResetTrafficByEmail(inboundSvc, email); err != nil {
|
||||||
t.Fatalf("ResetTrafficByEmail: %v", err)
|
t.Fatalf("ResetTrafficByEmail: %v", err)
|
||||||
|
|||||||
@@ -680,12 +680,14 @@ func (s *InboundService) ResetClientTrafficByEmail(clientEmail string) error {
|
|||||||
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
if err := adjustGroupBaselinesForRemovedTraffic(tx, []string{clientEmail}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
|
keepOff, err := handDisabledEmails(tx, []string{clientEmail})
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(xray.ClientTraffic{}).
|
if _, err := zeroClientTraffics(tx, []string{clientEmail}, keepOff); err != nil {
|
||||||
Where("email = ?", clientEmail).
|
return err
|
||||||
Updates(map[string]any{"enable": true, "up": 0, "down": 0}).Error; err != nil {
|
}
|
||||||
|
if err := clearGlobalTraffic(tx, clientEmail); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
|
return tx.Where("email = ?", clientEmail).Delete(&model.NodeClientTraffic{}).Error
|
||||||
@@ -723,8 +725,14 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, err
|
return false, nil, err
|
||||||
}
|
}
|
||||||
|
// A reset lifts the quota gate only: a client switched off by hand stays off.
|
||||||
|
keepOff, err := handDisabledEmails(database.GetDB(), []string{clientEmail})
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, err
|
||||||
|
}
|
||||||
|
handDisabled := len(keepOff) > 0
|
||||||
|
|
||||||
if !traffic.Enable {
|
if !traffic.Enable && !handDisabled {
|
||||||
inbound, err := s.GetInbound(id)
|
inbound, err := s.GetInbound(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, nil, err
|
return false, nil, err
|
||||||
@@ -766,7 +774,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
|||||||
|
|
||||||
traffic.Up = 0
|
traffic.Up = 0
|
||||||
traffic.Down = 0
|
traffic.Down = 0
|
||||||
traffic.Enable = true
|
traffic.Enable = !handDisabled
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
|||||||
Reference in New Issue
Block a user