B5 rehearsal fix: sing-box stats registration needs all three lists

The B5 full-stack rehearsal caught a silent accounting killer:
with the production switch set (inbound on, outbound off) the
generated v2ray_api stats block omitted the outbounds list, and
sing-box then registers NO counters at all — QueryStats answered
zero entries while real traffic flowed (A/B against the real core:
adding the list produced full user/inbound/outbound counters).

- builder: whenever the stats block is generated, emit inbounds,
  users and outbounds together; the gates now only decide whether
  the block exists
- master: enforce the inbound category gate at recording time in
  the agent traffic report (the whole-network accounting switch
  semantics: off period is never counted); onlines stay ungated
- golden gate test updated to the corrected contract
This commit is contained in:
Muse
2026-10-23 01:57:52 +00:00
parent 4a532e8c5a
commit c610ab88ea
5 changed files with 98 additions and 35 deletions
+1
View File
@@ -0,0 +1 @@
/home/hatch/workspace/3x-ui-agent/frontend/node_modules
+29
View File
@@ -0,0 +1,29 @@
package agent
import (
"context"
"os"
"testing"
"time"
)
// Throwaway live probe (B5 rehearsal): query the rehearsal core's
// v2ray_api stats service with the production client code.
func TestZZProbeLiveStats(t *testing.T) {
c := newSingboxStatsClient(func() string { return "probe" })
addr := os.Getenv("ZZ_STATS_ADDR")
if addr == "" {
addr = "127.0.0.1:10086"
}
c.SetAddr(addr)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
entries, err := c.queryStats(ctx)
if err != nil {
t.Fatalf("queryStats: %v", err)
}
t.Logf("entries: %d", len(entries))
for _, e := range entries {
t.Logf(" %s = %d", e.Name, e.Value)
}
}
+24 -9
View File
@@ -684,8 +684,21 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
jsonMsg(c, "Invalid request", err)
return
}
// ITEM6 B5 recording gate: a sing-box core registers counters
// only when every stats family is listed in its config, so the
// generated config can no longer omit a switched-off category
// (see the stats wiring in SingboxService). The whole-network
// accounting switches (settings.trafficStatsSwitches, the
// master switch on the Settings page) are therefore enforced
// here, at recording time: with the inbound category off
// (either direction), inbound totals and per-user traffic are
// accepted but not recorded — the off period is never counted,
// matching the switches' documented no-backfill semantics.
// Online presence is not traffic accounting and stays ungated.
sw := a.singboxService.GetTrafficStatsSwitches()
recordInbound := sw.InboundUplink && sw.InboundDownlink
db := database.GetDB()
if agent.NodeID != nil {
if recordInbound && agent.NodeID != nil {
for _, ib := range req.Inbounds {
if ib.Tag == "" || ib.Up+ib.Down <= 0 {
continue
@@ -699,15 +712,17 @@ func (a *AgentController) reportTraffic(c *gin.Context) {
}
}
var clientTraffics []*xray.ClientTraffic
for _, u := range req.Users {
if u.Email == "" || u.Up+u.Down <= 0 {
continue
if recordInbound {
for _, u := range req.Users {
if u.Email == "" || u.Up+u.Down <= 0 {
continue
}
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: u.Email,
Up: u.Up,
Down: u.Down,
})
}
clientTraffics = append(clientTraffics, &xray.ClientTraffic{
Email: u.Email,
Up: u.Up,
Down: u.Down,
})
}
if len(clientTraffics) > 0 {
if _, _, err := a.inboundService.AddTraffic(nil, clientTraffics); err != nil {
+31 -18
View File
@@ -521,29 +521,42 @@ func assembleSingboxConfig(opts assembleSingboxOptions) map[string]any {
"final": "direct",
}
// Stats: sing-box v2ray_api counters exist per category only, so
// a category is generated only when BOTH of its direction
// switches are on (a category with either direction off is not
// generated at all — the off period's traffic is never counted,
// matching the switches' documented no-backfill semantics).
// Stats: the block exists when at least one category gate is on
// (a category counts only when BOTH of its direction switches
// are on). All three name lists are always generated together:
// sing-box's v2ray_api stats manager registers NO counters at
// all unless the inbounds, users AND outbounds lists are all
// present — verified against the real core in the ITEM6 B5
// rehearsal (a config without "outbounds" answered QueryStats
// with zero entries while traffic flowed; adding the list
// produced full user/inbound/outbound counters). The per-
// category counting policy therefore cannot be enforced by
// omission here; the master enforces it at recording time
// (agent traffic report gate) instead. Outbound counters are
// registered but never reported by the agent, so their presence
// costs nothing.
inboundGate := opts.switches.InboundUplink && opts.switches.InboundDownlink
outboundGate := opts.switches.OutboundUplink && opts.switches.OutboundDownlink
if inboundGate || outboundGate {
stats := map[string]any{"enabled": true}
if inboundGate {
stats["inbounds"] = inboundTags
stats["users"] = userNames
}
if outboundGate {
var outboundTags []string
for _, o := range outbounds {
if om, ok := o.(map[string]any); ok {
if t, _ := om["tag"].(string); t != "" {
outboundTags = append(outboundTags, t)
}
outboundTags := []string{}
for _, o := range outbounds {
if om, ok := o.(map[string]any); ok {
if t, _ := om["tag"].(string); t != "" {
outboundTags = append(outboundTags, t)
}
}
stats["outbounds"] = outboundTags
}
if inboundTags == nil {
inboundTags = []string{}
}
if userNames == nil {
userNames = []string{}
}
stats := map[string]any{
"enabled": true,
"inbounds": inboundTags,
"users": userNames,
"outbounds": outboundTags,
}
cfg["experimental"] = map[string]any{
"v2ray_api": map[string]any{
+13 -8
View File
@@ -262,8 +262,13 @@ func loadGenerationFixture(t *testing.T, dir string) (*model.Inbound, singbox.In
}
// TestSingboxStatsGates pins the trafficStatsSwitches → v2ray_api
// stats wiring: a category is generated only when both of its
// direction switches are on; with all four off there is no
// stats wiring: the block is generated when at least one category
// gate is on (both direction switches), and all three name lists
// are always present together — sing-box registers no counters at
// all unless inbounds, users and outbounds are all listed (real-
// core verified in the ITEM6 B5 rehearsal). The per-category
// counting policy is enforced at recording time on the master,
// not by omission here. With all four switches off there is no
// experimental block at all.
func TestSingboxStatsGates(t *testing.T) {
mkItems := func() []*singboxInboundItem {
@@ -301,8 +306,8 @@ func TestSingboxStatsGates(t *testing.T) {
t.Errorf("inbounds list = %v", got)
}
}
if _, ok := stats["outbounds"]; ok {
t.Errorf("outbound-off: outbounds list generated anyway")
if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbounds list must always accompany the stats block (sing-box registers no counters without it)")
}
usersJSON := marshalIndentValue(stats["users"])
if !strings.Contains(usersJSON, "a@example.com") || !strings.Contains(usersJSON, "b@example.com") {
@@ -315,11 +320,11 @@ func TestSingboxStatsGates(t *testing.T) {
if stats == nil {
t.Fatalf("outbound-on: no stats block")
}
if _, ok := stats["inbounds"]; ok {
t.Errorf("inbound half-off (downlink off): inbounds list generated anyway")
if _, ok := stats["inbounds"]; !ok {
t.Errorf("inbound half-off: inbounds list must still accompany the block (registration requirement; recording gate enforces the policy)")
}
if _, ok := stats["users"]; ok {
t.Errorf("inbound half-off: users list generated anyway")
if _, ok := stats["users"]; !ok {
t.Errorf("inbound half-off: users list must still accompany the block (registration requirement)")
}
if _, ok := stats["outbounds"]; !ok {
t.Errorf("outbound-on: outbounds list missing")