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>
160 lines
4.2 KiB
Go
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)
|
|
}
|