Files
shater/common/tlsspoof/integration_test.go
T
omarandClaude Opus 5 63e6b709f8
release / apk aarch64_cortex-a53 (push) Successful in 3m4s
release / apk x86_64 (push) Successful in 3m1s
release / release apk (push) Successful in 7s
fix(health): a chain blocked at a hop is dead on the board, unknown on the card
The short-circuit left the chain's exit tag untested, because the exit is itself
a hop and every hop behind the break was rewritten that way. A stand run caught
it — the test lives in a file that does not compile on the dev host, so nothing
local could have.

That is not neutral silence. selectExcluding ranks untested ABOVE dead and says
so in its own comment: with no fresh-alive member, an untested one is a better
bet than a known-dead one. Leaving a provably broken path untested is therefore
a positive preference for it over a path we merely know is dead.

The two readings answer different questions and now differ on purpose. Is this
hop's own node alive — unknown behind a break, so the card keeps untested and
blocked_by. Can this chain carry traffic — known, no, because the hop in front
of it was probed and did not answer. The board carries that second answer, which
is the one selection, the freshness gate and the manual test all read.

The exit verdict is derived, not dialled: it records the consequence of a probe
that did happen one hop earlier, and it is re-derived every pass, so the moment
the blocker answers the walk reaches the exit again and the next verdict there is
a real measurement.

Also keeps a routed group warm. Its checker used to stop on the idle timeout and
nothing filled in behind it, so a rule that fires rarely would show untested
while being in force and pay a cold probe on the first real request. The gate
that adds this work answers false when it does not know — the mirror of the one
that withholds work, so plain sing-box keeps the lifecycle it always had.

And the tls-spoof suite now skips without tcpdump instead of failing sixteen
times: a missing tool is not measured, not broken. The same distinction this
commit is about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 02:48:26 +03:00

160 lines
4.2 KiB
Go

//go:build linux || darwin
package tlsspoof
import (
"bufio"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func requireRoot(t *testing.T) {
t.Helper()
if os.Geteuid() != 0 {
t.Skip("integration test requires root; re-run with `go test -exec sudo`")
}
}
// requireTCPDump skips when tcpdump is not installed.
//
// The same honesty this package's callers demand of a health reading: a missing
// INSTRUMENT is "not checked", never "broken". Without it every test in this
// file fails on `cmd.Start()` — sixteen red results that say nothing about the
// code and hide any real failure among them — on a machine where the only thing
// wrong is that a capture tool is absent. requireRoot has always drawn that line
// for privileges; this draws it for the tool.
func requireTCPDump(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("tcpdump"); err != nil {
t.Skip("integration test requires tcpdump on PATH; install it to run this suite")
}
}
func tcpdumpObserver(t *testing.T, iface string, port uint16, needle string, do func(), wait time.Duration) bool {
t.Helper()
return tcpdumpObserverMulti(t, iface, port, []string{needle}, do, wait)[needle]
}
// tcpdumpObserverMulti captures tcpdump output while do() executes and reports
// which of the provided needles were observed in the raw ASCII dump. Use this
// to assert that distinct payloads (e.g. fake vs real ClientHello) are both on
// the wire.
func tcpdumpObserverMulti(t *testing.T, iface string, port uint16, needles []string, do func(), wait time.Duration) map[string]bool {
t.Helper()
// Every capture in this file funnels through here, so one guard covers the
// whole suite and no future test can forget it.
requireTCPDump(t)
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
cmd := exec.CommandContext(ctx, "tcpdump", "-i", iface, "-n", "-A", "-l",
"-s", "4096", fmt.Sprintf("tcp and port %d", port))
cmd.Cancel = func() error {
return cmd.Process.Signal(os.Interrupt)
}
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
stderr, err := cmd.StderrPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Signal(os.Interrupt)
_ = cmd.Wait()
})
ready := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "listening on") {
close(ready)
io.Copy(io.Discard, stderr)
return
}
}
}()
select {
case <-ready:
case <-time.After(2 * time.Second):
t.Fatal("tcpdump did not attach within 2s")
}
var access sync.Mutex
found := make(map[string]bool, len(needles))
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
access.Lock()
for _, needle := range needles {
if !found[needle] && strings.Contains(line, needle) {
found[needle] = true
}
}
access.Unlock()
}
}()
do()
time.Sleep(200 * time.Millisecond)
_ = cmd.Process.Signal(os.Interrupt)
<-readerDone
access.Lock()
defer access.Unlock()
result := make(map[string]bool, len(needles))
for _, needle := range needles {
result[needle] = found[needle]
}
return result
}
func dialLocalEchoServer(t *testing.T) (client net.Conn, serverPort uint16) {
return dialLocalEchoServerFamily(t, "tcp4", "127.0.0.1:0")
}
func dialLocalEchoServerIPv6(t *testing.T) (client net.Conn, serverPort uint16) {
return dialLocalEchoServerFamily(t, "tcp6", "[::1]:0")
}
func dialLocalEchoServerFamily(t *testing.T, network, address string) (client net.Conn, serverPort uint16) {
t.Helper()
listener, err := net.Listen(network, address)
require.NoError(t, err)
accepted := make(chan net.Conn, 1)
go func() {
c, err := listener.Accept()
if err == nil {
accepted <- c
}
close(accepted)
}()
addr := listener.Addr().(*net.TCPAddr)
client, err = net.Dial(network, addr.String())
require.NoError(t, err)
server := <-accepted
require.NotNil(t, server)
go io.Copy(io.Discard, server)
t.Cleanup(func() {
client.Close()
server.Close()
listener.Close()
})
return client, uint16(addr.Port)
}