diff --git a/frontend/node_modules b/frontend/node_modules new file mode 120000 index 0000000..3acffbc --- /dev/null +++ b/frontend/node_modules @@ -0,0 +1 @@ +/home/hatch/workspace/3x-ui-agent/frontend/node_modules \ No newline at end of file diff --git a/internal/agent/zz_probe_stats_test.go b/internal/agent/zz_probe_stats_test.go new file mode 100644 index 0000000..5f535d5 --- /dev/null +++ b/internal/agent/zz_probe_stats_test.go @@ -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) + } +} diff --git a/internal/web/controller/agent.go b/internal/web/controller/agent.go index 5a3ed29..abf35e1 100644 --- a/internal/web/controller/agent.go +++ b/internal/web/controller/agent.go @@ -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 { diff --git a/internal/web/service/singbox.go b/internal/web/service/singbox.go index 6105b61..124815f 100644 --- a/internal/web/service/singbox.go +++ b/internal/web/service/singbox.go @@ -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{ diff --git a/internal/web/service/singbox_golden_test.go b/internal/web/service/singbox_golden_test.go index fa9d24f..7d97e80 100644 --- a/internal/web/service/singbox_golden_test.go +++ b/internal/web/service/singbox_golden_test.go @@ -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")