internal/checker: voice-quality + voice-srv tests for predictive voice diagnosis
Build / test (push) Has been cancelled
Build / build-windows (push) Has been cancelled

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:
2026-05-01 18:42:12 +03:00
parent ea4202d4a3
commit 0a85979142
6 changed files with 1264 additions and 137 deletions
+269 -81
View File
@@ -3,13 +3,12 @@ package checker
import ( import (
"context" "context"
"crypto/tls" "crypto/tls"
"encoding/binary"
"errors"
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"regexp" "regexp"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -22,6 +21,12 @@ const (
StatusPassed Status = "passed" StatusPassed Status = "passed"
StatusFailed Status = "failed" StatusFailed Status = "failed"
StatusSkipped Status = "skipped" StatusSkipped Status = "skipped"
// StatusWarn is a "soft pass" — the test technically succeeded but
// the user should know about a degradation (e.g. voice quality at the
// upper end of acceptable, or all Discord voice domains resolve but
// the proxy filters their TCP). Frontend renders it like StatusPassed
// but keeps the Hint visible.
StatusWarn Status = "warn"
) )
// Result is one event in the diagnostic stream. Multiple Results may be // Result is one event in the diagnostic stream. Multiple Results may be
@@ -53,6 +58,18 @@ type Config struct {
DiscordGateway string DiscordGateway string
DiscordAPI string DiscordAPI string
StunServer string StunServer string
// Voice-quality burst tuning (see runVoiceQuality). Defaults: 30
// packets, 20ms between sends.
VoiceBurstCount int
VoiceBurstInterval time.Duration
// VoiceServerHostnames is the list of Discord voice-domain hostnames
// probed in the voice-srv test. Empty means "use the built-in 16-region
// default" (russia, russia2, frankfurt, europe, singapore, japan,
// us-east, us-west, brazil, india, hongkong, southkorea, sydney,
// southafrica, dubai, atlanta — all under .discord.media).
VoiceServerHostnames []string
} }
// applyDefaults returns a copy of cfg with zero-valued knobs filled in. // applyDefaults returns a copy of cfg with zero-valued knobs filled in.
@@ -84,6 +101,15 @@ func applyDefaults(cfg Config) Config {
if cfg.StunServer == "" { if cfg.StunServer == "" {
cfg.StunServer = "stun.l.google.com:19302" cfg.StunServer = "stun.l.google.com:19302"
} }
if cfg.VoiceBurstCount <= 0 {
cfg.VoiceBurstCount = 30
}
if cfg.VoiceBurstInterval <= 0 {
cfg.VoiceBurstInterval = 20 * time.Millisecond
}
if len(cfg.VoiceServerHostnames) == 0 {
cfg.VoiceServerHostnames = defaultVoiceHostnames
}
return cfg return cfg
} }
@@ -108,7 +134,8 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
} }
e.runConnect() e.runConnect()
e.runUDP() e.runUDP()
e.runStun() e.runVoiceQuality()
e.runVoiceSrv()
e.runAPI() e.runAPI()
}() }()
@@ -137,8 +164,9 @@ type executor struct {
// udpClient is our local UDP socket used to talk to the relay. // udpClient is our local UDP socket used to talk to the relay.
udpClient net.PacketConn udpClient net.PacketConn
// Step gating: each xOK is set true on success. // Step gating: each xOK is set true on success (or "soft pass"
tcpOK, greetOK, authOK, connectOK, udpOK bool // warn for voice-quality / voice-srv).
tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool
// Cancellation latch. Once any test emits a "cancelled" failure, // Cancellation latch. Once any test emits a "cancelled" failure,
// remaining tests emit a single Skipped result with the same reason. // remaining tests emit a single Skipped result with the same reason.
@@ -542,19 +570,28 @@ func (e *executor) runUDP() {
e.udpOK = ok e.udpOK = ok
} }
// runStun — Test 6: STUN through the SOCKS5 UDP relay. // runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
func (e *executor) runStun() { // relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
if e.shouldSkip("stun", e.udpOK) { //
// - StatusPassed: loss ≤ 5%, jitter ≤ 30ms, p50 ≤ 250ms.
// - StatusWarn: loss ≤ 15%, jitter ≤ 60ms, p50 ≤ 400ms — voice will
// work but with audible glitches.
// - StatusFailed: anything worse, OR no replies at all.
//
// On warn/pass, voiceQualityOK is true (downstream tests proceed). On
// failure it stays false.
func (e *executor) runVoiceQuality() {
if e.shouldSkip("voice-quality", e.udpOK) {
return return
} }
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer) host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
if splitErr != nil { if splitErr != nil {
e.emit(Result{ e.emit(Result{
ID: "stun", ID: "voice-quality",
Status: StatusFailed, Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()), Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
Hint: hintFor("stun", splitErr), Hint: hintFor("voice-quality", splitErr),
Attempt: 1, Attempt: 1,
}) })
return return
@@ -562,105 +599,256 @@ func (e *executor) runStun() {
port64, perr := strconv.ParseUint(portStr, 10, 16) port64, perr := strconv.ParseUint(portStr, 10, 16)
if perr != nil { if perr != nil {
e.emit(Result{ e.emit(Result{
ID: "stun", ID: "voice-quality",
Status: StatusFailed, Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()), Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
Hint: hintFor("stun", perr), Hint: hintFor("voice-quality", perr),
Attempt: 1, Attempt: 1,
}) })
return return
} }
stunPort := uint16(port64) stunPort := uint16(port64)
e.runAttempt("stun", func(ctx context.Context) (string, error) { maxAttempts := 1 + e.cfg.MaxRetries
// Resolve STUN host to an IPv4. We don't support IPv6 STUN. for attempt := 1; attempt <= maxAttempts; attempt++ {
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip4", host) if err := e.ctx.Err(); err != nil {
if err != nil { e.emitCancelled("voice-quality", attempt, 0)
return "", fmt.Errorf("stun: lookup %s: %w", host, err) return
}
var stunIP4 net.IP
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
stunIP4 = v4
break
}
}
if stunIP4 == nil {
return "", errors.New("stun: no IPv4 for STUN server")
} }
e.emit(Result{ID: "voice-quality", Status: StatusRunning, Attempt: attempt})
// Per-test budget: cap burst+listen at PerTestTimeout.
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
start := time.Now()
// Open a fresh local UDP socket per attempt. // Open a fresh local UDP socket per attempt.
if e.udpClient != nil { if e.udpClient != nil {
_ = e.udpClient.Close() _ = e.udpClient.Close()
e.udpClient = nil e.udpClient = nil
} }
pc, err := net.ListenPacket("udp", ":0") pc, perr := net.ListenPacket("udp", ":0")
if err != nil { if perr != nil {
return "", fmt.Errorf("stun: listen udp: %w", err) cancel()
dur := time.Since(start)
class := classifyError(perr)
canRetry := class == ClassificationTransient && attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("voice-quality: listen udp: %s", perr.Error()),
Hint: hintFor("voice-quality", perr),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
} }
e.udpClient = pc e.udpClient = pc
if dl, ok := ctx.Deadline(); ok {
_ = pc.SetDeadline(dl) res, berr := runVoiceQualityBurst(
attemptCtx, pc, e.udpRelay,
host, stunPort,
e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval,
)
dur := time.Since(start)
cancel()
if berr != nil {
// Resolution / cancellation. Treat ctx-cancel separately.
if e.ctx.Err() != nil {
e.emitCancelled("voice-quality", attempt, dur)
return
}
class := classifyError(berr)
canRetry := class == ClassificationTransient && attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: berr.Error(),
Hint: hintFor("voice-quality", berr),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
} }
// Build SOCKS5 UDP datagram: RSV(2)=0 FRAG=0 ATYP=01 IP(4) PORT(2) STUN(20) // 100% loss with no underlying error → the relay accepted UDP
txID, err := NewTransactionID() // (per test 5) but nothing came back. Treat as transient on
if err != nil { // the first attempt; permanent on the second.
return "", err if res.Received == 0 {
canRetry := attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: "no replies received",
Hint: voiceQualityFailHint(100.0, 0, 0, 0),
Metric: "loss=100%",
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
} }
stunReq := EncodeBindingRequest(txID) }
dgram := make([]byte, 0, 10+len(stunReq)) return
dgram = append(dgram, 0x00, 0x00, 0x00, 0x01)
dgram = append(dgram, stunIP4...)
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], stunPort)
dgram = append(dgram, portBuf[:]...)
dgram = append(dgram, stunReq...)
start := time.Now()
if _, werr := pc.WriteTo(dgram, e.udpRelay); werr != nil {
return "", fmt.Errorf("stun: write to relay: %w", werr)
} }
readBuf := make([]byte, 1500) metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
n, _, rerr := pc.ReadFrom(readBuf) res.LossPct, res.JitterMS, res.P50RTTMS)
if rerr != nil {
return "", fmt.Errorf("stun: read from relay: %w", rerr)
}
rtt := time.Since(start)
if n < 10 { switch {
return "", fmt.Errorf("stun: relay reply too short (%d bytes)", n) case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
} e.emit(Result{
// Validate SOCKS5 UDP wrapper: RSV=00 00, FRAG=00, ATYP=01. ID: "voice-quality",
if readBuf[0] != 0x00 || readBuf[1] != 0x00 || readBuf[2] != 0x00 { Status: StatusPassed,
return "", fmt.Errorf("stun: bad SOCKS5 UDP header (raw=%x)", readBuf[:10]) Metric: metric,
} Attempt: attempt,
// We sent IPv4, expect IPv4 reply. Duration: dur,
var hdrLen int })
switch readBuf[3] { e.voiceQualityOK = true
case 0x01: return
hdrLen = 10 case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
case 0x04: e.emit(Result{
hdrLen = 22 ID: "voice-quality",
case 0x03: Status: StatusWarn,
if n < 5 { Metric: metric,
return "", fmt.Errorf("stun: truncated SOCKS5 UDP domain header") Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
} Attempt: attempt,
hdrLen = 4 + 1 + int(readBuf[4]) + 2 Duration: dur,
})
e.voiceQualityOK = true
return
default: default:
return "", fmt.Errorf("stun: unknown SOCKS5 UDP ATYP=0x%02X", readBuf[3]) canRetry := attempt < maxAttempts
e.emit(Result{
ID: "voice-quality",
Status: StatusFailed,
Error: metric,
Metric: metric,
Hint: voiceQualityFailHint(res.LossPct, res.JitterMS, res.P50RTTMS, res.P95RTTMS),
Attempt: attempt,
Duration: dur,
})
if canRetry {
select {
case <-time.After(e.cfg.RetryBackoff):
continue
case <-e.ctx.Done():
return
}
}
return
}
} }
if n < hdrLen {
return "", fmt.Errorf("stun: relay reply truncated (%d < %d)", n, hdrLen)
} }
stunReply := readBuf[hdrLen:n]
_, _, perr := ParseBindingResponse(stunReply, txID) // runVoiceSrv — Test 7: probe Discord voice-domain reachability through
if perr != nil { // the SOCKS5 proxy. Single attempt (DNS+connect bursts are slow and
return "", perr // idempotent — retry budget better spent on transient handshake steps).
func (e *executor) runVoiceSrv() {
if e.shouldSkip("voice-srv", e.connectOK) {
return
} }
return fmt.Sprintf("%dms RTT", rtt.Milliseconds()), nil
attempt := 1
e.emit(Result{ID: "voice-srv", Status: StatusRunning, Attempt: attempt})
attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout)
defer cancel()
start := time.Now()
// Per-host dial timeout — fits inside PerTestTimeout but isn't the
// whole budget (we run 8 in parallel, so total wall-clock is roughly
// ceil(N/8) * dialTimeout + DNS).
dialTimeout := e.cfg.PerTestTimeout / 4
if dialTimeout < 500*time.Millisecond {
dialTimeout = 500 * time.Millisecond
}
res, err := runVoiceServerProbe(
attemptCtx, e.cfg.VoiceServerHostnames, e.proxyAddr(),
e.cfg.UseAuth, e.cfg.ProxyLogin, e.cfg.ProxyPassword, dialTimeout,
)
dur := time.Since(start)
if err != nil {
if e.ctx.Err() != nil {
e.emitCancelled("voice-srv", attempt, dur)
return
}
e.emit(Result{
ID: "voice-srv",
Status: StatusFailed,
Error: err.Error(),
Hint: hintFor("voice-srv", err),
Attempt: attempt,
Duration: dur,
})
return
}
if len(res.Resolved) == 0 {
e.emit(Result{
ID: "voice-srv",
Status: StatusFailed,
Error: "no Discord voice domain resolved",
Hint: "DNS не возвращает A-записи для *.discord.media — проверь системный DNS.",
Attempt: attempt,
Duration: dur,
})
return
}
if len(res.Reachable) == 0 {
e.emit(Result{
ID: "voice-srv",
Status: StatusWarn,
Metric: fmt.Sprintf("0/%d regions reachable", len(res.Resolved)),
Hint: "Discord voice-домены резолвятся, но прокси не пропускает к ним TCP — голос с большой вероятностью не заработает.",
Attempt: attempt,
Duration: dur,
})
return
}
// Top-3 region prefixes for the metric line.
top := res.Reachable
if len(top) > 3 {
top = top[:3]
}
prefixes := make([]string, 0, len(top))
for _, h := range top {
prefix := h
if i := strings.Index(h, ".discord.media"); i > 0 {
prefix = h[:i]
}
prefixes = append(prefixes, prefix)
}
metric := fmt.Sprintf("%d/%d regions: %s", len(res.Reachable), len(res.Resolved), strings.Join(prefixes, ", "))
e.emit(Result{
ID: "voice-srv",
Status: StatusPassed,
Metric: metric,
Attempt: attempt,
Duration: dur,
}) })
} }
+147 -13
View File
@@ -35,6 +35,12 @@ type fakeProxy struct {
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply 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, // API-passthrough hook: when a CONNECT targets this host:port,
// the proxy dials apiTargetAddr and splices the conns instead of // the proxy dials apiTargetAddr and splices the conns instead of
// sending a fake REP=00 + close. // sending a fake REP=00 + close.
@@ -42,6 +48,11 @@ type fakeProxy struct {
apiTargetPort uint16 apiTargetPort uint16
apiTargetAddr string 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 // timeoutFirstAttempt stalls the first connection on greet to
// drive a timeout. Subsequent connections behave normally. // drive a timeout. Subsequent connections behave normally.
timeoutFirstAttempt atomic.Int32 timeoutFirstAttempt atomic.Int32
@@ -84,7 +95,8 @@ func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
func needsUDPRelay(scenario string) bool { func needsUDPRelay(scenario string) bool {
switch scenario { 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 return true
default: default:
return false return false
@@ -184,6 +196,23 @@ func (fp *fakeProxy) handle(conn net.Conn) {
switch cmdReq.cmd { switch cmdReq.cmd {
case 0x01: // CONNECT 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 { switch fp.scenario {
case "connect_refused": case "connect_refused":
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) _, _ = 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 { if err != nil {
return 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 { if n < 10 {
continue continue
} }
@@ -478,6 +517,13 @@ func hostPort(addr string) (string, int) {
// proxyConfig builds a Config pointed at the given fakeProxy with sane // proxyConfig builds a Config pointed at the given fakeProxy with sane
// short timeouts for tests. // 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 { func proxyConfig(fp *fakeProxy, useAuth bool) Config {
host, port := hostPort(fp.addr) host, port := hostPort(fp.addr)
cfg := Config{ cfg := Config{
@@ -487,6 +533,9 @@ func proxyConfig(fp *fakeProxy, useAuth bool) Config {
PerTestTimeout: 500 * time.Millisecond, PerTestTimeout: 500 * time.Millisecond,
MaxRetries: 1, MaxRetries: 1,
RetryBackoff: 30 * time.Millisecond, RetryBackoff: 30 * time.Millisecond,
VoiceBurstCount: 10,
VoiceBurstInterval: 5 * time.Millisecond,
VoiceServerHostnames: []string{"localhost"},
} }
if useAuth { if useAuth {
cfg.ProxyLogin = "u" cfg.ProxyLogin = "u"
@@ -560,7 +609,7 @@ func TestRun_HappyNoAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) 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{} finals := map[string]Result{}
for _, id := range expected { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
@@ -579,6 +628,8 @@ func TestRun_HappyNoAuth(t *testing.T) {
// Metrics format spot-checks. // Metrics format spot-checks.
assert.Contains(t, finals["greet"].Metric, "no auth") assert.Contains(t, finals["greet"].Metric, "no auth")
assert.Equal(t, "REP=00", finals["connect"].Metric) 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) assert.Equal(t, "HTTP 200", finals["api"].Metric)
} }
@@ -592,7 +643,7 @@ func TestRun_HappyWithAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) 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 { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s; results=%+v", id, results) 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.Equal(t, StatusFailed, rA.Status)
assert.NotEmpty(t, rA.Hint) 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%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.Equal(t, StatusFailed, rG.Status)
assert.NotEmpty(t, rG.Hint) 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%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") rU, _ := finalByID(results, "udp")
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect") assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect")
// stun depends on udp → passes too. // voice-quality depends on udp → passes too.
rS, _ := finalByID(results, "stun") rVQ, _ := finalByID(results, "voice-quality")
assert.Equal(t, StatusPassed, rS.Status) 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. // api depends on connect → skipped.
rA, _ := finalByID(results, "api") rA, _ := finalByID(results, "api")
@@ -708,8 +763,13 @@ func TestRun_UDPUnsupported(t *testing.T) {
require.Equal(t, StatusFailed, rU.Status) require.Equal(t, StatusFailed, rU.Status)
assert.NotEmpty(t, rU.Hint) assert.NotEmpty(t, rU.Hint)
rS, _ := finalByID(results, "stun") // voice-quality depends on udp → skipped.
assert.Equal(t, StatusSkipped, rS.Status) 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") rA, _ := finalByID(results, "api")
assert.Equal(t, StatusPassed, rA.Status) assert.Equal(t, StatusPassed, rA.Status)
@@ -747,7 +807,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
assert.Equal(t, 2, greetEvents[3].Attempt) assert.Equal(t, 2, greetEvents[3].Attempt)
// All seven non-auth tests should ultimately pass. // 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r) 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 // Either: one cancelled-failed + rest cancelled-skipped, OR all
// cancelled-skipped (if cancellation hit before next test even // cancelled-skipped (if cancellation hit before next test even
// started). Both are acceptable. // started). Both are acceptable.
// Without auth, 5 tests remain after tcp (greet/connect/udp/stun/api). // Without auth, 6 tests remain after tcp (greet/connect/udp/
// Cancel may race with greet completing successfully, so accept ≥4. // 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) 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) 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) { func TestExtractRawHex(t *testing.T) {
cases := []struct { cases := []struct {
in, want string in, want string
+74 -14
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"net" "net"
"strings"
"syscall" "syscall"
) )
@@ -35,8 +36,10 @@ func tcpFriendlyName(testID string) string {
return "TCP-туннель к Discord" return "TCP-туннель к Discord"
case "udp": case "udp":
return "UDP ASSOCIATE" return "UDP ASSOCIATE"
case "stun": case "voice-quality":
return "STUN round-trip" return "качество UDP-канала"
case "voice-srv":
return "доступность voice-серверов Discord"
case "api": case "api":
return "Discord API" return "Discord API"
default: default:
@@ -125,25 +128,35 @@ func hintFor(testID string, err error) string {
} }
return genericFallback(testID, err) return genericFallback(testID, err)
case "stun": case "voice-quality":
switch { switch {
case errors.Is(err, ErrSTUNNoMappedAddress): case errors.Is(err, ErrSTUNNoMappedAddress):
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик." return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
case errors.Is(err, ErrSTUNTooShort): case errors.Is(err, ErrSTUNTooShort),
return "STUN-ответ короче 20-байтного заголовка — релей возвращает мусор." errors.Is(err, ErrSTUNBadMagicCookie),
case errors.Is(err, ErrSTUNBadMagicCookie): errors.Is(err, ErrSTUNNotSuccess),
return "STUN-ответ без правильного magic cookie — релей возвращает мусор." errors.Is(err, ErrSTUNTxIDMismatch),
case errors.Is(err, ErrSTUNNotSuccess): errors.Is(err, ErrSTUNUnsupportedFamily):
return "STUN-сервер вернул не Binding Success — UDP-релей сломан." return "STUN-релей возвращает мусор — голос работать не будет."
case errors.Is(err, ErrSTUNTxIDMismatch):
return "STUN-ответ с чужим transaction ID — релей путает пакеты."
case errors.Is(err, ErrSTUNUnsupportedFamily):
return "STUN-ответ с неподдерживаемым семейством адресов."
case isTimeout: case isTimeout:
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны." return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
} }
return genericFallback(testID, err) return genericFallback(testID, err)
case "voice-srv":
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return "DNS не резолвит voice-домены Discord."
}
if isTimeout {
return "Не удалось проверить voice-серверы Discord вовремя — таймаут."
}
return fmt.Sprintf("Не удалось проверить доступность Discord voice-серверов: %s.", err.Error())
case "api": case "api":
switch { switch {
case isTimeout: case isTimeout:
@@ -187,3 +200,50 @@ func socks5ReplyHint(step string, code byte) string {
func genericFallback(testID string, err error) string { func genericFallback(testID string, err error) string {
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error()) return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
} }
// voiceQualityWarnHint composes a warn-tier hint based on which threshold
// was violated. Thresholds match runVoiceQuality's warn band: loss>5,
// jitter>30, p50>250. Always returns non-empty.
func voiceQualityWarnHint(loss, jitter, p50 float64) string {
parts := make([]string, 0, 3)
if loss > 5.0 {
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос будет с заиканиями", loss))
}
if jitter > 30.0 {
parts = append(parts, fmt.Sprintf("большой джиттер %.1fms — звук будет дёргаться", jitter))
}
if p50 > 250.0 {
parts = append(parts, fmt.Sprintf("высокая задержка %.0fms — заметная рассинхронизация при разговоре", p50))
}
if len(parts) == 0 {
// Shouldn't happen — caller only invokes us in the warn band.
return "UDP-канал на грани приемлемого — возможны помехи в голосе."
}
return strings.Join(parts, "; ") + "."
}
// voiceQualityFailHint composes a fail-tier hint. p95 is informational —
// included only when notably worse than p50.
func voiceQualityFailHint(loss, jitter, p50, p95 float64) string {
_ = p95
parts := make([]string, 0, 3)
if loss > 15.0 {
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос работать не будет", loss))
} else if loss > 5.0 {
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%%", loss))
}
if jitter > 60.0 {
parts = append(parts, fmt.Sprintf("джиттер %.1fms — звук развалится", jitter))
} else if jitter > 30.0 {
parts = append(parts, fmt.Sprintf("джиттер %.1fms", jitter))
}
if p50 > 400.0 {
parts = append(parts, fmt.Sprintf("задержка %.0fms — голос идёт со значительной паузой", p50))
} else if p50 > 250.0 {
parts = append(parts, fmt.Sprintf("задержка %.0fms", p50))
}
if len(parts) == 0 {
return "UDP-канал не пригоден для голоса."
}
return strings.Join(parts, "; ") + "."
}
+18 -11
View File
@@ -18,7 +18,7 @@ func TestHintFor(t *testing.T) {
t.Run("context_canceled_uniform", func(t *testing.T) { t.Run("context_canceled_uniform", func(t *testing.T) {
// Cancellation is always reported as «Проверка отменена.» across // Cancellation is always reported as «Проверка отменена.» across
// all testIDs. // all testIDs.
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api", "unknown"} { for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api", "unknown"} {
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id) assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id) assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
} }
@@ -42,8 +42,8 @@ func TestHintFor(t *testing.T) {
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"}, {"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"}, {"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"}, {"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
{"stun_no_mapped_xor", "stun", ErrSTUNNoMappedAddress, "XOR-MAPPED"}, {"voice_quality_no_mapped_xor", "voice-quality", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
{"stun_timeout_mentions_stun", "stun", &timeoutOnlyError{}, "STUN"}, {"voice_quality_timeout_mentions_stun", "voice-quality", &timeoutOnlyError{}, "STUN"},
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"}, {"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"}, {"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"}, {"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
@@ -111,13 +111,19 @@ func TestHintFor_PerStepBranches(t *testing.T) {
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"}, {"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"}, {"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
// stun: every sentinel branch // voice-quality: every sentinel branch (collapsed in 2026-05-01
{"stun_too_short", "stun", ErrSTUNTooShort, "20"}, // rewrite into a single user-visible message rather than
{"stun_bad_magic", "stun", ErrSTUNBadMagicCookie, "magic"}, // per-error "магник cookie" / "семейство адресов" exposition)
{"stun_not_success", "stun", ErrSTUNNotSuccess, "Binding"}, {"voice_quality_too_short", "voice-quality", ErrSTUNTooShort, "мусор"},
{"stun_txid_mismatch", "stun", ErrSTUNTxIDMismatch, "transaction"}, {"voice_quality_bad_magic", "voice-quality", ErrSTUNBadMagicCookie, "мусор"},
{"stun_unsupported_family", "stun", ErrSTUNUnsupportedFamily, "семейством"}, {"voice_quality_not_success", "voice-quality", ErrSTUNNotSuccess, "мусор"},
{"stun_fallback", "stun", errors.New("weird"), "STUN"}, {"voice_quality_txid_mismatch", "voice-quality", ErrSTUNTxIDMismatch, "мусор"},
{"voice_quality_unsupported_family", "voice-quality", ErrSTUNUnsupportedFamily, "мусор"},
{"voice_quality_fallback", "voice-quality", errors.New("weird"), "качество"},
// voice-srv: DNS error, timeout, generic
{"voice_srv_timeout", "voice-srv", &timeoutOnlyError{}, "таймаут"},
{"voice_srv_generic", "voice-srv", errors.New("boom"), "boom"},
// api: timeout vs generic // api: timeout vs generic
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"}, {"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
@@ -151,7 +157,8 @@ func TestTcpFriendlyName(t *testing.T) {
"auth": "авторизация SOCKS5", "auth": "авторизация SOCKS5",
"connect": "TCP-туннель к Discord", "connect": "TCP-туннель к Discord",
"udp": "UDP ASSOCIATE", "udp": "UDP ASSOCIATE",
"stun": "STUN round-trip", "voice-quality": "качество UDP-канала",
"voice-srv": "доступность voice-серверов Discord",
"api": "Discord API", "api": "Discord API",
"weirdo": "weirdo", "weirdo": "weirdo",
} }
+438
View File
@@ -0,0 +1,438 @@
package checker
// voice.go — predictive voice diagnostics.
//
// Two primitives sit on top of the SOCKS5/STUN building blocks already
// in this package:
//
// - runVoiceQualityBurst: fire a burst of STUN binding requests through
// an open SOCKS5 UDP relay, then derive packet-loss / jitter /
// percentile-RTT from the replies. A single round-trip says the relay
// accepts UDP; a 30-packet burst tells you whether voice will actually
// hold together.
//
// - runVoiceServerProbe: parallel-DNS a list of <region>.discord.media
// hostnames, then SOCKS5 CONNECT to :443 on each, recording which
// regions are reachable through the proxy. DNS-resolves-but-CONNECT-fails
// is a very common Russian-DPI failure mode that all five preceding
// SOCKS5 sanity checks miss.
import (
"context"
"encoding/binary"
"errors"
"fmt"
"math"
"net"
"sort"
"sync"
"time"
)
// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5
// relay. All fields are zero on a hard failure (no replies at all).
type VoiceQualityResult struct {
Sent int
Received int
LossPct float64 // 0..100
JitterMS float64 // mean abs of inter-arrival deltas in ms
P50RTTMS float64 // median round-trip in ms
P95RTTMS float64 // 95th percentile (informational, not gated)
}
// runVoiceQualityBurst sends `count` STUN binding requests through the
// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort,
// spaced `interval` apart. It listens on udpConn until
// `time.Now() + max(interval, 200ms)` after the last send, then returns
// the aggregate result.
//
// Each outbound datagram has the SOCKS5 UDP header
// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by
// a 20-byte STUN binding request. We track each request by its
// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP
// header before being handed to ParseBindingResponse.
//
// Returns an error only when ctx is cancelled or stunHost can't be
// resolved to IPv4. A 100% loss is NOT an error — the caller decides
// what status to assign; we just report Sent=count, Received=0.
func runVoiceQualityBurst(
ctx context.Context,
udpConn net.PacketConn,
relayAddr *net.UDPAddr,
stunHost string,
stunPort uint16,
count int,
interval time.Duration,
) (VoiceQualityResult, error) {
if count <= 0 {
return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0")
}
// Resolve stunHost to IPv4.
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost)
if err != nil {
return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err)
}
var stunIP4 net.IP
for _, ip := range ips {
if v4 := ip.To4(); v4 != nil {
stunIP4 = v4
break
}
}
if stunIP4 == nil {
return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost)
}
// Per-tx state: send-time + arrival-time.
type entry struct {
sentAt time.Time
arrivedAt time.Time
received bool
}
var (
mu sync.Mutex
entries = make(map[[12]byte]*entry, count)
arrivals = make([]time.Time, 0, count) // for jitter (in arrival order)
rtts = make([]float64, 0, count) // milliseconds
)
// Reader goroutine: loops on ReadFrom until deadline expires.
doneRead := make(chan struct{})
go func() {
defer close(doneRead)
buf := make([]byte, 1500)
for {
n, _, rerr := udpConn.ReadFrom(buf)
if rerr != nil {
// Deadline expired or conn closed — exit.
return
}
if n < 10 {
continue
}
// Validate SOCKS5 UDP wrapper, derive header length.
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
}
stunReply := buf[hdrLen:n]
// Pull the transaction ID out of the STUN header so we
// can look up the matching send-time. ParseBindingResponse
// rejects mismatched txIDs, so we feed it the *expected*
// id from the entries map.
var txID [12]byte
copy(txID[:], stunReply[8:20])
now := time.Now()
mu.Lock()
ent, ok := entries[txID]
if !ok || ent.received {
mu.Unlock()
continue
}
if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil {
mu.Unlock()
continue
}
ent.arrivedAt = now
ent.received = true
arrivals = append(arrivals, now)
rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0)
mu.Unlock()
}
}()
// Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body
// is per-packet (fresh tx id each).
hdr := make([]byte, 0, 10)
hdr = append(hdr, 0x00, 0x00, 0x00, 0x01)
hdr = append(hdr, stunIP4...)
var portBuf [2]byte
binary.BigEndian.PutUint16(portBuf[:], stunPort)
hdr = append(hdr, portBuf[:]...)
// Send burst.
ticker := time.NewTicker(interval)
defer ticker.Stop()
sent := 0
sendLoop:
for sent < count {
// Make a fresh tx id and STUN request.
txID, terr := NewTransactionID()
if terr != nil {
break
}
stunReq := EncodeBindingRequest(txID)
dgram := make([]byte, 0, len(hdr)+len(stunReq))
dgram = append(dgram, hdr...)
dgram = append(dgram, stunReq...)
// Record send-time *before* the write. Note: we register the
// entry into the map BEFORE Write so the reader can never get a
// reply for an unknown tx (would happen on a very fast localhost
// echo).
now := time.Now()
mu.Lock()
entries[txID] = &entry{sentAt: now}
mu.Unlock()
if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil {
// Write failure aborts the burst — but we still wait for
// any in-flight replies. Treat as "sent so far".
break
}
sent++
if sent >= count {
break sendLoop
}
// Wait for next tick OR ctx cancel.
select {
case <-ticker.C:
case <-ctx.Done():
break sendLoop
}
}
// Wait window for stragglers — at least 200ms past last send.
wait := interval
if wait < 200*time.Millisecond {
wait = 200 * time.Millisecond
}
deadline := time.Now().Add(wait)
_ = udpConn.SetReadDeadline(deadline)
// Wait for reader to exit. ctx cancel still races: bound by deadline.
select {
case <-doneRead:
case <-ctx.Done():
// Force the reader to exit ASAP by setting a past deadline.
_ = udpConn.SetReadDeadline(time.Unix(0, 1))
<-doneRead
}
// Reset deadline so subsequent users of the conn aren't surprised.
_ = udpConn.SetReadDeadline(time.Time{})
// Compute aggregates.
mu.Lock()
defer mu.Unlock()
received := len(rtts)
res := VoiceQualityResult{
Sent: sent,
Received: received,
}
if sent > 0 {
res.LossPct = float64(sent-received) / float64(sent) * 100.0
}
if received >= 2 {
// Sort arrivals to compute inter-arrival jitter in chronological order.
// arrivals is already chronological (appended as packets came in).
var diffs []float64
for i := 1; i < len(arrivals); i++ {
d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0
diffs = append(diffs, d)
}
// mean abs of consecutive deltas of inter-arrival diffs.
if len(diffs) >= 2 {
var sum float64
for i := 1; i < len(diffs); i++ {
sum += math.Abs(diffs[i] - diffs[i-1])
}
res.JitterMS = sum / float64(len(diffs)-1)
} else if len(diffs) == 1 {
// Only two arrivals — single delta, no second-order jitter.
res.JitterMS = 0
}
}
if received > 0 {
// percentile.
sorted := make([]float64, len(rtts))
copy(sorted, rtts)
sort.Float64s(sorted)
p50idx := len(sorted) / 2
if p50idx >= len(sorted) {
p50idx = len(sorted) - 1
}
res.P50RTTMS = sorted[p50idx]
p95idx := int(0.95 * float64(len(sorted)))
if p95idx >= len(sorted) {
p95idx = len(sorted) - 1
}
res.P95RTTMS = sorted[p95idx]
}
return res, nil
}
// VoiceServerProbeResult lists Discord voice-domain hostnames split by
// outcome. Resolved is ordered by input position; Reachable is too.
type VoiceServerProbeResult struct {
Resolved []string // hostnames that got at least one A record
Reachable []string // hostnames that succeeded SOCKS5 CONNECT
Unresolved []string // hostnames that didn't resolve at all
UnreachableButResolved []string // resolved but proxy CONNECT failed
}
// runVoiceServerProbe resolves each hostname and, for those that resolved
// to ≥ 1 IPv4, performs a SOCKS5 CONNECT to host:443 through proxyAddr.
// REP=00 is enough — we don't actually do TLS.
//
// DNS goes through the OS resolver (not the proxy). For tests, pass
// hostnames that always resolve locally (e.g. "localhost").
//
// Returns an error only when ctx is cancelled.
func runVoiceServerProbe(
ctx context.Context,
hostnames []string,
proxyAddr string,
useAuth bool,
login, password string,
dialTimeout time.Duration,
) (VoiceServerProbeResult, error) {
if dialTimeout <= 0 {
dialTimeout = 1 * time.Second
}
const dnsConcurrency = 16
const connectConcurrency = 8
// Phase 1: parallel DNS.
type dnsOut struct {
idx int
host string
resolved bool
}
dnsResults := make([]dnsOut, len(hostnames))
var wg sync.WaitGroup
dnsSem := make(chan struct{}, dnsConcurrency)
for i, h := range hostnames {
wg.Add(1)
dnsSem <- struct{}{}
go func(i int, h string) {
defer wg.Done()
defer func() { <-dnsSem }()
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", h)
ok := err == nil && len(ips) > 0
dnsResults[i] = dnsOut{idx: i, host: h, resolved: ok}
}(i, h)
}
wg.Wait()
if cerr := ctx.Err(); cerr != nil {
return VoiceServerProbeResult{}, cerr
}
res := VoiceServerProbeResult{}
// Build resolved/unresolved lists in input order.
resolvedHosts := make([]string, 0, len(hostnames))
for _, dr := range dnsResults {
if dr.resolved {
res.Resolved = append(res.Resolved, dr.host)
resolvedHosts = append(resolvedHosts, dr.host)
} else {
res.Unresolved = append(res.Unresolved, dr.host)
}
}
if len(resolvedHosts) == 0 {
return res, nil
}
// Phase 2: parallel SOCKS5 CONNECT.
reachable := make([]bool, len(resolvedHosts))
connectSem := make(chan struct{}, connectConcurrency)
wg = sync.WaitGroup{}
for i, h := range resolvedHosts {
wg.Add(1)
connectSem <- struct{}{}
go func(i int, h string) {
defer wg.Done()
defer func() { <-connectSem }()
reachable[i] = probeSocks5Connect(ctx, proxyAddr, useAuth, login, password, h, 443, dialTimeout)
}(i, h)
}
wg.Wait()
if cerr := ctx.Err(); cerr != nil {
return res, cerr
}
for i, h := range resolvedHosts {
if reachable[i] {
res.Reachable = append(res.Reachable, h)
} else {
res.UnreachableButResolved = append(res.UnreachableButResolved, h)
}
}
return res, nil
}
// probeSocks5Connect opens one fresh TCP connection to proxyAddr, runs
// the SOCKS5 handshake and CONNECT request to host:port, and returns
// true iff the proxy replied REP=00. Always closes the conn before
// returning.
func probeSocks5Connect(
parent context.Context,
proxyAddr string,
useAuth bool,
login, password string,
host string,
port uint16,
dialTimeout time.Duration,
) bool {
ctx, cancel := context.WithTimeout(parent, dialTimeout)
defer cancel()
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", proxyAddr)
if err != nil {
return false
}
defer conn.Close()
if _, _, gerr := socks5Greeting(ctx, conn, useAuth); gerr != nil {
return false
}
if useAuth {
if _, aerr := socks5Auth(ctx, conn, login, password); aerr != nil {
return false
}
}
if _, cerr := socks5Connect(ctx, conn, host, port); cerr != nil {
return false
}
return true
}
// defaultVoiceHostnames is the built-in list used when
// Config.VoiceServerHostnames is empty. Order matters — the GUI shows
// the first three reachable regions in the metric line.
var defaultVoiceHostnames = []string{
"russia.discord.media", "russia2.discord.media",
"frankfurt.discord.media", "europe.discord.media",
"singapore.discord.media", "japan.discord.media",
"us-east.discord.media", "us-west.discord.media",
"brazil.discord.media", "india.discord.media",
"hongkong.discord.media", "southkorea.discord.media",
"sydney.discord.media", "southafrica.discord.media",
"dubai.discord.media", "atlanta.discord.media",
}
+300
View File
@@ -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
}