Files
wweir c86937820a fix(admin): bound error alert to recent window, keep UTF-8-safe details
Review fixes for the error visibility feature: the destructive alert now
only shows while a failure occurred within the last 10 minutes, so a past
outage does not keep a red banner up forever (cumulative counts remain as
context). Error details are truncated on a UTF-8 rune boundary and have
line breaks collapsed, so an IDN host cannot produce invalid UTF-8 in the
SSE payload and a multiline error cannot break the alert layout. Adds
tests for both.
2026-08-11 10:29:10 +08:00

977 lines
32 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"bytes"
"fmt"
"net"
"slices"
"sort"
"strings"
"sync"
"testing"
"time"
"unicode/utf8"
)
type fakeConn struct {
net.Conn
readBuf []byte
writeBuf []byte
}
func (c *fakeConn) Read(p []byte) (int, error) {
n := copy(p, c.readBuf)
c.readBuf = c.readBuf[n:]
return n, nil
}
func (c *fakeConn) Write(p []byte) (int, error) {
c.writeBuf = append(c.writeBuf, p...)
return len(p), nil
}
func (c *fakeConn) Close() error { return nil }
func (c *fakeConn) RemoteAddr() net.Addr {
return &net.TCPAddr{IP: net.ParseIP("192.0.2.1")}
}
func TestStatsWrapConnCountsBytesBeforeBind(t *testing.T) {
s := newTestStats(t)
conn := &fakeConn{readBuf: []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")}
wrapped := s.WrapConn(conn, "http")
// protocol parsing reads header bytes before the domain is known
header := make([]byte, 5)
if _, err := wrapped.Read(header); err != nil {
t.Fatalf("read: %v", err)
}
if _, err := wrapped.Write([]byte("HTTP/1.1 200 OK\r\n")); err != nil {
t.Fatalf("write: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 http conn, got %d", snap.Conns.HTTP)
}
if snap.BytesUp != 5 {
t.Fatalf("expected 5 bytes up, got %d", snap.BytesUp)
}
if snap.BytesDown != 17 {
t.Fatalf("expected 17 bytes down, got %d", snap.BytesDown)
}
if len(snap.Domains) != 0 {
t.Fatalf("expected no domain before bind, got %v", snap.Domains)
}
}
func TestStatsBindConnAttributesBytesToDomain(t *testing.T) {
s := newTestStats(t)
conn := &fakeConn{readBuf: []byte("request body")}
wrapped := s.WrapConn(conn, "https")
pre := make([]byte, 4) // header bytes read before the domain is known
if _, err := wrapped.Read(pre); err != nil {
t.Fatalf("read: %v", err)
}
s.BindConn(wrapped, "example.com.")
payload := make([]byte, 8)
if _, err := wrapped.Read(payload); err != nil {
t.Fatalf("read: %v", err)
}
if _, err := wrapped.Write([]byte("response")); err != nil {
t.Fatalf("write: %v", err)
}
// Byte attribution is batched per connection: the pending batches are
// drained on Close (or when a threshold/interval is crossed), so close
// before asserting the per-domain totals.
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 {
t.Fatalf("expected 1 domain, got %v", snap.Domains)
}
d := snap.Domains[0]
if d.Domain != "example.com" {
t.Fatalf("unexpected domain %q", d.Domain)
}
if d.Conns != 1 {
t.Fatalf("expected 1 domain conn, got %d", d.Conns)
}
if d.BytesUp != 8 {
t.Fatalf("expected 8 domain bytes up, got %d", d.BytesUp)
}
if d.BytesDown != 8 {
t.Fatalf("expected 8 domain bytes down, got %d", d.BytesDown)
}
if snap.BytesUp != 12 {
t.Fatalf("expected 12 aggregate bytes up, got %d", snap.BytesUp)
}
}
func TestStatsBindConnIgnoresUnwrappedConn(t *testing.T) {
s := newTestStats(t)
s.BindConn(&fakeConn{}, "example.com")
if len(s.Snapshot(DomainSortBytes, SourceAll, "").Domains) != 0 {
t.Fatal("expected unwrapped conn to be ignored")
}
}
// TestCountingConnBatchWindow: bytes within the flush window stay pending;
// Close drains them so attribution is exact in the end.
func TestCountingConnBatchWindow(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("x"), 64)}, "http")
s.BindConn(wrapped, "example.com")
buf := make([]byte, 1)
// First I/O flushes immediately (nextFlush starts zero): attributed now.
if _, err := wrapped.Read(buf); err != nil {
t.Fatalf("read: %v", err)
}
// Second I/O falls inside the flush window: kept pending.
if _, err := wrapped.Read(buf); err != nil {
t.Fatalf("read: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != 1 {
t.Fatalf("expected 1 pending byte unflushed, got %+v", snap.Domains)
}
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != 2 {
t.Fatalf("expected both bytes after close, got %+v", snap.Domains)
}
}
// TestCountingConnBatchThreshold: sustained traffic flushes at the byte
// threshold without waiting for Close.
func TestCountingConnBatchThreshold(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("x"), 9*flushBytes)}, "https")
s.BindConn(wrapped, "example.com")
chunk := make([]byte, 4096)
total := 0
for total < 9*4096 {
n, err := wrapped.Read(chunk)
if err != nil {
t.Fatalf("read: %v", err)
}
total += n
}
if total != 9*4096 {
t.Fatalf("read %d bytes, want %d", total, 9*4096)
}
// 9×4KiB crosses the 32KiB threshold (first chunk flushes immediately,
// the next eight accumulate to exactly the threshold), so the domain
// total is visible without Close.
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].BytesUp != uint64(total) {
t.Fatalf("expected threshold flush of %d bytes, got %+v", total, snap.Domains)
}
}
// TestCountingConnBatchConcurrent exercises the relay layout: Read and Write
// run on different goroutines, each direction's pending batch is owned by
// its goroutine, and Close drains both directions under concurrent I/O.
func TestCountingConnBatchConcurrent(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("y"), 64<<10)}, "socks5")
s.BindConn(wrapped, "example.com")
const iters = 8
readChunk, writeChunk := make([]byte, 4096), make([]byte, 4096)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for range iters {
if _, err := wrapped.Read(readChunk); err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
for range iters {
if _, err := wrapped.Write(writeChunk); err != nil {
t.Errorf("write: %v", err)
return
}
}
}()
wg.Wait()
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 {
t.Fatalf("expected 1 domain, got %v", snap.Domains)
}
d := snap.Domains[0]
want := uint64(iters * len(readChunk))
if d.BytesUp != want || d.BytesDown != want {
t.Fatalf("expected %d up/%d down after concurrent relay, got %d/%d",
want, want, d.BytesUp, d.BytesDown)
}
}
// TestCountingConnCloseConcurrent races Close against active relay I/O: the
// closed latch must force a final drain of bytes that land after Close's
// own swap, so no byte is left permanently pending.
func TestCountingConnCloseConcurrent(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{readBuf: bytes.Repeat([]byte("z"), 1<<20)}, "http")
s.BindConn(wrapped, "example.com")
const iters = 500
readChunk, writeChunk := make([]byte, 4096), make([]byte, 4096)
var wg sync.WaitGroup
wg.Add(3)
go func() {
defer wg.Done()
for i := 0; i < iters; i++ {
if _, err := wrapped.Read(readChunk); err != nil {
t.Errorf("read: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
for i := 0; i < iters; i++ {
if _, err := wrapped.Write(writeChunk); err != nil {
t.Errorf("write: %v", err)
return
}
}
}()
go func() {
defer wg.Done()
if err := wrapped.Close(); err != nil {
t.Errorf("close: %v", err)
}
}()
wg.Wait()
// After Close, any byte that raced in must still be drained by the
// closed-latch flush; totals therefore equal the writes that reported
// success, regardless of interleaving.
want := uint64(iters * len(readChunk))
if got := s.bytesDown.Load(); got != want {
t.Fatalf("expected %d total bytes down, got %d", want, got)
}
}
func TestStatsRecordDNS(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("Example.COM.", "")
s.RecordDNS("example.com.", "")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.DNSQueries != 2 {
t.Fatalf("expected 2 dns queries, got %d", snap.DNSQueries)
}
if len(snap.Domains) != 1 {
t.Fatalf("expected dns domains to be deduplicated, got %v", snap.Domains)
}
if snap.Domains[0].Conns != 2 {
t.Fatalf("expected 2 dns conns, got %d", snap.Domains[0].Conns)
}
}
func TestStatsSnapshotEvictsStaleDomains(t *testing.T) {
s := newTestStats(t)
s.mu.Lock()
s.domains["stale.example"] = &domainStat{lastSeen: time.Now().Add(-2 * staleDomainAge)}
s.domains["fresh.example"] = &domainStat{lastSeen: time.Now()}
s.mu.Unlock()
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 1 || snap.Domains[0].Domain != "fresh.example" {
t.Fatalf("expected stale domain evicted, got %v", snap.Domains)
}
}
func TestStatsDomainCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxTotalDomains; i++ {
s.domains[fmt.Sprintf("host%d.example", i)] = &domainStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.mu.Unlock()
s.record("fresh.example", SourceHTTP, "", 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
s.mu.Lock()
n := len(s.domains)
_, oldestPresent := s.domains["host0.example"]
_, newestPresent := s.domains[fmt.Sprintf("host%d.example", maxTotalDomains-1)]
_, freshPresent := s.domains["fresh.example"]
s.mu.Unlock()
want := maxTotalDomains - domainEvictionBatch + 1
if n != want {
t.Fatalf("expected %d domains after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained domains: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestEvictOldestDomainsLocked(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < 4; i++ {
s.domains[fmt.Sprintf("host%d.example", i)] = &domainStat{lastSeen: now.Add(time.Duration(i) * time.Second)}
}
s.evictOldestDomainsLocked(2)
_, firstPresent := s.domains["host0.example"]
_, secondPresent := s.domains["host1.example"]
_, thirdPresent := s.domains["host2.example"]
_, fourthPresent := s.domains["host3.example"]
s.mu.Unlock()
if firstPresent || secondPresent || !thirdPresent || !fourthPresent {
t.Fatalf("unexpected eviction: first=%v second=%v third=%v fourth=%v", firstPresent, secondPresent, thirdPresent, fourthPresent)
}
}
func TestStatsSnapshotSortsByTotalBytes(t *testing.T) {
s := newTestStats(t)
s.record("small.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 1, 1
src.bytesUp, src.bytesDown = 1, 1
})
s.record("big.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 100, 100
src.bytesUp, src.bytesDown = 100, 100
})
s.record("mid.example", SourceHTTP, "", 0, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.bytesUp, d.bytesDown = 10, 10
src.bytesUp, src.bytesDown = 10, 10
})
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 3 {
t.Fatalf("unexpected domains: %v", snap.Domains)
}
if snap.Domains[0].Domain != "big.example" || snap.Domains[2].Domain != "small.example" {
t.Fatalf("unexpected sort order: %v", snap.Domains)
}
}
func newTestStats(t *testing.T) *Stats {
t.Helper()
s, err := NewStats()
if err != nil {
t.Fatalf("new stats: %v", err)
}
return s
}
func TestStatsUptimeIsSeconds(t *testing.T) {
s := newTestStats(t)
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
// A fresh stats object must report a small number of seconds, not
// nanoseconds (a time.Duration would serialize as ~1e9 per second).
if snap.Uptime < 0 || snap.Uptime > 60 {
t.Fatalf("expected uptime in seconds, got %d", snap.Uptime)
}
}
func TestStatsActiveConnsTrackedAndIdempotentClose(t *testing.T) {
s := newTestStats(t)
wrapped := s.WrapConn(&fakeConn{}, "http")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 1 || snap.Conns.HTTP != 1 {
t.Fatalf("expected 1 active/1 total http conn, got %+v / %+v", snap.Active, snap.Conns)
}
if err := wrapped.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if err := wrapped.Close(); err != nil { // idempotent
t.Fatalf("second close: %v", err)
}
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 0 {
t.Fatalf("expected 0 active after close, got %d", snap.Active.HTTP)
}
if snap.Conns.HTTP != 1 {
t.Fatalf("expected cumulative count to stay 1, got %d", snap.Conns.HTTP)
}
}
func TestStatsActiveConnsPerProtocol(t *testing.T) {
s := newTestStats(t)
http := s.WrapConn(&fakeConn{}, "http")
https := s.WrapConn(&fakeConn{}, "https")
socks := s.WrapConn(&fakeConn{}, "socks5")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 1 || snap.Active.HTTPS != 1 || snap.Active.Socks5 != 1 {
t.Fatalf("unexpected active conns: %+v", snap.Active)
}
_ = http.Close()
_ = https.Close()
_ = socks.Close()
snap = s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Active.HTTP != 0 || snap.Active.HTTPS != 0 || snap.Active.Socks5 != 0 {
t.Fatalf("expected all active conns released: %+v", snap.Active)
}
}
func TestStatsRecordRoute(t *testing.T) {
s := newTestStats(t)
s.RecordRoute("block", "ads.example.com")
s.RecordRoute("block", "ads.example.com")
s.RecordRoute("block", "tracker.example.net")
s.RecordRoute("direct", "")
s.RecordRoute("proxy", "")
s.RecordRoute("proxy", "")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.RuleHits.Block != 3 || snap.RuleHits.Direct != 1 || snap.RuleHits.Proxy != 2 {
t.Fatalf("unexpected rule hits: %+v", snap.RuleHits)
}
if len(snap.Blocked) != 2 {
t.Fatalf("expected 2 blocked domains, got %d", len(snap.Blocked))
}
if snap.Blocked[0].Domain != "ads.example.com" || snap.Blocked[0].Count != 2 {
t.Fatalf("unexpected blocked ranking: %+v", snap.Blocked)
}
if snap.Blocked[1].Domain != "tracker.example.net" || snap.Blocked[1].Count != 1 {
t.Fatalf("unexpected blocked ranking: %+v", snap.Blocked)
}
}
func TestStatsRecordProxyErrorCountsAndRings(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", "example.com: connection refused")
s.RecordProxyError("dial", "example.org: timeout")
s.RecordProxyError("dns", "fallback dial 1.1.1.1: timeout")
s.RecordProxyError("accept", "http listener: accept failed")
// Unknown kinds are ignored, not counted.
s.RecordProxyError("bogus", "ignored")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if snap.Errors.Dial != 2 || snap.Errors.DNS != 1 || snap.Errors.Accept != 1 {
t.Fatalf("unexpected error counts: %+v", snap.Errors)
}
if len(snap.Events) != 4 {
t.Fatalf("expected 4 events, got %d", len(snap.Events))
}
// Ring is newest first.
if snap.Events[0].Kind != "accept" || snap.Events[3].Kind != "dial" {
t.Fatalf("unexpected event order: %+v", snap.Events)
}
if snap.Events[0].Detail != "http listener: accept failed" {
t.Fatalf("unexpected event detail: %q", snap.Events[0].Detail)
}
}
func TestStatsErrorEventRingIsBounded(t *testing.T) {
s := newTestStats(t)
for i := 0; i < maxErrorEvents+20; i++ {
s.RecordProxyError("dial", fmt.Sprintf("host-%d: refused", i))
}
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != maxErrorEvents {
t.Fatalf("expected ring capped at %d, got %d", maxErrorEvents, len(snap.Events))
}
// The oldest events are dropped: 84 recorded, 64 retained, host-0..host-19 evicted.
if snap.Events[len(snap.Events)-1].Detail != "host-20: refused" {
t.Fatalf("unexpected oldest retained event: %+v", snap.Events[len(snap.Events)-1])
}
if snap.Errors.Dial != maxErrorEvents+20 {
t.Fatalf("counter must stay cumulative, got %d", snap.Errors.Dial)
}
}
func TestStatsErrorEventDetailTruncated(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", strings.Repeat("x", maxErrorDetailLen*2))
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 || len(snap.Events[0].Detail) != maxErrorDetailLen {
t.Fatalf("detail not truncated to %d: %d", maxErrorDetailLen, len(snap.Events[0].Detail))
}
}
func TestStatsErrorEventDetailTruncationKeepsValidUTF8(t *testing.T) {
s := newTestStats(t)
// A Chinese host name is 3 bytes per rune; a byte-sliced truncation would
// split a rune and emit invalid UTF-8 into the JSON payload.
s.RecordProxyError("dial", strings.Repeat("中", 50))
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
d := snap.Events[0].Detail
if !utf8.ValidString(d) {
t.Fatalf("truncated detail must stay valid UTF-8: %q", d)
}
if len(d) > maxErrorDetailLen {
t.Fatalf("detail exceeds byte budget: %d > %d", len(d), maxErrorDetailLen)
}
}
func TestStatsErrorEventDetailCollapsesNewlines(t *testing.T) {
s := newTestStats(t)
s.RecordProxyError("dial", "first line\nsecond line\n")
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Events) != 1 {
t.Fatalf("expected 1 event, got %d", len(snap.Events))
}
if d := snap.Events[0].Detail; strings.ContainsAny(d, "\r\n") {
t.Fatalf("detail must not contain line breaks: %q", d)
}
}
func TestStatsBlockedCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxBlockedDomains; i++ {
s.blocked[fmt.Sprintf("blocked-%d.example", i)] = &blockedStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.mu.Unlock()
s.RecordRoute("block", "fresh.example")
s.mu.Lock()
n := len(s.blocked)
_, oldestPresent := s.blocked["blocked-0.example"]
_, newestPresent := s.blocked[fmt.Sprintf("blocked-%d.example", maxBlockedDomains-1)]
_, freshPresent := s.blocked["fresh.example"]
s.mu.Unlock()
want := maxBlockedDomains - blockedEvictionBatch + 1
if n != want {
t.Fatalf("expected %d blocked domains after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained blocked domains: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestStatsRatesFromHistory(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.histMu.Lock()
s.lastSample = sampleCounters{at: now.Add(-10 * time.Second)}
s.history = []HistorySample{
{At: now.Add(-10 * time.Second), BytesUp: 1000, BytesDown: 2000, DNS: 10, Conns: 5},
{At: now.Add(-5 * time.Second), BytesUp: 2000, BytesDown: 4000, DNS: 20, Conns: 10},
}
s.histMu.Unlock()
up, down, dns, conns := s.rates()
if up < 290 || up > 310 {
t.Fatalf("expected ~300 bytes/s up, got %v", up)
}
if down < 590 || down > 610 {
t.Fatalf("expected ~600 bytes/s down, got %v", down)
}
if dns < 2.9 || dns > 3.1 {
t.Fatalf("expected ~3 dns/s, got %v", dns)
}
if conns < 1.4 || conns > 1.6 {
t.Fatalf("expected ~1.5 conns/s, got %v", conns)
}
}
func TestStatsHistoryRingBounded(t *testing.T) {
s := newTestStats(t)
s.histMu.Lock()
s.lastSample = sampleCounters{at: time.Now().Add(-2 * time.Second)}
s.history = make([]HistorySample, historyCapacity)
for i := range s.history {
s.history[i] = HistorySample{At: time.Now().Add(-time.Duration(historyCapacity-i) * time.Second)}
}
s.histMu.Unlock()
s.sample() // gap 2s > minGap: appends and trims to capacity
if got := len(s.History().Samples); got != historyCapacity {
t.Fatalf("expected ring capped at %d, got %d", historyCapacity, got)
}
}
func TestStatsSampleClampsLongGap(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("example.com", "")
s.RecordRoute("proxy", "")
s.histMu.Lock()
s.lastSample = sampleCounters{at: time.Now().Add(-10 * time.Minute)}
s.histMu.Unlock()
s.sample()
h := s.History().Samples
if len(h) != 1 {
t.Fatalf("expected 1 sample, got %d", len(h))
}
if h[0].DNS != 0 || h[0].Proxy != 0 {
t.Fatalf("expected clamped zero deltas, got %+v", h[0])
}
}
func TestStatsSnapshotDomainSortModes(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["big.example"] = &domainStat{conns: 10, bytesUp: 1000, bytesDown: 0, lastSeen: now.Add(-time.Minute)}
s.domains["hot.example"] = &domainStat{conns: 2, bytesUp: 100, bytesDown: 100, lastSeen: now}
s.domains["busy.example"] = &domainStat{conns: 20, bytesUp: 10, bytesDown: 10, lastSeen: now.Add(-2 * time.Minute)}
s.mu.Unlock()
byBytes := s.Snapshot(DomainSortBytes, SourceAll, "")
if byBytes.Domains[0].Domain != "big.example" {
t.Fatalf("expected big.example first by bytes, got %q", byBytes.Domains[0].Domain)
}
byRecent := s.Snapshot(DomainSortRecent, SourceAll, "")
if byRecent.Domains[0].Domain != "hot.example" {
t.Fatalf("expected hot.example first by recency, got %q", byRecent.Domains[0].Domain)
}
byConns := s.Snapshot(DomainSortConns, SourceAll, "")
if byConns.Domains[0].Domain != "busy.example" {
t.Fatalf("expected busy.example first by conns, got %q", byConns.Domains[0].Domain)
}
fallback := s.Snapshot(DomainSort("bogus"), SourceAll, "")
if fallback.Domains[0].Domain != "big.example" {
t.Fatalf("expected invalid sort to fall back to bytes, got %q", fallback.Domains[0].Domain)
}
}
func TestStatsSnapshotSourceFilter(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["proxied.example"] = &domainStat{
conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now,
bySource: map[Source]*sourceStat{
SourceHTTP: {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
SourceHTTPS: {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now},
},
}
s.domains["queried.example"] = &domainStat{
conns: 5, lastSeen: now,
bySource: map[Source]*sourceStat{
SourceDNS: {conns: 5, lastSeen: now},
},
}
s.mu.Unlock()
all := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(all.Domains) != 2 {
t.Fatalf("expected 2 domains in all view, got %d", len(all.Domains))
}
dns := s.Snapshot(DomainSortBytes, SourceDNS, "")
if len(dns.Domains) != 1 || dns.Domains[0].Domain != "queried.example" || dns.Domains[0].Conns != 5 {
t.Fatalf("unexpected dns view: %+v", dns.Domains)
}
if dns.Domains[0].BytesUp != 0 || dns.Domains[0].BytesDown != 0 {
t.Fatalf("dns view should carry no bytes, got %+v", dns.Domains[0])
}
httpView := s.Snapshot(DomainSortBytes, SourceHTTP, "")
if len(httpView.Domains) != 1 || httpView.Domains[0].Domain != "proxied.example" || httpView.Domains[0].Conns != 2 {
t.Fatalf("unexpected http view: %+v", httpView.Domains)
}
socks := s.Snapshot(DomainSortBytes, SourceSocks5, "")
if len(socks.Domains) != 0 {
t.Fatalf("expected no socks5 domains, got %+v", socks.Domains)
}
}
func TestRecordDNSAttributedToDNSSource(t *testing.T) {
s := newTestStats(t)
s.RecordDNS("example.com.", "")
dns := s.Snapshot(DomainSortBytes, SourceDNS, "")
if len(dns.Domains) != 1 || dns.Domains[0].Domain != "example.com" || dns.Domains[0].Conns != 1 {
t.Fatalf("unexpected dns view: %+v", dns.Domains)
}
httpView := s.Snapshot(DomainSortBytes, SourceHTTP, "")
if len(httpView.Domains) != 0 {
t.Fatalf("dns query must not appear in http view: %+v", httpView.Domains)
}
}
func TestStatsSnapshotClientFilter(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
conns: 4, bytesUp: 400, bytesDown: 400, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now},
"192.168.1.20": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now},
},
}
s.domains["b.example"] = &domainStat{
conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
},
}
s.domains["c.example"] = &domainStat{
conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.20": {conns: 7, bytesUp: 700, bytesDown: 700, lastSeen: now},
},
}
s.mu.Unlock()
// filtered by client: only that client's domains, with that client's values
view := s.Snapshot(DomainSortBytes, SourceAll, "192.168.1.10")
if len(view.Domains) != 2 {
t.Fatalf("expected 2 domains for client, got %d", len(view.Domains))
}
if view.Domains[0].Domain != "a.example" || view.Domains[0].Conns != 3 {
t.Fatalf("a.example should show client 10's conns (3), got %+v", view.Domains[0])
}
if view.Domains[1].Domain != "b.example" || view.Domains[1].Conns != 2 {
t.Fatalf("b.example should show client 10's conns (2), got %+v", view.Domains[1])
}
// unknown client yields an empty list
empty := s.Snapshot(DomainSortBytes, SourceAll, "203.0.113.9")
if len(empty.Domains) != 0 {
t.Fatalf("expected no domains for unknown client, got %+v", empty.Domains)
}
// the client chips are a navigation device: they must stay stable and
// globally counted regardless of the active client filter, including
// clients that share no domain with the selected one
if len(view.Clients) != 2 {
t.Fatalf("expected both clients in chips under filter, got %+v", view.Clients)
}
if view.Clients[0].IP != "192.168.1.20" || view.Clients[0].Conns != 8 {
t.Fatalf("client .20 must keep its global conns (8) under filter, got %+v", view.Clients[0])
}
if len(empty.Clients) != 2 {
t.Fatalf("chips must survive an unknown-client filter, got %+v", empty.Clients)
}
}
func TestStatsSnapshotLastClientIP(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-2 * time.Minute)},
"192.168.1.20": {conns: 1, bytesUp: 100, bytesDown: 100, lastSeen: now.Add(-1 * time.Minute)},
},
}
s.mu.Unlock()
// unfiltered: the client with the most recent activity wins
view := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(view.Domains) != 1 || view.Domains[0].LastClientIP != "192.168.1.20" {
t.Fatalf("expected .20 as last client, got %+v", view.Domains)
}
// client-filtered: the filter itself is the last client of the view
filtered := s.Snapshot(DomainSortBytes, SourceAll, "192.168.1.10")
if len(filtered.Domains) != 1 || filtered.Domains[0].LastClientIP != "192.168.1.10" {
t.Fatalf("expected filter client as last client, got %+v", filtered.Domains)
}
}
func TestStatsSnapshotClientsAggregate(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
s.domains["a.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 3, bytesUp: 300, bytesDown: 300, lastSeen: now},
},
}
s.domains["b.example"] = &domainStat{
lastSeen: now,
byClient: map[string]*clientStat{
"192.168.1.10": {conns: 2, bytesUp: 200, bytesDown: 200, lastSeen: now},
"192.168.1.20": {conns: 5, bytesUp: 50, bytesDown: 50, lastSeen: now},
},
}
s.mu.Unlock()
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Clients) != 2 {
t.Fatalf("expected 2 clients, got %+v", snap.Clients)
}
if snap.Clients[0].IP != "192.168.1.10" || snap.Clients[0].Conns != 5 || snap.Clients[0].BytesUp != 500 {
t.Fatalf("unexpected top client: %+v", snap.Clients[0])
}
if snap.Clients[1].IP != "192.168.1.20" {
t.Fatalf("unexpected second client: %+v", snap.Clients[1])
}
}
func TestStatsTotalsSurviveStaleness(t *testing.T) {
s := newTestStats(t)
old := time.Now().Add(-2 * staleDomainAge)
s.mu.Lock()
s.domains["old.example"] = &domainStat{
conns: 5, bytesUp: 500, bytesDown: 500, lastSeen: old,
bySource: map[Source]*sourceStat{
SourceHTTP: {conns: 5, bytesUp: 500, bytesDown: 500, lastSeen: old},
},
}
s.mu.Unlock()
// the window view hides the stale domain, the totals keep it
snap := s.Snapshot(DomainSortBytes, SourceAll, "")
if len(snap.Domains) != 0 {
t.Fatalf("expected stale domain hidden from window, got %+v", snap.Domains)
}
tot := s.Totals()
if len(tot.Domains) != 1 || tot.Domains[0].Domain != "old.example" || tot.Domains[0].Conns != 5 {
t.Fatalf("expected stale domain kept in totals, got %+v", tot.Domains)
}
}
func TestStatsTotalsClientsSurviveDomainEviction(t *testing.T) {
s := newTestStats(t)
s.record("evicted.example", SourceHTTP, "192.168.1.10", 2, 100, 200, func(d *domainStat, src *sourceStat, dc *clientStat) {
d.conns += 2
src.conns += 2
})
s.mu.Lock()
delete(s.domains, "evicted.example") // simulate cap eviction
s.mu.Unlock()
tot := s.Totals()
if len(tot.Domains) != 0 {
t.Fatalf("expected no domains after eviction, got %+v", tot.Domains)
}
if len(tot.Clients) != 1 || tot.Clients[0].IP != "192.168.1.10" ||
tot.Clients[0].Conns != 2 || tot.Clients[0].BytesUp != 100 || tot.Clients[0].BytesDown != 200 {
t.Fatalf("expected client totals to survive domain eviction, got %+v", tot.Clients)
}
}
func TestStatsClientCapIsEnforced(t *testing.T) {
s := newTestStats(t)
now := time.Now()
s.mu.Lock()
for i := 0; i < maxTotalClients; i++ {
s.clients[fmt.Sprintf("192.168.%d.%d", i/256, i%256)] = &clientStat{lastSeen: now.Add(time.Duration(i) * time.Nanosecond)}
}
s.domains["existing.example"] = &domainStat{bySource: make(map[Source]*sourceStat), lastSeen: now}
s.mu.Unlock()
s.record("existing.example", SourceHTTP, "203.0.113.1", 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
s.mu.Lock()
n := len(s.clients)
_, oldestPresent := s.clients["192.168.0.0"]
_, newestPresent := s.clients[fmt.Sprintf("192.168.%d.%d", (maxTotalClients-1)/256, (maxTotalClients-1)%256)]
_, freshPresent := s.clients["203.0.113.1"]
s.mu.Unlock()
want := maxTotalClients - clientEvictionBatch + 1
if n != want {
t.Fatalf("expected %d clients after batch eviction, got %d", want, n)
}
if oldestPresent || !newestPresent || !freshPresent {
t.Fatalf("unexpected retained clients: oldest=%v newest=%v fresh=%v", oldestPresent, newestPresent, freshPresent)
}
}
func TestStatsClientsPerDomainCap(t *testing.T) {
s := newTestStats(t)
for i := 0; i < maxClientsPerDomain+10; i++ {
s.record("shared.example", SourceHTTP, fmt.Sprintf("192.0.2.%d", i), 1, 0, 0, func(d *domainStat, src *sourceStat, dc *clientStat) {})
}
s.mu.Lock()
n := len(s.domains["shared.example"].byClient)
s.mu.Unlock()
if n > maxClientsPerDomain {
t.Fatalf("expected per-domain client cap, got %d entries", n)
}
}
func TestTopDomainsMatchesFullSort(t *testing.T) {
now := time.Now()
input := make([]DomainStat, 0, snapshotTopN+37)
for i := 0; i < snapshotTopN+37; i++ {
input = append(input, DomainStat{
Domain: fmt.Sprintf("host%03d.example", i),
Conns: uint64((i * 7) % 31),
BytesUp: uint64((i * 13) % 101),
BytesDown: uint64((i * 17) % 97),
LastSeen: now.Add(time.Duration((i*19)%71) * time.Second),
})
}
for _, mode := range []DomainSort{DomainSortBytes, DomainSortRecent, DomainSortConns} {
want := slices.Clone(input)
sortDomains(want, mode)
want = want[:snapshotTopN]
got := topDomains(slices.Clone(input), snapshotTopN, mode)
if !slices.Equal(got, want) {
t.Fatalf("top domains differ for %s:\n got %v\nwant %v", mode, got, want)
}
}
tied := topDomains([]DomainStat{
{Domain: "z.example", BytesUp: 1},
{Domain: "a.example", BytesUp: 1},
}, 2, DomainSortBytes)
if tied[0].Domain != "a.example" || tied[1].Domain != "z.example" {
t.Fatalf("expected domain tie break by name, got %v", tied)
}
}
func TestTopClientsMatchesFullSort(t *testing.T) {
agg := make(map[string]*ClientStat, clientsTopN+17)
for i := 0; i < clientsTopN+17; i++ {
ip := fmt.Sprintf("192.0.2.%03d", i)
agg[ip] = &ClientStat{IP: ip, BytesUp: uint64((i * 11) % 113), BytesDown: uint64((i * 23) % 109)}
}
want := make([]ClientStat, 0, len(agg))
for _, client := range agg {
want = append(want, *client)
}
sort.Slice(want, func(i, j int) bool { return clientLess(want[i], want[j]) })
want = want[:clientsTopN]
got := topClients(agg)
if !slices.Equal(got, want) {
t.Fatalf("top clients differ:\n got %v\nwant %v", got, want)
}
tied := topClients(map[string]*ClientStat{
"192.0.2.2": {IP: "192.0.2.2", BytesUp: 1},
"192.0.2.1": {IP: "192.0.2.1", BytesUp: 1},
})
if tied[0].IP != "192.0.2.1" || tied[1].IP != "192.0.2.2" {
t.Fatalf("expected IP tie break, got %v", tied)
}
}