Files
3x-ui-agent/internal/agent/singbox_online_test.go
T
Muse 1762e54d5f ITEM6 B3: agent sing-box runtime (parallel path)
Agent consumes singbox_config alongside xray: candidate->check->
flush->swap activation with .previous rollback, wire-level v2ray_api
stats client with NexusGate epoch discipline (mid-query flip voids
the round, new epoch counts in full, agent-first snapshot baselines),
access-log online tracker (authenticated-connection lines, ANSI-safe,
merged with snapshot-active users), per-core traffic baselines with
the sing-box one carrying its epoch. install-agent.sh installs the
self-built sing-box v1.13.21 next to xray (xray/geo removal deferred
to the B5 switchover). Env-gated real-core E2E covers vless-reality,
ss2022, mixed, trojan and the forwarding leg end to end; hysteria2 is
probe-gated (sandbox blocks UDP replies) and stays a B5 pilot item.
2026-10-22 22:48:12 +00:00

125 lines
4.2 KiB
Go

package agent
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Real line shapes captured from sing-box v1.13.21 in the B3 spike,
// ANSI color sequences included (the core colorizes the level and
// the connection id even when its output is redirected to a file).
const (
spikeAuthSS = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: [bob@example.com] inbound connection to 127.0.0.1:18080"
spikeAuthMixed = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;231m1630640343\x1b[0m 0ms] inbound/mixed[mixed-in]: [carol@example.com] inbound connection to 127.0.0.1:18080"
spikeFromLine = "+0000 2026-10-22 21:54:58 \x1b[36mINFO\x1b[0m [\x1b[38;5;51m1785752244\x1b[0m 0ms] inbound/shadowsocks[ss22]: inbound connection from 127.0.0.1:52076"
spikeErrorLine = "+0000 2026-10-22 21:54:57 \x1b[31mERROR\x1b[0m [\x1b[38;5;158m398453065\x1b[0m 85ms] inbound/vless[vless-reality]: process connection from 127.0.0.1:42732: TLS handshake: REALITY: processed invalid connection"
spikeStartLine = "+0000 2026-10-22 21:53:31 \x1b[36mINFO\x1b[0m inbound/mixed[mixed-in]: tcp server started at 127.0.0.1:24445"
)
func TestParseSingboxAuthLine(t *testing.T) {
cases := []struct {
line string
want string
}{
{spikeAuthSS, "bob@example.com"},
{spikeAuthMixed, "carol@example.com"},
{spikeFromLine, ""}, // unauthenticated stage: never counts
{spikeErrorLine, ""}, // failed handshake: never counts
{spikeStartLine, ""},
{"", ""},
}
for _, tc := range cases {
if got := parseSingboxAuthLine(tc.line); got != tc.want {
t.Errorf("parse(%q) = %q, want %q", tc.line, got, tc.want)
}
}
}
func TestSingboxOnlineTrackerPollAndGrace(t *testing.T) {
path := filepath.Join(t.TempDir(), "singbox.log")
now := time.Unix(1_700_000_000, 0)
tr := newSingboxOnlineTracker(path)
// No file yet: quiet no-op.
tr.Poll(now)
if got := tr.Online(now); len(got) != 0 {
t.Fatalf("online = %v, want empty", got)
}
content := spikeFromLine + "\n" + spikeAuthSS + "\n" + spikeErrorLine + "\n"
if err := os.WriteFile(path, []byte(content), 0o640); err != nil {
t.Fatal(err)
}
tr.Poll(now)
got := tr.Online(now)
if len(got) != 1 || got[0] != "bob@example.com" {
t.Fatalf("online = %v, want [bob@example.com]", got)
}
// Re-poll without new content: no double counting, same set.
tr.Poll(now)
if got := tr.Online(now); len(got) != 1 {
t.Fatalf("online after idle poll = %v", got)
}
// Past the grace window the user ages out.
if got := tr.Online(now.Add(singboxOnlineGrace + time.Second)); len(got) != 0 {
t.Fatalf("online after grace = %v, want empty", got)
}
// A partial line (no trailing newline) is not consumed until it
// completes. (bob is still within his grace window here; only
// carol's appearance is being timed.)
partial := spikeAuthMixed // no trailing \n yet
if err := appendToFile(path, partial); err != nil {
t.Fatal(err)
}
later := now.Add(time.Minute)
tr.Poll(later)
if got := tr.Online(later); strings.Contains(strings.Join(got, ","), "carol@example.com") {
t.Fatalf("partial line must not count, online = %v", got)
}
if err := appendToFile(path, "\n"); err != nil {
t.Fatal(err)
}
tr.Poll(later)
got = tr.Online(later)
if !strings.Contains(strings.Join(got, ","), "carol@example.com") {
t.Fatalf("online after line completes = %v, want carol@example.com present", got)
}
}
func TestSingboxOnlineTrackerTruncation(t *testing.T) {
path := filepath.Join(t.TempDir(), "singbox.log")
now := time.Unix(1_700_000_000, 0)
tr := newSingboxOnlineTracker(path)
if err := os.WriteFile(path, []byte(spikeAuthSS+"\n"+spikeAuthSS+"\n"), 0o640); err != nil {
t.Fatal(err)
}
tr.Poll(now)
// File shrinks below the cursor (rotation): rescan from the top.
if err := os.WriteFile(path, []byte(spikeAuthMixed+"\n"), 0o640); err != nil {
t.Fatal(err)
}
tr.Poll(now)
got := tr.Online(now)
joined := strings.Join(got, ",")
if !strings.Contains(joined, "carol@example.com") || !strings.Contains(joined, "bob@example.com") {
t.Fatalf("online after truncation = %v, want both users", got)
}
}
func appendToFile(path, s string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(s)
return err
}