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:
Symlink
+1
@@ -0,0 +1 @@
|
||||
/home/hatch/workspace/3x-ui-agent/frontend/node_modules
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user