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
+274 -86
View File
@@ -3,13 +3,12 @@ package checker
import (
"context"
"crypto/tls"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
@@ -22,6 +21,12 @@ const (
StatusPassed Status = "passed"
StatusFailed Status = "failed"
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
@@ -53,6 +58,18 @@ type Config struct {
DiscordGateway string
DiscordAPI 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.
@@ -84,6 +101,15 @@ func applyDefaults(cfg Config) Config {
if cfg.StunServer == "" {
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
}
@@ -108,7 +134,8 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
}
e.runConnect()
e.runUDP()
e.runStun()
e.runVoiceQuality()
e.runVoiceSrv()
e.runAPI()
}()
@@ -137,8 +164,9 @@ type executor struct {
// udpClient is our local UDP socket used to talk to the relay.
udpClient net.PacketConn
// Step gating: each xOK is set true on success.
tcpOK, greetOK, authOK, connectOK, udpOK bool
// Step gating: each xOK is set true on success (or "soft pass"
// warn for voice-quality / voice-srv).
tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool
// Cancellation latch. Once any test emits a "cancelled" failure,
// remaining tests emit a single Skipped result with the same reason.
@@ -542,19 +570,28 @@ func (e *executor) runUDP() {
e.udpOK = ok
}
// runStun — Test 6: STUN through the SOCKS5 UDP relay.
func (e *executor) runStun() {
if e.shouldSkip("stun", e.udpOK) {
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
// relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
//
// - 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
}
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
if splitErr != nil {
e.emit(Result{
ID: "stun",
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
Hint: hintFor("stun", splitErr),
Hint: hintFor("voice-quality", splitErr),
Attempt: 1,
})
return
@@ -562,105 +599,256 @@ func (e *executor) runStun() {
port64, perr := strconv.ParseUint(portStr, 10, 16)
if perr != nil {
e.emit(Result{
ID: "stun",
ID: "voice-quality",
Status: StatusFailed,
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
Hint: hintFor("stun", perr),
Hint: hintFor("voice-quality", perr),
Attempt: 1,
})
return
}
stunPort := uint16(port64)
e.runAttempt("stun", func(ctx context.Context) (string, error) {
// Resolve STUN host to an IPv4. We don't support IPv6 STUN.
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip4", host)
if err != nil {
return "", fmt.Errorf("stun: lookup %s: %w", host, err)
}
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")
maxAttempts := 1 + e.cfg.MaxRetries
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := e.ctx.Err(); err != nil {
e.emitCancelled("voice-quality", attempt, 0)
return
}
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.
if e.udpClient != nil {
_ = e.udpClient.Close()
e.udpClient = nil
}
pc, err := net.ListenPacket("udp", ":0")
if err != nil {
return "", fmt.Errorf("stun: listen udp: %w", err)
pc, perr := net.ListenPacket("udp", ":0")
if perr != nil {
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
if dl, ok := ctx.Deadline(); ok {
_ = pc.SetDeadline(dl)
}
// Build SOCKS5 UDP datagram: RSV(2)=0 FRAG=0 ATYP=01 IP(4) PORT(2) STUN(20)
txID, err := NewTransactionID()
if err != nil {
return "", err
}
stunReq := EncodeBindingRequest(txID)
dgram := make([]byte, 0, 10+len(stunReq))
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...)
res, berr := runVoiceQualityBurst(
attemptCtx, pc, e.udpRelay,
host, stunPort,
e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval,
)
dur := time.Since(start)
cancel()
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)
n, _, rerr := pc.ReadFrom(readBuf)
if rerr != nil {
return "", fmt.Errorf("stun: read from relay: %w", rerr)
}
rtt := time.Since(start)
if n < 10 {
return "", fmt.Errorf("stun: relay reply too short (%d bytes)", n)
}
// Validate SOCKS5 UDP wrapper: RSV=00 00, FRAG=00, ATYP=01.
if readBuf[0] != 0x00 || readBuf[1] != 0x00 || readBuf[2] != 0x00 {
return "", fmt.Errorf("stun: bad SOCKS5 UDP header (raw=%x)", readBuf[:10])
}
// We sent IPv4, expect IPv4 reply.
var hdrLen int
switch readBuf[3] {
case 0x01:
hdrLen = 10
case 0x04:
hdrLen = 22
case 0x03:
if n < 5 {
return "", fmt.Errorf("stun: truncated SOCKS5 UDP domain header")
if berr != nil {
// Resolution / cancellation. Treat ctx-cancel separately.
if e.ctx.Err() != nil {
e.emitCancelled("voice-quality", attempt, dur)
return
}
hdrLen = 4 + 1 + int(readBuf[4]) + 2
default:
return "", fmt.Errorf("stun: unknown SOCKS5 UDP ATYP=0x%02X", readBuf[3])
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
}
if n < hdrLen {
return "", fmt.Errorf("stun: relay reply truncated (%d < %d)", n, hdrLen)
}
stunReply := readBuf[hdrLen:n]
_, _, perr := ParseBindingResponse(stunReply, txID)
if perr != nil {
return "", perr
// 100% loss with no underlying error → the relay accepted UDP
// (per test 5) but nothing came back. Treat as transient on
// the first attempt; permanent on the second.
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
}
}
return
}
return fmt.Sprintf("%dms RTT", rtt.Milliseconds()), nil
metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
res.LossPct, res.JitterMS, res.P50RTTMS)
switch {
case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
e.emit(Result{
ID: "voice-quality",
Status: StatusPassed,
Metric: metric,
Attempt: attempt,
Duration: dur,
})
e.voiceQualityOK = true
return
case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
e.emit(Result{
ID: "voice-quality",
Status: StatusWarn,
Metric: metric,
Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
Attempt: attempt,
Duration: dur,
})
e.voiceQualityOK = true
return
default:
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
}
}
}
// runVoiceSrv — Test 7: probe Discord voice-domain reachability through
// the SOCKS5 proxy. Single attempt (DNS+connect bursts are slow and
// idempotent — retry budget better spent on transient handshake steps).
func (e *executor) runVoiceSrv() {
if e.shouldSkip("voice-srv", e.connectOK) {
return
}
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,
})
}