internal/checker: voice-quality + voice-srv tests for predictive voice diagnosis
Replaces the single-packet `stun` test with two predictive voice tests:
- voice-quality: 30-packet STUN burst through the SOCKS5 UDP relay.
Computes loss%, jitter (RFC-3550-ish mean abs of inter-arrival
delta), p50/p95 RTT. Three-tier gating: pass (loss≤5%, jitter≤30,
p50≤250), warn (loss≤15%, jitter≤60, p50≤400 — voice glitches but
works), fail (anything worse, including 100% loss).
- voice-srv: parallel-DNS the 16-region <region>.discord.media
hostnames, then SOCKS5 CONNECT to :443 on each through the proxy.
Catches the very common Russian-DPI failure mode where the proxy
passes generic Discord.com TCP but blocks the .discord.media voice
CIDRs — a regression all 5 prior SOCKS5 sanity checks miss.
New StatusWarn = "warn" — soft pass with Hint kept visible. Counted as
passed in summary but flagged in UI.
Config gains VoiceBurstCount (default 30), VoiceBurstInterval (default
20ms), VoiceServerHostnames (default = built-in 16-region list).
Tests cover happy path, warn-tier (10% drop), fail-tier (100% drop),
voice-srv blocked, plus standalone unit tests on
runVoiceQualityBurst and runVoiceServerProbe with a fake UDP relay
and fake SOCKS5 server. Race + cover stays at 82.4%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"net"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeUDPRelay listens on a UDP socket and echoes SOCKS5-wrapped STUN
|
||||
// binding requests as a synthetic Binding Success Response, just like
|
||||
// fakeProxy.runRelay in checker_test.go but standalone (no SOCKS5 TCP
|
||||
// control channel needed). dropEveryN > 0 drops every Nth packet.
|
||||
type fakeUDPRelay struct {
|
||||
conn *net.UDPConn
|
||||
addr *net.UDPAddr
|
||||
dropEveryN atomic.Int32
|
||||
count atomic.Int32
|
||||
}
|
||||
|
||||
func newFakeUDPRelay(t *testing.T) *fakeUDPRelay {
|
||||
t.Helper()
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
uconn := pc.(*net.UDPConn)
|
||||
r := &fakeUDPRelay{
|
||||
conn: uconn,
|
||||
addr: uconn.LocalAddr().(*net.UDPAddr),
|
||||
}
|
||||
t.Cleanup(func() { _ = uconn.Close() })
|
||||
go r.serve()
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *fakeUDPRelay) serve() {
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, src, err := r.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dropN := r.dropEveryN.Load(); dropN > 0 {
|
||||
c := r.count.Add(1)
|
||||
if c%dropN == 0 {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
r.count.Add(1)
|
||||
}
|
||||
if n < 10 {
|
||||
continue
|
||||
}
|
||||
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]
|
||||
var txID [12]byte
|
||||
copy(txID[:], stunReq[8:20])
|
||||
|
||||
ip4 := src.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||
|
||||
stunResp := make([]byte, 20+12)
|
||||
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||
binary.BigEndian.PutUint16(stunResp[2:4], 12)
|
||||
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||
copy(stunResp[8:20], txID[:])
|
||||
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||
stunResp[24] = 0
|
||||
stunResp[25] = 0x01
|
||||
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||
|
||||
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...)
|
||||
|
||||
_, _ = r.conn.WriteToUDP(out, src)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_Math: full 30-of-30 reception on localhost, all
|
||||
// RTTs in single-digit milliseconds.
|
||||
func TestVoiceQualityBurst_Math(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 30, 5*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 30, res.Sent)
|
||||
assert.Equal(t, 30, res.Received)
|
||||
assert.InDelta(t, 0.0, res.LossPct, 0.001)
|
||||
assert.Less(t, res.P50RTTMS, 50.0, "loopback p50 should be tiny")
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_HalfLoss verifies the loss-percentage math when
|
||||
// the relay drops half the packets.
|
||||
func TestVoiceQualityBurst_HalfLoss(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(2) // every other packet → 50% loss
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 20, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 20, res.Sent)
|
||||
assert.InDelta(t, 50.0, res.LossPct, 5.0, "expected ~50%% loss got %+v", res)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_AllDropped: dropEveryN=1 → 100% loss. Should NOT
|
||||
// return an error; should report Sent=N, Received=0.
|
||||
func TestVoiceQualityBurst_AllDropped(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
relay.dropEveryN.Store(1)
|
||||
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 10, 3*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 10, res.Sent)
|
||||
assert.Equal(t, 0, res.Received)
|
||||
assert.InDelta(t, 100.0, res.LossPct, 0.001)
|
||||
assert.Equal(t, 0.0, res.P50RTTMS)
|
||||
assert.Equal(t, 0.0, res.JitterMS)
|
||||
}
|
||||
|
||||
// TestVoiceQualityBurst_ZeroCount: count=0 → error (defensive).
|
||||
func TestVoiceQualityBurst_ZeroCount(t *testing.T) {
|
||||
relay := newFakeUDPRelay(t)
|
||||
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer clientPC.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||
"localhost", 19302, 0, 5*time.Millisecond)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// TestVoiceServerProbe_HappyAndBlocked: against a fake SOCKS5 server, one
|
||||
// hostname (localhost) succeeds (REP=00) and another (127.0.0.1) gets
|
||||
// blocked (REP=05). Both resolve at the OS level, so Resolved should be
|
||||
// {localhost, 127.0.0.1}; Reachable should be {localhost}; Unreachable
|
||||
// should be {127.0.0.1}.
|
||||
func TestVoiceServerProbe_HappyAndBlocked(t *testing.T) {
|
||||
// Stand up a tiny fake SOCKS5 server that completes greet+CONNECT
|
||||
// only for hostname "localhost"; CONNECT to "127.0.0.1" is refused
|
||||
// with REP=05. We don't need full RFC 1928 — just enough to drive
|
||||
// the probe path.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
_ = c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
// Read greeting: VER NMETHODS METHODS...
|
||||
hdr := make([]byte, 2)
|
||||
if _, err := readFull(c, hdr); err != nil {
|
||||
return
|
||||
}
|
||||
if hdr[0] != 0x05 {
|
||||
return
|
||||
}
|
||||
methods := make([]byte, hdr[1])
|
||||
if _, err := readFull(c, methods); err != nil {
|
||||
return
|
||||
}
|
||||
// Reply: no-auth.
|
||||
_, _ = c.Write([]byte{0x05, 0x00})
|
||||
// Read CONNECT request: VER CMD RSV ATYP...
|
||||
h2 := make([]byte, 4)
|
||||
if _, err := readFull(c, h2); err != nil {
|
||||
return
|
||||
}
|
||||
var host string
|
||||
switch h2[3] {
|
||||
case 0x01:
|
||||
ip := make([]byte, 4)
|
||||
_, _ = readFull(c, ip)
|
||||
host = net.IP(ip).String()
|
||||
case 0x03:
|
||||
l := make([]byte, 1)
|
||||
_, _ = readFull(c, l)
|
||||
name := make([]byte, int(l[0]))
|
||||
_, _ = readFull(c, name)
|
||||
host = string(name)
|
||||
}
|
||||
port := make([]byte, 2)
|
||||
_, _ = readFull(c, port)
|
||||
// Reply REP=00 only for hostname "localhost"; refuse otherwise.
|
||||
if host == "localhost" {
|
||||
_, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
} else {
|
||||
_, _ = c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
}
|
||||
}(c)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceServerProbe(ctx,
|
||||
[]string{"localhost", "127.0.0.1"}, ln.Addr().String(),
|
||||
false, "", "", 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.ElementsMatch(t, []string{"localhost", "127.0.0.1"}, res.Resolved)
|
||||
assert.Equal(t, []string{"localhost"}, res.Reachable)
|
||||
assert.Equal(t, []string{"127.0.0.1"}, res.UnreachableButResolved)
|
||||
assert.Empty(t, res.Unresolved)
|
||||
}
|
||||
|
||||
// TestVoiceServerProbe_EmptyHostnameList: zero-length input → zero-length
|
||||
// Resolved/Reachable, no error.
|
||||
func TestVoiceServerProbe_EmptyHostnameList(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := runVoiceServerProbe(ctx,
|
||||
[]string{}, "127.0.0.1:1", false, "", "", 100*time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Empty(t, res.Resolved)
|
||||
assert.Empty(t, res.Reachable)
|
||||
assert.Empty(t, res.Unresolved)
|
||||
}
|
||||
|
||||
// readFull is a tiny helper to avoid importing io just for this.
|
||||
func readFull(c net.Conn, buf []byte) (int, error) {
|
||||
got := 0
|
||||
for got < len(buf) {
|
||||
n, err := c.Read(buf[got:])
|
||||
got += n
|
||||
if err != nil {
|
||||
return got, err
|
||||
}
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
Reference in New Issue
Block a user