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
+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")