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
+74 -14
View File
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net"
"strings"
"syscall"
)
@@ -35,8 +36,10 @@ func tcpFriendlyName(testID string) string {
return "TCP-туннель к Discord"
case "udp":
return "UDP ASSOCIATE"
case "stun":
return "STUN round-trip"
case "voice-quality":
return "качество UDP-канала"
case "voice-srv":
return "доступность voice-серверов Discord"
case "api":
return "Discord API"
default:
@@ -125,25 +128,35 @@ func hintFor(testID string, err error) string {
}
return genericFallback(testID, err)
case "stun":
case "voice-quality":
switch {
case errors.Is(err, ErrSTUNNoMappedAddress):
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
case errors.Is(err, ErrSTUNTooShort):
return "STUN-ответ короче 20-байтного заголовка — релей возвращает мусор."
case errors.Is(err, ErrSTUNBadMagicCookie):
return "STUN-ответ без правильного magic cookie — релей возвращает мусор."
case errors.Is(err, ErrSTUNNotSuccess):
return "STUN-сервер вернул не Binding Success — UDP-релей сломан."
case errors.Is(err, ErrSTUNTxIDMismatch):
return "STUN-ответ с чужим transaction ID — релей путает пакеты."
case errors.Is(err, ErrSTUNUnsupportedFamily):
return "STUN-ответ с неподдерживаемым семейством адресов."
case errors.Is(err, ErrSTUNTooShort),
errors.Is(err, ErrSTUNBadMagicCookie),
errors.Is(err, ErrSTUNNotSuccess),
errors.Is(err, ErrSTUNTxIDMismatch),
errors.Is(err, ErrSTUNUnsupportedFamily):
return "STUN-релей возвращает мусор — голос работать не будет."
case isTimeout:
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны."
return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
}
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":
switch {
case isTimeout:
@@ -187,3 +200,50 @@ func socks5ReplyHint(step string, code byte) string {
func genericFallback(testID string, err error) string {
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, "; ") + "."
}