Files
drover-go/internal/checker/hints.go
T
root 0a85979142
Build / test (push) Has been cancelled
Build / build-windows (push) Has been cancelled
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>
2026-05-01 18:42:12 +03:00

250 lines
9.6 KiB
Go

package checker
import (
"errors"
"fmt"
"net"
"strings"
"syscall"
)
// socks5ReplyHints maps SOCKS5 REP codes to short Russian explanations
// used by hintFor for the "connect" and "udp" steps. Codes outside this
// table fall back to a generic "unknown REP" message.
var socks5ReplyHints = map[byte]string{
0x01: "общий сбой SOCKS5-сервера",
0x02: "правила прокси запрещают это соединение",
0x03: "сеть назначения недоступна",
0x04: "хост назначения недоступен",
0x05: "connection refused",
0x06: "истёк TTL",
0x07: "команда не поддерживается",
0x08: "тип адреса не поддерживается",
}
// tcpFriendlyName turns a testID into a Russian-friendly label for the
// generic fallback hint.
func tcpFriendlyName(testID string) string {
switch testID {
case "tcp":
return "TCP"
case "greet":
return "приветствие SOCKS5"
case "auth":
return "авторизация SOCKS5"
case "connect":
return "TCP-туннель к Discord"
case "udp":
return "UDP ASSOCIATE"
case "voice-quality":
return "качество UDP-канала"
case "voice-srv":
return "доступность voice-серверов Discord"
case "api":
return "Discord API"
default:
return testID
}
}
// hintFor returns a short Russian-language explanation of why a test
// failed. Returns "" when err is nil.
func hintFor(testID string, err error) string {
if err == nil {
return ""
}
if isContextErr(err) {
return "Проверка отменена."
}
// Common error shapes we recognise across all testIDs.
var ne net.Error
isTimeout := errors.As(err, &ne) && ne.Timeout()
var rep ErrSocks5Reply
hasReply := errors.As(err, &rep)
switch testID {
case "tcp":
switch {
case isTimeout:
return "Превышен таймаут подключения — прокси может быть выключен или брандмауэр режет порт."
case errors.Is(err, syscall.ECONNREFUSED):
return "Прокси отклонил TCP-соединение — порт закрыт или сервис не запущен."
}
return fmt.Sprintf("Прокси не отвечает по TCP — проверь host и port (%s).", err.Error())
case "greet":
switch {
case errors.Is(err, ErrSocks5BadVersion):
return "Сервер вернул не SOCKS5 — возможно, это HTTP-прокси."
case errors.Is(err, ErrSocks5RejectedAllAuth):
return "Прокси требует авторизацию, но мы её не предложили (или прокси не принимает наши методы)."
case errors.Is(err, ErrShortReply):
return "SOCKS5-сервер прислал укороченный ответ на приветствие."
case isTimeout:
return "SOCKS5-сервер не ответил на приветствие вовремя."
}
return genericFallback(testID, err)
case "auth":
switch {
case errors.Is(err, ErrAuthRejected):
return "Логин или пароль неверны."
case errors.Is(err, ErrCredentialTooLong):
return "Логин или пароль длиннее 255 байт — SOCKS5 такого не позволяет."
case errors.Is(err, ErrShortReply):
return "SOCKS5-сервер прислал укороченный ответ на авторизацию."
case isTimeout:
return "SOCKS5-сервер не ответил на авторизацию вовремя."
}
return genericFallback(testID, err)
case "connect":
if hasReply {
return socks5ReplyHint("connect", rep.Code)
}
switch {
case errors.Is(err, ErrHostTooLong):
return "Имя хоста длиннее 255 байт — SOCKS5 такого не позволяет."
case errors.Is(err, ErrShortReply):
return "SOCKS5-сервер прислал укороченный ответ на CONNECT."
case isTimeout:
return "SOCKS5-сервер не ответил на CONNECT вовремя."
}
return genericFallback(testID, err)
case "udp":
if hasReply {
return socks5ReplyHint("udp", rep.Code)
}
switch {
case errors.Is(err, ErrUnsupportedRelayATYP):
return "Прокси выдал IPv6 relay для UDP — пока не поддерживается, голос работать не будет."
case errors.Is(err, ErrShortReply):
return "SOCKS5-сервер прислал укороченный ответ на UDP ASSOCIATE."
case isTimeout:
return "SOCKS5-сервер не ответил на UDP ASSOCIATE вовремя."
}
return genericFallback(testID, err)
case "voice-quality":
switch {
case errors.Is(err, ErrSTUNNoMappedAddress):
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
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 через прокси сильно теряет пакеты."
}
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:
return "Discord API не ответил вовремя через прокси — таймаут."
}
return fmt.Sprintf("Discord API недоступен через прокси — TLS handshake упал (%s).", err.Error())
}
return genericFallback(testID, err)
}
// socks5ReplyHint formats a SOCKS5 REP-code hint specialised by step.
// "connect" wording references Discord; "udp" wording references voice.
func socks5ReplyHint(step string, code byte) string {
desc, ok := socks5ReplyHints[code]
if !ok {
desc = "неизвестная REP"
}
switch step {
case "udp":
// 0x07 (cmd not supported) is the headline UDP failure mode.
if code == 0x07 {
return "Прокси не поддерживает UDP ASSOCIATE — голос Discord работать не будет."
}
return fmt.Sprintf("Прокси отклонил UDP ASSOCIATE (REP=%02X, %s).", code, desc)
case "connect":
if code == 0x05 {
return "Прокси не смог подключиться к Discord (REP=05, connection refused)."
}
if code == 0x07 {
return "Прокси не поддерживает CONNECT (REP=07)."
}
return fmt.Sprintf("Прокси отклонил CONNECT к Discord (REP=%02X, %s).", code, desc)
}
return fmt.Sprintf("Прокси отклонил запрос (REP=%02X, %s).", code, desc)
}
// genericFallback is the catch-all used when we don't recognise the
// (testID, err) shape. Keeps the user informed without exposing raw Go
// error wrapping.
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, "; ") + "."
}