acd5291604
Adds: - retry.go: classifyError() splits errors into Permanent vs Transient (used to gate auto-retry); isContextErr() detects ctx cancellation through wrapping (OpError, errors.Join). - hints.go: hintFor(testID, err) returns short Russian explanation per failure step, with dedicated branches for SOCKS5 sentinels, every documented REP code (0x01..0x08), STUN sentinels, timeouts, and a friendly-name fallback. Coverage: retry.go 100%, hints.go 100%; package total 94.2%. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
190 lines
7.3 KiB
Go
190 lines
7.3 KiB
Go
package checker
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net"
|
||
"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 "stun":
|
||
return "STUN round-trip"
|
||
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 "stun":
|
||
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 isTimeout:
|
||
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны."
|
||
}
|
||
return genericFallback(testID, err)
|
||
|
||
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())
|
||
}
|