168596bcb5
Three follow-up fixes after the WinDivert→sing-box pivot: 1. Discord updater now routes through upstream. Previously only the process-name rule matched, but sing-box's TUN-side process detection on Windows mis-attributes the in-process Rust updater's TLS connection to e.g. steam.exe — the connection went direct and hit RKN block. Adding domain_suffix + ip_cidr rules for Cloudflare (162.159/16, 104.16/13, 172.64/13) and Fastly (199.232/16, 151.101/16) catches updates.discord.com regardless of which PID the kernel claims sent it. Verified via curl through mihomo: updates.discord.com responds 400 in 393ms (i.e. TLS handshake succeeds, only the path is wrong — proves the routing reaches it). 2. DiscordSystemHelper.exe added to TargetProcs alongside Update.exe (modern Discord builds use it for elevated updates). 3. UDP voice quality test removed from the checker. The STUN-via- relay burst measured private mihomo BND.ADDR (192.168.1.132) which is unroutable from external clients, so the test reported 100% loss every time despite voice actually working through sing-box's TUN+SOCKS5. The remaining 6 checks (TCP/greet/auth/ connect/UDP/api) cover what's actionable; voice quality is verified empirically by joining a Discord call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
895 lines
24 KiB
Go
895 lines
24 KiB
Go
package checker
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeProxy is a test SOCKS5 server with per-scenario behaviour. It also
|
|
// optionally runs a UDP relay that echoes STUN-shaped responses crafted
|
|
// to look like Binding Success Responses with XOR-MAPPED-ADDRESS pointing
|
|
// back at the client's source IP.
|
|
//
|
|
// The TCP-side splice for the API test detects CONNECT requests targeting
|
|
// apiTargetHost:apiTargetPort and, instead of sending a synthetic reply,
|
|
// dials apiTargetAddr and bridges the two conns. This lets a real
|
|
// httptest.NewServer be used as the API endpoint.
|
|
type fakeProxy struct {
|
|
t *testing.T
|
|
addr string
|
|
scenario string
|
|
|
|
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
|
|
|
|
// udpDropEveryN, when > 0, drops every Nth packet through the relay
|
|
// (counted across the whole listener lifetime). N=2 → 50% loss; N=10
|
|
// → 10%; N=1 → 100% loss; 0 → no drops.
|
|
udpDropEveryN atomic.Int32
|
|
udpRelayCount atomic.Int32
|
|
|
|
// API-passthrough hook: when a CONNECT targets this host:port,
|
|
// the proxy dials apiTargetAddr and splices the conns instead of
|
|
// sending a fake REP=00 + close.
|
|
apiTargetHost string
|
|
apiTargetPort uint16
|
|
apiTargetAddr string
|
|
|
|
// timeoutFirstAttempt stalls the first connection on greet to
|
|
// drive a timeout. Subsequent connections behave normally.
|
|
timeoutFirstAttempt atomic.Int32
|
|
}
|
|
|
|
// newFakeProxy starts a TCP listener and a UDP relay (if relevant for
|
|
// the scenario). Both are torn down via t.Cleanup.
|
|
func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
|
|
t.Helper()
|
|
|
|
fp := &fakeProxy{t: t, scenario: scenario}
|
|
|
|
// Start UDP relay for scenarios that need it.
|
|
if needsUDPRelay(scenario) {
|
|
ua, err := net.ResolveUDPAddr("udp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
uconn, err := net.ListenUDP("udp", ua)
|
|
require.NoError(t, err)
|
|
fp.udpRelayAddr = uconn.LocalAddr().(*net.UDPAddr)
|
|
|
|
t.Cleanup(func() { _ = uconn.Close() })
|
|
go fp.runRelay(uconn)
|
|
}
|
|
|
|
// Start TCP listener.
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
fp.addr = ln.Addr().String()
|
|
|
|
if scenario == "timeout_then_ok" {
|
|
fp.timeoutFirstAttempt.Store(1)
|
|
}
|
|
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
|
|
go fp.serve(ln)
|
|
|
|
return fp
|
|
}
|
|
|
|
func needsUDPRelay(scenario string) bool {
|
|
switch scenario {
|
|
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok",
|
|
"voice_quality_warn", "voice_quality_fail":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// serve accepts connections forever until the listener is closed.
|
|
func (fp *fakeProxy) serve(ln net.Listener) {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go fp.handle(conn)
|
|
}
|
|
}
|
|
|
|
func (fp *fakeProxy) handle(conn net.Conn) {
|
|
defer conn.Close()
|
|
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
|
|
|
// First-attempt-timeout scenario: read greet, then sleep past
|
|
// the per-test timeout to force a deadline error.
|
|
if fp.timeoutFirstAttempt.CompareAndSwap(1, 0) {
|
|
buf := make([]byte, 1024)
|
|
_, _ = conn.Read(buf)
|
|
time.Sleep(2 * time.Second)
|
|
return
|
|
}
|
|
|
|
br := newPeekReader(conn)
|
|
|
|
// Step 1: greeting.
|
|
greet, err := readGreeting(br)
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch fp.scenario {
|
|
case "all_methods_rejected":
|
|
_, _ = conn.Write([]byte{0x05, 0xFF})
|
|
return
|
|
case "auth_rejected":
|
|
// Server picks user/pass.
|
|
_, _ = conn.Write([]byte{0x05, 0x02})
|
|
// Read auth.
|
|
_ = readAuth(br)
|
|
_, _ = conn.Write([]byte{0x01, 0x01}) // status=fail
|
|
return
|
|
}
|
|
|
|
// Method selection: scenarios that involve auth force 0x02 if
|
|
// offered; otherwise prefer 0x00.
|
|
preferAuth := fp.scenario == "happy_with_auth"
|
|
chosen := byte(0xFF)
|
|
if preferAuth {
|
|
for _, m := range greet.methods {
|
|
if m == 0x02 {
|
|
chosen = 0x02
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen == 0xFF {
|
|
for _, m := range greet.methods {
|
|
if m == 0x00 {
|
|
chosen = 0x00
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen == 0xFF {
|
|
for _, m := range greet.methods {
|
|
if m == 0x02 {
|
|
chosen = 0x02
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if chosen == 0xFF {
|
|
_, _ = conn.Write([]byte{0x05, 0xFF})
|
|
return
|
|
}
|
|
_, _ = conn.Write([]byte{0x05, chosen})
|
|
|
|
if chosen == 0x02 {
|
|
if err := readAuth(br); err != nil {
|
|
return
|
|
}
|
|
_, _ = conn.Write([]byte{0x01, 0x00}) // success
|
|
}
|
|
|
|
// Step 2: read CMD request.
|
|
cmdReq, err := readSocks5Request(br)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
switch cmdReq.cmd {
|
|
case 0x01: // CONNECT
|
|
switch fp.scenario {
|
|
case "connect_refused":
|
|
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
// API passthrough?
|
|
if fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort {
|
|
// Dial real target, splice.
|
|
target, derr := net.Dial("tcp", fp.apiTargetAddr)
|
|
if derr != nil {
|
|
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
// Clear deadline for the splice.
|
|
_ = conn.SetDeadline(time.Time{})
|
|
_ = target.SetDeadline(time.Time{})
|
|
// Splice. We can't get already-buffered bytes back
|
|
// out of br trivially, but the client only sent the
|
|
// 7+len bytes for CONNECT and we read exactly that —
|
|
// so br has no leftover buffered bytes here.
|
|
done := make(chan struct{}, 2)
|
|
go func() { _, _ = io.Copy(target, conn); done <- struct{}{} }()
|
|
go func() { _, _ = io.Copy(conn, target); done <- struct{}{} }()
|
|
<-done
|
|
_ = target.Close()
|
|
return
|
|
}
|
|
// Default happy CONNECT.
|
|
_, _ = conn.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
// Keep conn open briefly so client doesn't see EOF before
|
|
// reading the 10-byte reply.
|
|
time.Sleep(50 * time.Millisecond)
|
|
return
|
|
case 0x03: // UDP ASSOCIATE
|
|
if fp.scenario == "udp_unsupported" {
|
|
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
// Reply with our UDP relay endpoint.
|
|
ip4 := fp.udpRelayAddr.IP.To4()
|
|
if ip4 == nil {
|
|
_, _ = conn.Write([]byte{0x05, 0x01, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
reply := []byte{0x05, 0x00, 0x00, 0x01,
|
|
ip4[0], ip4[1], ip4[2], ip4[3],
|
|
byte(fp.udpRelayAddr.Port >> 8), byte(fp.udpRelayAddr.Port)}
|
|
_, _ = conn.Write(reply)
|
|
// Keep TCP control channel open so the relay stays valid.
|
|
// The client will close conn when done. We just block on
|
|
// read until peer closes.
|
|
_ = conn.SetDeadline(time.Time{})
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
return
|
|
default:
|
|
_, _ = conn.Write([]byte{0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
|
return
|
|
}
|
|
}
|
|
|
|
// runRelay reads SOCKS5 UDP datagrams, parses the embedded STUN binding
|
|
// request, and replies with a synthetic Binding Success Response carrying
|
|
// XOR-MAPPED-ADDRESS = client's source.
|
|
func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
|
|
buf := make([]byte, 2048)
|
|
for {
|
|
n, src, err := uconn.ReadFromUDP(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Optional packet-drop simulation. udpDropEveryN of value 1 drops
|
|
// everything; 2 drops every other packet; 10 drops 10%.
|
|
if dropN := fp.udpDropEveryN.Load(); dropN > 0 {
|
|
c := fp.udpRelayCount.Add(1)
|
|
if c%dropN == 0 {
|
|
continue
|
|
}
|
|
} else {
|
|
fp.udpRelayCount.Add(1)
|
|
}
|
|
if n < 10 {
|
|
continue
|
|
}
|
|
// Parse SOCKS5 UDP wrapper. Expect ATYP=01.
|
|
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
|
continue
|
|
}
|
|
var hdrLen int
|
|
switch buf[3] {
|
|
case 0x01:
|
|
hdrLen = 10
|
|
case 0x04:
|
|
hdrLen = 22
|
|
case 0x03:
|
|
if n < 5 {
|
|
continue
|
|
}
|
|
hdrLen = 4 + 1 + int(buf[4]) + 2
|
|
default:
|
|
continue
|
|
}
|
|
if n < hdrLen+20 {
|
|
continue
|
|
}
|
|
stunReq := buf[hdrLen:n]
|
|
// Expect a binding request.
|
|
if len(stunReq) < 20 {
|
|
continue
|
|
}
|
|
var txID [12]byte
|
|
copy(txID[:], stunReq[8:20])
|
|
|
|
// Build XOR-MAPPED-ADDRESS attribute value for src.
|
|
ip4 := src.IP.To4()
|
|
if ip4 == nil {
|
|
continue
|
|
}
|
|
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
|
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
|
|
|
// Build STUN Binding Success Response.
|
|
stunResp := make([]byte, 20+12) // header + 4-byte attr header + 8-byte XMA
|
|
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
|
binary.BigEndian.PutUint16(stunResp[2:4], 12) // attr length
|
|
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
|
copy(stunResp[8:20], txID[:])
|
|
// Attribute header: type, length.
|
|
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
|
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
|
// Value: 0, family=01, x-port, x-addr.
|
|
stunResp[24] = 0
|
|
stunResp[25] = 0x01
|
|
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
|
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
|
|
|
// Wrap in SOCKS5 UDP header.
|
|
out := make([]byte, 0, 10+len(stunResp))
|
|
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
|
out = append(out, ip4...)
|
|
var portBuf [2]byte
|
|
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
|
out = append(out, portBuf[:]...)
|
|
out = append(out, stunResp...)
|
|
|
|
_, _ = uconn.WriteToUDP(out, src)
|
|
}
|
|
}
|
|
|
|
// peekReader wraps net.Conn so we can read variable-length SOCKS5 frames.
|
|
type peekReader struct {
|
|
r io.Reader
|
|
}
|
|
|
|
func newPeekReader(r io.Reader) *peekReader { return &peekReader{r: r} }
|
|
|
|
func (p *peekReader) ReadFull(n int) ([]byte, error) {
|
|
buf := make([]byte, n)
|
|
if _, err := io.ReadFull(p.r, buf); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf, nil
|
|
}
|
|
|
|
type greetingMsg struct {
|
|
methods []byte
|
|
}
|
|
|
|
func readGreeting(r *peekReader) (*greetingMsg, error) {
|
|
hdr, err := r.ReadFull(2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hdr[0] != 0x05 {
|
|
return nil, fmt.Errorf("bad ver")
|
|
}
|
|
nMethods := int(hdr[1])
|
|
methods, err := r.ReadFull(nMethods)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &greetingMsg{methods: methods}, nil
|
|
}
|
|
|
|
func readAuth(r *peekReader) error {
|
|
hdr, err := r.ReadFull(2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if hdr[0] != 0x01 {
|
|
return fmt.Errorf("bad auth ver")
|
|
}
|
|
ulen := int(hdr[1])
|
|
if _, err := r.ReadFull(ulen); err != nil {
|
|
return err
|
|
}
|
|
plenBuf, err := r.ReadFull(1)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
plen := int(plenBuf[0])
|
|
if _, err := r.ReadFull(plen); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type socks5Request struct {
|
|
cmd byte
|
|
atyp byte
|
|
host string
|
|
port uint16
|
|
}
|
|
|
|
func readSocks5Request(r *peekReader) (*socks5Request, error) {
|
|
hdr, err := r.ReadFull(4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if hdr[0] != 0x05 {
|
|
return nil, fmt.Errorf("bad ver")
|
|
}
|
|
out := &socks5Request{cmd: hdr[1], atyp: hdr[3]}
|
|
switch hdr[3] {
|
|
case 0x01:
|
|
ipBuf, err := r.ReadFull(4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.host = net.IP(ipBuf).String()
|
|
case 0x03:
|
|
lenBuf, err := r.ReadFull(1)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hostBuf, err := r.ReadFull(int(lenBuf[0]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.host = string(hostBuf)
|
|
case 0x04:
|
|
ipBuf, err := r.ReadFull(16)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.host = net.IP(ipBuf).String()
|
|
default:
|
|
return nil, fmt.Errorf("bad atyp")
|
|
}
|
|
portBuf, err := r.ReadFull(2)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.port = binary.BigEndian.Uint16(portBuf)
|
|
return out, nil
|
|
}
|
|
|
|
func methodChosen(cur, _ byte) bool { return cur != 0xFF }
|
|
|
|
// drainResults pulls every Result off ch into a slice (with a hard timeout
|
|
// so a hung implementation doesn't hang the test).
|
|
func drainResults(t *testing.T, ch <-chan Result, timeout time.Duration) []Result {
|
|
t.Helper()
|
|
var out []Result
|
|
deadline := time.NewTimer(timeout)
|
|
defer deadline.Stop()
|
|
for {
|
|
select {
|
|
case r, ok := <-ch:
|
|
if !ok {
|
|
return out
|
|
}
|
|
out = append(out, r)
|
|
case <-deadline.C:
|
|
t.Fatalf("checker.Run did not finish within %s; got %d results so far: %+v", timeout, len(out), out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// finalByID returns the LAST result emitted for the given test id, or zero.
|
|
func finalByID(results []Result, id string) (Result, bool) {
|
|
for i := len(results) - 1; i >= 0; i-- {
|
|
if results[i].ID == id && results[i].Status != StatusRunning {
|
|
return results[i], true
|
|
}
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
// hostPort splits an addr returned by net.Listener.Addr().String().
|
|
func hostPort(addr string) (string, int) {
|
|
host, p, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
pn, err := strconv.Atoi(p)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return host, pn
|
|
}
|
|
|
|
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
|
// short timeouts for tests.
|
|
func proxyConfig(fp *fakeProxy, useAuth bool) Config {
|
|
host, port := hostPort(fp.addr)
|
|
cfg := Config{
|
|
ProxyHost: host,
|
|
ProxyPort: port,
|
|
UseAuth: useAuth,
|
|
PerTestTimeout: 500 * time.Millisecond,
|
|
MaxRetries: 1,
|
|
RetryBackoff: 30 * time.Millisecond,
|
|
VoiceBurstCount: 10,
|
|
VoiceBurstInterval: 5 * time.Millisecond,
|
|
}
|
|
if useAuth {
|
|
cfg.ProxyLogin = "u"
|
|
cfg.ProxyPassword = "p"
|
|
}
|
|
if fp.udpRelayAddr != nil {
|
|
// no-op; relay is announced via UDP ASSOCIATE reply
|
|
_ = fp.udpRelayAddr
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
// stubAPIServer starts an httptest server returning HTTP 200 with a tiny
|
|
// JSON body, plus arranges fakeProxy to splice CONNECTs targeting it.
|
|
func stubAPIServer(t *testing.T, fp *fakeProxy, status int) string {
|
|
t.Helper()
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(status)
|
|
_, _ = io.WriteString(w, `{"url":"wss://gateway.discord.gg"}`)
|
|
}))
|
|
t.Cleanup(srv.Close)
|
|
|
|
// Parse the test server's host:port.
|
|
host, port := hostPort(strings.TrimPrefix(srv.URL, "http://"))
|
|
fp.apiTargetHost = host
|
|
fp.apiTargetPort = uint16(port)
|
|
fp.apiTargetAddr = srv.Listener.Addr().String()
|
|
return srv.URL + "/api/v9/gateway"
|
|
}
|
|
|
|
// stubGatewayServer stands in for gateway.discord.gg:443 so the connect
|
|
// test has a real target. We don't actually speak TLS — the client's
|
|
// CONNECT only reads the 10-byte SOCKS5 reply, so as long as we send
|
|
// REP=00 the test passes. proxyConfig points DiscordGateway at this addr.
|
|
//
|
|
// We piggy-back on a TCP listener that does nothing.
|
|
func stubGatewayAddr(t *testing.T) string {
|
|
t.Helper()
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() { _ = ln.Close() })
|
|
go func() {
|
|
for {
|
|
conn, err := ln.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Just keep open; the splice will read/write nothing
|
|
// useful (the SOCKS5 reply is fake REP=00 from the
|
|
// proxy itself, not from us — see fakeProxy.handle).
|
|
go func(c net.Conn) {
|
|
defer c.Close()
|
|
_, _ = io.Copy(io.Discard, c)
|
|
}(conn)
|
|
}
|
|
}()
|
|
return ln.Addr().String()
|
|
}
|
|
|
|
func TestRun_HappyNoAuth(t *testing.T) {
|
|
fp := newFakeProxy(t, "happy_no_auth")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
|
cfg.StunServer = "127.0.0.1:1" // unused: we patch via direct relay; see below
|
|
|
|
// We don't actually need DNS — runStun does net.LookupIP("ip4", host).
|
|
// Use a literal IP so the resolver returns it.
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
expected := []string{"tcp", "greet", "connect", "udp", "api"}
|
|
finals := map[string]Result{}
|
|
for _, id := range expected {
|
|
r, ok := finalByID(results, id)
|
|
require.True(t, ok, "missing final result for %q in %+v", id, results)
|
|
finals[id] = r
|
|
}
|
|
for _, id := range expected {
|
|
assert.Equal(t, StatusPassed, finals[id].Status, "test %s should pass; got %+v", id, finals[id])
|
|
}
|
|
|
|
// auth must not appear (UseAuth=false).
|
|
for _, r := range results {
|
|
assert.NotEqual(t, "auth", r.ID, "auth must not be emitted when UseAuth=false")
|
|
}
|
|
|
|
// Metrics format spot-checks.
|
|
assert.Contains(t, finals["greet"].Metric, "no auth")
|
|
assert.Equal(t, "REP=00", finals["connect"].Metric)
|
|
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
|
}
|
|
|
|
func TestRun_HappyWithAuth(t *testing.T) {
|
|
fp := newFakeProxy(t, "happy_with_auth")
|
|
cfg := proxyConfig(fp, true)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
expected := []string{"tcp", "greet", "auth", "connect", "udp", "api"}
|
|
for _, id := range expected {
|
|
r, ok := finalByID(results, id)
|
|
require.True(t, ok, "missing %s; results=%+v", id, results)
|
|
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
|
}
|
|
r, _ := finalByID(results, "auth")
|
|
assert.Equal(t, "ok", r.Metric)
|
|
}
|
|
|
|
func TestRun_AuthRejected(t *testing.T) {
|
|
fp := newFakeProxy(t, "auth_rejected")
|
|
cfg := proxyConfig(fp, true)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
// tcp + greet pass, auth fails.
|
|
rTCP, _ := finalByID(results, "tcp")
|
|
assert.Equal(t, StatusPassed, rTCP.Status)
|
|
rG, _ := finalByID(results, "greet")
|
|
assert.Equal(t, StatusPassed, rG.Status)
|
|
|
|
rA, ok := finalByID(results, "auth")
|
|
require.True(t, ok)
|
|
assert.Equal(t, StatusFailed, rA.Status)
|
|
assert.NotEmpty(t, rA.Hint)
|
|
|
|
for _, id := range []string{"connect", "udp", "api"} {
|
|
r, ok := finalByID(results, id)
|
|
require.True(t, ok, "missing %s", id)
|
|
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
|
}
|
|
}
|
|
|
|
func TestRun_AllMethodsRejected(t *testing.T) {
|
|
fp := newFakeProxy(t, "all_methods_rejected")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
rTCP, _ := finalByID(results, "tcp")
|
|
assert.Equal(t, StatusPassed, rTCP.Status)
|
|
|
|
rG, ok := finalByID(results, "greet")
|
|
require.True(t, ok)
|
|
assert.Equal(t, StatusFailed, rG.Status)
|
|
assert.NotEmpty(t, rG.Hint)
|
|
|
|
for _, id := range []string{"connect", "udp", "api"} {
|
|
r, ok := finalByID(results, id)
|
|
require.True(t, ok, "missing %s", id)
|
|
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
|
}
|
|
}
|
|
|
|
func TestRun_ConnectRefused(t *testing.T) {
|
|
fp := newFakeProxy(t, "connect_refused")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = "http://127.0.0.1:1/api/v9/gateway"
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
rT, _ := finalByID(results, "tcp")
|
|
assert.Equal(t, StatusPassed, rT.Status)
|
|
rG, _ := finalByID(results, "greet")
|
|
assert.Equal(t, StatusPassed, rG.Status)
|
|
|
|
rC, ok := finalByID(results, "connect")
|
|
require.True(t, ok)
|
|
assert.Equal(t, StatusFailed, rC.Status)
|
|
assert.NotEmpty(t, rC.Hint)
|
|
assert.NotEmpty(t, rC.RawHex)
|
|
|
|
// udp goes through a SECOND conn → unaffected; should pass.
|
|
rU, _ := finalByID(results, "udp")
|
|
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
|
|
|
|
// api depends on connect → skipped.
|
|
rA, _ := finalByID(results, "api")
|
|
assert.Equal(t, StatusSkipped, rA.Status)
|
|
}
|
|
|
|
func TestRun_UDPUnsupported(t *testing.T) {
|
|
fp := newFakeProxy(t, "udp_unsupported")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
for _, id := range []string{"tcp", "greet", "connect"} {
|
|
r, _ := finalByID(results, id)
|
|
assert.Equal(t, StatusPassed, r.Status, "id=%s", id)
|
|
}
|
|
|
|
rU, _ := finalByID(results, "udp")
|
|
require.Equal(t, StatusFailed, rU.Status)
|
|
assert.NotEmpty(t, rU.Hint)
|
|
|
|
rA, _ := finalByID(results, "api")
|
|
assert.Equal(t, StatusPassed, rA.Status)
|
|
}
|
|
|
|
func TestRun_TimeoutThenOK(t *testing.T) {
|
|
fp := newFakeProxy(t, "timeout_then_ok")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 401)
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
cfg.PerTestTimeout = 200 * time.Millisecond
|
|
cfg.RetryBackoff = 20 * time.Millisecond
|
|
cfg.MaxRetries = 1
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 15*time.Second)
|
|
|
|
// Find the greet results.
|
|
var greetEvents []Result
|
|
for _, r := range results {
|
|
if r.ID == "greet" {
|
|
greetEvents = append(greetEvents, r)
|
|
}
|
|
}
|
|
// Expect: running(1), failed(1), running(2), passed(2). 4 events.
|
|
require.Len(t, greetEvents, 4, "events=%+v all=%+v", greetEvents, results)
|
|
assert.Equal(t, StatusRunning, greetEvents[0].Status)
|
|
assert.Equal(t, 1, greetEvents[0].Attempt)
|
|
assert.Equal(t, StatusFailed, greetEvents[1].Status)
|
|
assert.Equal(t, 1, greetEvents[1].Attempt)
|
|
assert.Equal(t, StatusRunning, greetEvents[2].Status)
|
|
assert.Equal(t, 2, greetEvents[2].Attempt)
|
|
assert.Equal(t, StatusPassed, greetEvents[3].Status)
|
|
assert.Equal(t, 2, greetEvents[3].Attempt)
|
|
|
|
// All non-auth tests should ultimately pass.
|
|
for _, id := range []string{"tcp", "greet", "connect", "udp", "api"} {
|
|
r, ok := finalByID(results, id)
|
|
require.True(t, ok, "missing %s", id)
|
|
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r)
|
|
}
|
|
|
|
// API should report 401.
|
|
rA, _ := finalByID(results, "api")
|
|
assert.Equal(t, "HTTP 401", rA.Metric)
|
|
}
|
|
|
|
func TestRun_CancelledMidFlight(t *testing.T) {
|
|
fp := newFakeProxy(t, "happy_no_auth")
|
|
cfg := proxyConfig(fp, false)
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
ch := Run(ctx, cfg)
|
|
|
|
var (
|
|
results []Result
|
|
mu sync.Mutex
|
|
)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
defer close(done)
|
|
for r := range ch {
|
|
mu.Lock()
|
|
results = append(results, r)
|
|
mu.Unlock()
|
|
// Cancel as soon as we see tcp pass.
|
|
if r.ID == "tcp" && r.Status == StatusPassed {
|
|
cancel()
|
|
}
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(15 * time.Second):
|
|
t.Fatal("timed out waiting for cancelled run to finish")
|
|
}
|
|
|
|
// At least one Failed/Skipped after tcp Pass.
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
|
|
var failed, skipped int
|
|
for _, r := range results {
|
|
switch r.Status {
|
|
case StatusFailed:
|
|
if r.Error == "cancelled" {
|
|
failed++
|
|
}
|
|
case StatusSkipped:
|
|
if r.Error == "cancelled" {
|
|
skipped++
|
|
}
|
|
}
|
|
}
|
|
// Either: one cancelled-failed + rest cancelled-skipped, OR all
|
|
// cancelled-skipped (if cancellation hit before next test even
|
|
// started). Both are acceptable.
|
|
// Without auth, 5 tests remain after tcp (greet/connect/udp/
|
|
// voice-quality/api). Cancel may race with greet
|
|
// completing successfully, so accept ≥3.
|
|
assert.GreaterOrEqual(t, failed+skipped, 3, "expected at least 3 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
|
|
}
|
|
|
|
func TestRun_AppliesDefaults(t *testing.T) {
|
|
// Use a Config{} with only ProxyHost/Port populated; everything
|
|
// else should fall back to spec defaults.
|
|
fp := newFakeProxy(t, "happy_no_auth")
|
|
host, port := hostPort(fp.addr)
|
|
cfg := Config{
|
|
ProxyHost: host,
|
|
ProxyPort: port,
|
|
}
|
|
|
|
// Verify applyDefaults produces expected values.
|
|
out := applyDefaults(cfg)
|
|
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
|
assert.Equal(t, 1, out.MaxRetries)
|
|
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
|
assert.Equal(t, "gateway.discord.gg:443", out.DiscordGateway)
|
|
assert.Equal(t, "https://discord.com/api/v9/gateway", out.DiscordAPI)
|
|
assert.Equal(t, "stun.l.google.com:19302", out.StunServer)
|
|
|
|
// Behavioral: passing a zero Config to Run should not panic and
|
|
// should at minimum emit a TCP result. We override defaults to
|
|
// shorter values so the test isn't slow when the public Discord
|
|
// targets are unreachable.
|
|
cfg.PerTestTimeout = 200 * time.Millisecond
|
|
cfg.RetryBackoff = 20 * time.Millisecond
|
|
cfg.DiscordGateway = stubGatewayAddr(t)
|
|
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
|
cfg.StunServer = "127.0.0.1:65000"
|
|
|
|
ch := Run(context.Background(), cfg)
|
|
results := drainResults(t, ch, 10*time.Second)
|
|
|
|
rT, ok := finalByID(results, "tcp")
|
|
require.True(t, ok)
|
|
assert.Equal(t, StatusPassed, rT.Status)
|
|
}
|
|
|
|
func TestRun_NegativeRetryClamped(t *testing.T) {
|
|
cfg := Config{MaxRetries: -5, RetryBackoff: -1 * time.Second, PerTestTimeout: -1 * time.Second}
|
|
out := applyDefaults(cfg)
|
|
// Spec: MaxRetries < 0 → 0. But our default for "not set" is 1.
|
|
// We treat <0 as 0, then bump 0→1 (default for zero).
|
|
// Either 0 or 1 is acceptable per spec wording; we settled on 1.
|
|
assert.True(t, out.MaxRetries == 0 || out.MaxRetries == 1)
|
|
assert.Equal(t, 5*time.Second, out.PerTestTimeout)
|
|
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
|
}
|
|
|
|
func TestExtractRawHex(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"socks5: bad version (raw=05ff)", "05ff"},
|
|
{"socks5: bad version (raw=DEADBEEF)", "DEADBEEF"},
|
|
{"no raw here", ""},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
assert.Equal(t, c.want, extractRawHex(c.in), "input=%q", c.in)
|
|
}
|
|
}
|