fix(traffic): keep client accounting independent of outbound counters

The traffic poll read every Xray counter in one QueryStats reply under
gRPC's default 4 MiB receive limit; past it the whole poll failed and
no client was counted, so quotas stopped being enforced. Outbound
counters carry tags derived from third-party link remarks, so one long
remark was enough.

Poll the user, inbound and outbound families separately with a 64 MiB
limit, keep counting the families that answer, and cap a subscription
link's slug at 64 runes so the tag stays small to begin with.
This commit is contained in:
MHSanaei
2026-10-31 08:46:13 +00:00
parent 61ee202750
commit 3cdb7e2b49
4 changed files with 123 additions and 32 deletions
+53 -6
View File
@@ -3,10 +3,13 @@ package xray
import (
"context"
"net"
"strings"
"testing"
statsService "github.com/xtls/xray-core/app/stats/command"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// fakeStatsServer serves a scripted sequence of QueryStats responses, one per
@@ -14,16 +17,28 @@ import (
type fakeStatsServer struct {
statsService.UnimplementedStatsServiceServer
rounds [][]*statsService.Stat
calls int
calls map[string]int
fail map[string]error
}
func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) {
round := f.calls
f.calls++
// QueryStats answers each pattern from its own round counter and, like the core, matches
// the pattern anywhere in the name.
func (f *fakeStatsServer) QueryStats(_ context.Context, req *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) {
if err := f.fail[req.Pattern]; err != nil {
return nil, err
}
round := f.calls[req.Pattern]
f.calls[req.Pattern]++
if round >= len(f.rounds) {
round = len(f.rounds) - 1
}
return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil
var matched []*statsService.Stat
for _, s := range f.rounds[round] {
if strings.Contains(s.Name, req.Pattern) {
matched = append(matched, s)
}
}
return &statsService.QueryStatsResponse{Stat: matched}, nil
}
func stat(name string, value int64) *statsService.Stat {
@@ -34,12 +49,18 @@ func stat(name string, value int64) *statsService.Stat {
// to it.
func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI {
t.Helper()
return startFakeStatsServer(t, &fakeStatsServer{rounds: rounds})
}
func startFakeStatsServer(t *testing.T, fake *fakeStatsServer) *XrayAPI {
t.Helper()
fake.calls = map[string]int{}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
srv := grpc.NewServer()
statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds})
statsService.RegisterStatsServiceServer(srv, fake)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
@@ -190,3 +211,29 @@ func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) {
t.Fatal("baselines for stats that no longer exist were not pruned")
}
}
// Outbound counters are named after subscription tags a third party chooses; however
// large that family's reply grows, the clients' counters must still be counted.
func TestGetTrafficCountsClientsPastAnOversizedOutboundFamily(t *testing.T) {
huge := stat("outbound>>>"+strings.Repeat("x", 5<<20)+">>>traffic>>>uplink", 1)
rounds := [][]*statsService.Stat{
{stat("user>>>alice>>>traffic>>>uplink", 100), huge},
{stat("user>>>alice>>>traffic>>>uplink", 400), huge},
}
for name, fake := range map[string]*fakeStatsServer{
"reply over gRPC's default limit": {rounds: rounds},
"family that fails": {rounds: rounds, fail: map[string]error{"outbound>>>": status.Error(codes.ResourceExhausted, "too large")}},
} {
api := startFakeStatsServer(t, fake)
if _, _, err := api.GetTraffic(); err != nil {
t.Fatalf("%s: GetTraffic (baseline): %v", name, err)
}
_, clients, err := api.GetTraffic()
if err != nil {
t.Fatalf("%s: GetTraffic: %v", name, err)
}
if alice := clientTrafficByEmail(t, clients)["alice"]; alice == nil || alice.Up != 300 {
t.Fatalf("%s: alice = %+v, want 300 bytes up", name, alice)
}
}
}