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:
+274
-86
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user