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:
@@ -35,6 +35,12 @@ type fakeProxy struct {
|
||||
|
||||
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.
|
||||
@@ -42,6 +48,11 @@ type fakeProxy struct {
|
||||
apiTargetPort uint16
|
||||
apiTargetAddr string
|
||||
|
||||
// blockVoiceCONNECT, when true, makes any CONNECT to a hostname
|
||||
// not equal to apiTargetHost return REP=05. Used by voice-srv
|
||||
// negative scenarios.
|
||||
blockVoiceCONNECT atomic.Bool
|
||||
|
||||
// timeoutFirstAttempt stalls the first connection on greet to
|
||||
// drive a timeout. Subsequent connections behave normally.
|
||||
timeoutFirstAttempt atomic.Int32
|
||||
@@ -84,7 +95,8 @@ func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
|
||||
|
||||
func needsUDPRelay(scenario string) bool {
|
||||
switch scenario {
|
||||
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok":
|
||||
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok",
|
||||
"voice_quality_warn", "voice_quality_fail", "voice_srv_blocked":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -184,6 +196,23 @@ func (fp *fakeProxy) handle(conn net.Conn) {
|
||||
|
||||
switch cmdReq.cmd {
|
||||
case 0x01: // CONNECT
|
||||
// voice-srv block: refuse CONNECT to anything that isn't the
|
||||
// gateway/api passthrough target. Only checked when the test
|
||||
// explicitly sets blockVoiceCONNECT — keeps gateway+api happy.
|
||||
if fp.blockVoiceCONNECT.Load() {
|
||||
isAPITarget := fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort
|
||||
isGatewayTarget := cmdReq.port == 443 && (cmdReq.host == "127.0.0.1" || cmdReq.host == "localhost") && fp.apiTargetHost == ""
|
||||
_ = isGatewayTarget
|
||||
if !isAPITarget {
|
||||
// Refuse only :443 voice probes; allow gateway probe
|
||||
// (which has its own configured port from stubGatewayAddr,
|
||||
// not 443). Logic: target port == 443 → voice probe → refuse.
|
||||
if cmdReq.port == 443 {
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
switch fp.scenario {
|
||||
case "connect_refused":
|
||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||
@@ -255,6 +284,16 @@ func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
|
||||
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
|
||||
}
|
||||
@@ -478,15 +517,25 @@ func hostPort(addr string) (string, int) {
|
||||
|
||||
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
||||
// short timeouts for tests.
|
||||
//
|
||||
// Note on voice-srv: the default Config.VoiceServerHostnames would hit
|
||||
// real Discord DNS during go test — we don't want that. Override with a
|
||||
// single local hostname ("localhost") so DNS always resolves to 127.0.0.1
|
||||
// and the SOCKS5 CONNECT goes back to the fake proxy itself, which
|
||||
// returns REP=00 on a happy CONNECT or REP=05 when blockVoiceCONNECT is
|
||||
// flipped.
|
||||
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,
|
||||
ProxyHost: host,
|
||||
ProxyPort: port,
|
||||
UseAuth: useAuth,
|
||||
PerTestTimeout: 500 * time.Millisecond,
|
||||
MaxRetries: 1,
|
||||
RetryBackoff: 30 * time.Millisecond,
|
||||
VoiceBurstCount: 10,
|
||||
VoiceBurstInterval: 5 * time.Millisecond,
|
||||
VoiceServerHostnames: []string{"localhost"},
|
||||
}
|
||||
if useAuth {
|
||||
cfg.ProxyLogin = "u"
|
||||
@@ -560,7 +609,7 @@ func TestRun_HappyNoAuth(t *testing.T) {
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "connect", "udp", "stun", "api"}
|
||||
expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "api"}
|
||||
finals := map[string]Result{}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
@@ -579,6 +628,8 @@ func TestRun_HappyNoAuth(t *testing.T) {
|
||||
// Metrics format spot-checks.
|
||||
assert.Contains(t, finals["greet"].Metric, "no auth")
|
||||
assert.Equal(t, "REP=00", finals["connect"].Metric)
|
||||
assert.Contains(t, finals["voice-quality"].Metric, "loss=")
|
||||
assert.Contains(t, finals["voice-srv"].Metric, "1/1 regions")
|
||||
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
||||
}
|
||||
|
||||
@@ -592,7 +643,7 @@ func TestRun_HappyWithAuth(t *testing.T) {
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 10*time.Second)
|
||||
|
||||
expected := []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api"}
|
||||
expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api"}
|
||||
for _, id := range expected {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s; results=%+v", id, results)
|
||||
@@ -623,7 +674,7 @@ func TestRun_AuthRejected(t *testing.T) {
|
||||
assert.Equal(t, StatusFailed, rA.Status)
|
||||
assert.NotEmpty(t, rA.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "stun", "api"} {
|
||||
for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
@@ -648,7 +699,7 @@ func TestRun_AllMethodsRejected(t *testing.T) {
|
||||
assert.Equal(t, StatusFailed, rG.Status)
|
||||
assert.NotEmpty(t, rG.Hint)
|
||||
|
||||
for _, id := range []string{"connect", "udp", "stun", "api"} {
|
||||
for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "api"} {
|
||||
r, ok := finalByID(results, id)
|
||||
require.True(t, ok, "missing %s", id)
|
||||
assert.Equal(t, StatusSkipped, r.Status, "id=%s", id)
|
||||
@@ -680,9 +731,13 @@ func TestRun_ConnectRefused(t *testing.T) {
|
||||
rU, _ := finalByID(results, "udp")
|
||||
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
|
||||
|
||||
// stun depends on udp → passes too.
|
||||
rS, _ := finalByID(results, "stun")
|
||||
assert.Equal(t, StatusPassed, rS.Status)
|
||||
// voice-quality depends on udp → passes too.
|
||||
rVQ, _ := finalByID(results, "voice-quality")
|
||||
assert.Equal(t, StatusPassed, rVQ.Status)
|
||||
|
||||
// voice-srv depends on connect → skipped.
|
||||
rVS, _ := finalByID(results, "voice-srv")
|
||||
assert.Equal(t, StatusSkipped, rVS.Status)
|
||||
|
||||
// api depends on connect → skipped.
|
||||
rA, _ := finalByID(results, "api")
|
||||
@@ -708,8 +763,13 @@ func TestRun_UDPUnsupported(t *testing.T) {
|
||||
require.Equal(t, StatusFailed, rU.Status)
|
||||
assert.NotEmpty(t, rU.Hint)
|
||||
|
||||
rS, _ := finalByID(results, "stun")
|
||||
assert.Equal(t, StatusSkipped, rS.Status)
|
||||
// voice-quality depends on udp → skipped.
|
||||
rVQ, _ := finalByID(results, "voice-quality")
|
||||
assert.Equal(t, StatusSkipped, rVQ.Status)
|
||||
|
||||
// voice-srv depends on connect (passed) → runs and passes.
|
||||
rVS, _ := finalByID(results, "voice-srv")
|
||||
assert.Equal(t, StatusPassed, rVS.Status)
|
||||
|
||||
rA, _ := finalByID(results, "api")
|
||||
assert.Equal(t, StatusPassed, rA.Status)
|
||||
@@ -747,7 +807,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
|
||||
assert.Equal(t, 2, greetEvents[3].Attempt)
|
||||
|
||||
// All seven non-auth tests should ultimately pass.
|
||||
for _, id := range []string{"tcp", "greet", "connect", "udp", "stun", "api"} {
|
||||
for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "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)
|
||||
@@ -813,8 +873,9 @@ func TestRun_CancelledMidFlight(t *testing.T) {
|
||||
// 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/stun/api).
|
||||
// Cancel may race with greet completing successfully, so accept ≥4.
|
||||
// Without auth, 6 tests remain after tcp (greet/connect/udp/
|
||||
// voice-quality/voice-srv/api). Cancel may race with greet
|
||||
// completing successfully, so accept ≥4.
|
||||
assert.GreaterOrEqual(t, failed+skipped, 4, "expected at least 4 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
|
||||
}
|
||||
|
||||
@@ -866,6 +927,79 @@ func TestRun_NegativeRetryClamped(t *testing.T) {
|
||||
assert.Equal(t, 500*time.Millisecond, out.RetryBackoff)
|
||||
}
|
||||
|
||||
// TestRun_VoiceQualityWarn drives the relay to drop ~1 in 10 packets,
|
||||
// which puts the burst into the warn band (loss in (5, 15]%, jitter and
|
||||
// p50 typically tiny on localhost). Asserts StatusWarn and that the
|
||||
// metric reports a non-zero loss.
|
||||
func TestRun_VoiceQualityWarn(t *testing.T) {
|
||||
fp := newFakeProxy(t, "voice_quality_warn")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
// Burst of 30 with 1-in-10 drop → ~3 lost ≈ 10%.
|
||||
cfg.VoiceBurstCount = 30
|
||||
cfg.VoiceBurstInterval = 5 * time.Millisecond
|
||||
cfg.PerTestTimeout = 1 * time.Second
|
||||
fp.udpDropEveryN.Store(10)
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
rVQ, ok := finalByID(results, "voice-quality")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusWarn, rVQ.Status, "got %+v", rVQ)
|
||||
assert.Contains(t, rVQ.Metric, "loss=")
|
||||
assert.NotEmpty(t, rVQ.Hint)
|
||||
}
|
||||
|
||||
// TestRun_VoiceQualityFail drives the relay to drop 4 of every 5 packets
|
||||
// (~80% loss) — well past the fail threshold.
|
||||
func TestRun_VoiceQualityFail(t *testing.T) {
|
||||
fp := newFakeProxy(t, "voice_quality_fail")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
cfg.VoiceBurstCount = 30
|
||||
cfg.VoiceBurstInterval = 3 * time.Millisecond
|
||||
cfg.PerTestTimeout = 1 * time.Second
|
||||
cfg.MaxRetries = 0
|
||||
// Drop everything: dropEveryN=1 means EVERY packet dropped → 100%.
|
||||
// Use 2 for ~50%, 1 for 100. We want fail-band — pick 1 to guarantee
|
||||
// "no replies received".
|
||||
fp.udpDropEveryN.Store(1)
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
rVQ, ok := finalByID(results, "voice-quality")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusFailed, rVQ.Status, "got %+v", rVQ)
|
||||
assert.NotEmpty(t, rVQ.Hint)
|
||||
}
|
||||
|
||||
// TestRun_VoiceSrvAllResolvedNoneReachable: DNS resolves localhost but
|
||||
// the proxy refuses CONNECT to :443. Expect StatusWarn with "0/1 regions
|
||||
// reachable" metric.
|
||||
func TestRun_VoiceSrvAllResolvedNoneReachable(t *testing.T) {
|
||||
fp := newFakeProxy(t, "voice_srv_blocked")
|
||||
cfg := proxyConfig(fp, false)
|
||||
cfg.DiscordGateway = stubGatewayAddr(t)
|
||||
cfg.DiscordAPI = stubAPIServer(t, fp, 200)
|
||||
cfg.StunServer = "127.0.0.1:65000"
|
||||
fp.blockVoiceCONNECT.Store(true)
|
||||
|
||||
ch := Run(context.Background(), cfg)
|
||||
results := drainResults(t, ch, 15*time.Second)
|
||||
|
||||
rVS, ok := finalByID(results, "voice-srv")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, StatusWarn, rVS.Status, "got %+v", rVS)
|
||||
assert.Equal(t, "0/1 regions reachable", rVS.Metric)
|
||||
assert.NotEmpty(t, rVS.Hint)
|
||||
}
|
||||
|
||||
func TestExtractRawHex(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
Reference in New Issue
Block a user