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.
125 lines
4.2 KiB
Go
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
|
|
}
|