diff --git a/internal/checker/hints.go b/internal/checker/hints.go new file mode 100644 index 0000000..64a8d37 --- /dev/null +++ b/internal/checker/hints.go @@ -0,0 +1,189 @@ +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()) +} diff --git a/internal/checker/hints_test.go b/internal/checker/hints_test.go new file mode 100644 index 0000000..5a4232d --- /dev/null +++ b/internal/checker/hints_test.go @@ -0,0 +1,163 @@ +package checker + +import ( + "context" + "errors" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHintFor(t *testing.T) { + t.Run("nil_err_returns_empty", func(t *testing.T) { + assert.Equal(t, "", hintFor("tcp", nil)) + assert.Equal(t, "", hintFor("anything", nil)) + }) + + t.Run("context_canceled_uniform", func(t *testing.T) { + // Cancellation is always reported as «Проверка отменена.» across + // all testIDs. + for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api", "unknown"} { + assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id) + assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id) + } + }) + + cases := []struct { + name string + testID string + err error + substring string + }{ + {"tcp_timeout", "tcp", &timeoutOnlyError{}, "таймаут"}, + {"greet_bad_version_mentions_socks5", "greet", ErrSocks5BadVersion, "SOCKS5"}, + {"greet_bad_version_mentions_negation", "greet", ErrSocks5BadVersion, "не"}, + {"greet_rejected_all_auth_mentions_auth_or_methods", "greet", ErrSocks5RejectedAllAuth, "авторизаци"}, + {"auth_login", "auth", ErrAuthRejected, "Логин"}, + {"auth_password", "auth", ErrAuthRejected, "паро"}, + {"connect_refused_rep05", "connect", ErrSocks5Reply{Code: 0x05}, "REP=05"}, + {"connect_refused_text", "connect", ErrSocks5Reply{Code: 0x05}, "connection refused"}, + {"connect_unsupported_rep07", "connect", ErrSocks5Reply{Code: 0x07}, "REP=07"}, + {"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"}, + {"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"}, + {"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"}, + {"stun_no_mapped_xor", "stun", ErrSTUNNoMappedAddress, "XOR-MAPPED"}, + {"stun_timeout_mentions_stun", "stun", &timeoutOnlyError{}, "STUN"}, + {"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"}, + {"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"}, + {"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"}, + {"tcp_fallback_friendly_name", "tcp", errors.New("weird"), "TCP"}, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + got := hintFor(c.testID, c.err) + assert.Contains(t, got, c.substring, "got=%q", got) + }) + } +} + +func TestHintFor_AllSocks5ReplyCodesCovered(t *testing.T) { + // Every documented REP code (0x01..0x08) should produce a non-empty + // hint when surfaced via "connect" or "udp". + for code := byte(0x01); code <= 0x08; code++ { + err := ErrSocks5Reply{Code: code} + assert.NotEmpty(t, hintFor("connect", err), "connect code=%02X", code) + assert.NotEmpty(t, hintFor("udp", err), "udp code=%02X", code) + } + // Unknown REP code (0xFE) still gets a sensible fallback rather than + // an empty string. + err := ErrSocks5Reply{Code: 0xFE} + assert.NotEmpty(t, hintFor("connect", err)) + assert.NotEmpty(t, hintFor("udp", err)) +} + +func TestHintFor_PerStepBranches(t *testing.T) { + cases := []struct { + name string + testID string + err error + substring string + }{ + // tcp: ECONNREFUSED + generic fallback + {"tcp_econnrefused", "tcp", syscall.ECONNREFUSED, "отклонил"}, + {"tcp_generic", "tcp", errors.New("dial fail"), "TCP"}, + + // greet: short reply, timeout, fallback + {"greet_short_reply", "greet", ErrShortReply, "укороченный"}, + {"greet_timeout", "greet", &timeoutOnlyError{}, "вовремя"}, + {"greet_fallback", "greet", errors.New("weird"), "приветствие"}, + + // auth: credential too long, short reply, timeout, fallback + {"auth_cred_too_long", "auth", ErrCredentialTooLong, "255"}, + {"auth_short_reply", "auth", ErrShortReply, "укороченный"}, + {"auth_timeout", "auth", &timeoutOnlyError{}, "вовремя"}, + {"auth_fallback", "auth", errors.New("weird"), "авторизация"}, + + // connect: host too long, short reply, timeout, generic REP, fallback + {"connect_host_too_long", "connect", ErrHostTooLong, "255"}, + {"connect_short_reply", "connect", ErrShortReply, "укороченный"}, + {"connect_timeout", "connect", &timeoutOnlyError{}, "вовремя"}, + {"connect_generic_rep", "connect", ErrSocks5Reply{Code: 0x03}, "REP=03"}, + {"connect_unknown_rep", "connect", ErrSocks5Reply{Code: 0xFE}, "REP=FE"}, + {"connect_fallback", "connect", errors.New("weird"), "TCP-туннель"}, + + // udp: short reply, timeout, fallback, non-7 REP + {"udp_short_reply", "udp", ErrShortReply, "укороченный"}, + {"udp_timeout", "udp", &timeoutOnlyError{}, "вовремя"}, + {"udp_other_rep", "udp", ErrSocks5Reply{Code: 0x05}, "REP=05"}, + {"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"}, + {"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"}, + + // stun: every sentinel branch + {"stun_too_short", "stun", ErrSTUNTooShort, "20"}, + {"stun_bad_magic", "stun", ErrSTUNBadMagicCookie, "magic"}, + {"stun_not_success", "stun", ErrSTUNNotSuccess, "Binding"}, + {"stun_txid_mismatch", "stun", ErrSTUNTxIDMismatch, "transaction"}, + {"stun_unsupported_family", "stun", ErrSTUNUnsupportedFamily, "семейством"}, + {"stun_fallback", "stun", errors.New("weird"), "STUN"}, + + // api: timeout vs generic + {"api_timeout", "api", &timeoutOnlyError{}, "таймаут"}, + {"api_generic", "api", errors.New("tls boom"), "TLS"}, + + // socks5ReplyHint via uncategorised step (default branch) + // — we can't reach it via hintFor with current testIDs, but the + // default formatter still needs to be exercised. + } + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + got := hintFor(c.testID, c.err) + assert.Contains(t, got, c.substring, "got=%q", got) + }) + } +} + +func TestSocks5ReplyHint_DefaultStep(t *testing.T) { + // socks5ReplyHint("", code) hits the final fallback formatter. + got := socks5ReplyHint("", 0x03) + assert.Contains(t, got, "REP=03") + got = socks5ReplyHint("", 0xFE) + assert.Contains(t, got, "REP=FE") +} + +func TestTcpFriendlyName(t *testing.T) { + cases := map[string]string{ + "tcp": "TCP", + "greet": "приветствие SOCKS5", + "auth": "авторизация SOCKS5", + "connect": "TCP-туннель к Discord", + "udp": "UDP ASSOCIATE", + "stun": "STUN round-trip", + "api": "Discord API", + "weirdo": "weirdo", + } + for in, want := range cases { + t.Run(in, func(t *testing.T) { + assert.Equal(t, want, tcpFriendlyName(in)) + }) + } +} diff --git a/internal/checker/retry.go b/internal/checker/retry.go new file mode 100644 index 0000000..6b29f4b --- /dev/null +++ b/internal/checker/retry.go @@ -0,0 +1,135 @@ +package checker + +import ( + "context" + "errors" + "io" + "net" + "syscall" +) + +// Classification is the result of classifyError. Transient errors are +// candidates for one auto-retry (governed by Config.MaxRetries in +// checker.go). Permanent errors are reported to the user as-is. +type Classification int + +const ( + // ClassificationPermanent — caller should NOT retry. Either the user + // config is wrong (bad credentials, refused), the proxy is broken in + // a way retry won't fix (bad SOCKS5 version, malformed STUN reply), + // or the caller's context is already cancelled. + ClassificationPermanent Classification = iota + // ClassificationTransient — caller MAY retry once. Network blip, + // timeout, RST mid-handshake, DNS temporary failure. + ClassificationTransient +) + +// classifyError decides whether err is worth retrying. +// +// Transient (retry): +// - net.Error.Timeout() == true +// - errors.Is(err, syscall.ECONNRESET) +// - net.DNSError.IsTemporary || .IsTimeout +// - io.ErrUnexpectedEOF wrapped inside a *net.OpError on a Read (proxy +// hung up mid-reply mid-flight; bare io.ErrUnexpectedEOF without an +// OpError wrapper means we got a malformed reply and should not retry) +// +// Permanent (don't retry): +// - context.Canceled / context.DeadlineExceeded +// - errors.Is(err, syscall.ECONNREFUSED) +// - any of our SOCKS5/STUN sentinels +// - everything else we don't explicitly classify +// +// Returns ClassificationPermanent on nil err (defensive). +func classifyError(err error) Classification { + if err == nil { + return ClassificationPermanent + } + + // Context cancellation always wins — don't retry into a cancelled + // context, even if the chain also contains a timeout error. + if isContextErr(err) { + return ClassificationPermanent + } + + // Permanent: explicit refused. + if errors.Is(err, syscall.ECONNREFUSED) { + return ClassificationPermanent + } + + // Permanent: our SOCKS5 sentinels (auth refused, bad version, + // malformed credentials, etc.). Retrying won't fix any of these. + switch { + case errors.Is(err, ErrSocks5BadVersion), + errors.Is(err, ErrSocks5RejectedAllAuth), + errors.Is(err, ErrAuthRejected), + errors.Is(err, ErrCredentialTooLong), + errors.Is(err, ErrHostTooLong), + errors.Is(err, ErrUnsupportedRelayATYP), + errors.Is(err, ErrShortReply): + return ClassificationPermanent + } + + // Permanent: any non-zero SOCKS5 REP code. Includes 0x05 refused, + // 0x07 cmd unsupported, 0x02 not allowed by ruleset — none of which + // retry will fix. + var rep ErrSocks5Reply + if errors.As(err, &rep) { + return ClassificationPermanent + } + + // Permanent: STUN sentinels (malformed responses, missing attrs). + switch { + case errors.Is(err, ErrSTUNTooShort), + errors.Is(err, ErrSTUNBadMagicCookie), + errors.Is(err, ErrSTUNNotSuccess), + errors.Is(err, ErrSTUNTxIDMismatch), + errors.Is(err, ErrSTUNNoMappedAddress), + errors.Is(err, ErrSTUNUnsupportedFamily): + return ClassificationPermanent + } + + // Transient: ECONNRESET (peer hung up mid-stream). + if errors.Is(err, syscall.ECONNRESET) { + return ClassificationTransient + } + + // Transient: DNS temporary failure or DNS timeout. + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) { + if dnsErr.IsTemporary || dnsErr.IsTimeout { + return ClassificationTransient + } + return ClassificationPermanent + } + + // Transient: io.ErrUnexpectedEOF wrapped inside a net.OpError. Bare + // io.ErrUnexpectedEOF (synthesised by our SOCKS5 readers) is a + // malformed-reply signal and stays permanent. + var opErr *net.OpError + if errors.As(err, &opErr) { + if errors.Is(opErr.Err, io.ErrUnexpectedEOF) { + return ClassificationTransient + } + } + + // Transient: net.Error.Timeout(). Checked AFTER the typed sentinels + // so that a timeout-shaped error wrapping a permanent sentinel still + // classifies permanent. + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return ClassificationTransient + } + + return ClassificationPermanent +} + +// isContextErr returns true when err's chain contains context.Canceled +// or context.DeadlineExceeded. Used by checker.go to label cancelled +// tests as Error="cancelled". +func isContextErr(err error) bool { + if err == nil { + return false + } + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} diff --git a/internal/checker/retry_test.go b/internal/checker/retry_test.go new file mode 100644 index 0000000..45c2e77 --- /dev/null +++ b/internal/checker/retry_test.go @@ -0,0 +1,90 @@ +package checker + +import ( + "context" + "errors" + "io" + "net" + "syscall" + "testing" + + "github.com/stretchr/testify/assert" +) + +// timeoutOnlyError is a minimal net.Error that reports Timeout()=true. +// Used to drive the net.Error.Timeout() branch in classifyError. +type timeoutOnlyError struct{} + +func (timeoutOnlyError) Error() string { return "i/o timeout" } +func (timeoutOnlyError) Timeout() bool { return true } +func (timeoutOnlyError) Temporary() bool { return true } + +func TestClassifyError(t *testing.T) { + cases := []struct { + name string + err error + want Classification + }{ + {"nil", nil, ClassificationPermanent}, + {"context_canceled", context.Canceled, ClassificationPermanent}, + {"context_deadline", context.DeadlineExceeded, ClassificationPermanent}, + {"econnrefused", syscall.ECONNREFUSED, ClassificationPermanent}, + {"econnreset", syscall.ECONNRESET, ClassificationTransient}, + {"econnreset_wrapped", &net.OpError{Op: "read", Err: syscall.ECONNRESET}, ClassificationTransient}, + {"net_timeout", &timeoutOnlyError{}, ClassificationTransient}, + {"dns_temporary", &net.DNSError{IsTemporary: true}, ClassificationTransient}, + {"dns_timeout", &net.DNSError{IsTimeout: true}, ClassificationTransient}, + {"dns_permanent", &net.DNSError{IsNotFound: true}, ClassificationPermanent}, + {"socks5_auth_rejected", ErrAuthRejected, ClassificationPermanent}, + {"socks5_bad_version", ErrSocks5BadVersion, ClassificationPermanent}, + {"socks5_rejected_all_auth", ErrSocks5RejectedAllAuth, ClassificationPermanent}, + {"socks5_credential_too_long", ErrCredentialTooLong, ClassificationPermanent}, + {"socks5_host_too_long", ErrHostTooLong, ClassificationPermanent}, + {"socks5_unsupported_relay_atyp", ErrUnsupportedRelayATYP, ClassificationPermanent}, + {"socks5_short_reply", ErrShortReply, ClassificationPermanent}, + {"socks5_reply_general_failure", ErrSocks5Reply{Code: 0x01}, ClassificationPermanent}, + {"socks5_reply_not_allowed", ErrSocks5Reply{Code: 0x02}, ClassificationPermanent}, + {"socks5_reply_refused", ErrSocks5Reply{Code: 0x05}, ClassificationPermanent}, + {"socks5_reply_unsupported", ErrSocks5Reply{Code: 0x07}, ClassificationPermanent}, + {"stun_too_short", ErrSTUNTooShort, ClassificationPermanent}, + {"stun_bad_magic", ErrSTUNBadMagicCookie, ClassificationPermanent}, + {"stun_not_success", ErrSTUNNotSuccess, ClassificationPermanent}, + {"stun_txid_mismatch", ErrSTUNTxIDMismatch, ClassificationPermanent}, + {"stun_no_mapped", ErrSTUNNoMappedAddress, ClassificationPermanent}, + {"stun_unsupported_family", ErrSTUNUnsupportedFamily, ClassificationPermanent}, + {"unexpected_eof", io.ErrUnexpectedEOF, ClassificationPermanent}, + {"unexpected_eof_in_op", &net.OpError{Op: "read", Err: io.ErrUnexpectedEOF}, ClassificationTransient}, + {"joined_canceled_with_timeout", errors.Join(context.Canceled, &timeoutOnlyError{}), ClassificationPermanent}, + {"joined_canceled_with_econnreset", errors.Join(context.Canceled, syscall.ECONNRESET), ClassificationPermanent}, + {"random", errors.New("ouch"), ClassificationPermanent}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := classifyError(c.err) + assert.Equal(t, c.want, got, "err=%v", c.err) + }) + } +} + +func TestIsContextErr(t *testing.T) { + t.Run("nil", func(t *testing.T) { + assert.False(t, isContextErr(nil)) + }) + t.Run("canceled", func(t *testing.T) { + assert.True(t, isContextErr(context.Canceled)) + }) + t.Run("deadline", func(t *testing.T) { + assert.True(t, isContextErr(context.DeadlineExceeded)) + }) + t.Run("joined_canceled_with_econnreset", func(t *testing.T) { + assert.True(t, isContextErr(errors.Join(context.Canceled, syscall.ECONNRESET))) + }) + t.Run("random", func(t *testing.T) { + assert.False(t, isContextErr(errors.New("nope"))) + }) + t.Run("oprrror_wrapping_deadline", func(t *testing.T) { + err := &net.OpError{Op: "read", Err: context.DeadlineExceeded} + assert.True(t, isContextErr(err)) + }) +}