From 0a859791428f805fbfcd071315c22f3b0ab00148 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 1 May 2026 18:42:12 +0300 Subject: [PATCH] internal/checker: voice-quality + voice-srv tests for predictive voice diagnosis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 .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) --- internal/checker/checker.go | 360 +++++++++++++++++++------ internal/checker/checker_test.go | 172 ++++++++++-- internal/checker/hints.go | 88 ++++++- internal/checker/hints_test.go | 43 +-- internal/checker/voice.go | 438 +++++++++++++++++++++++++++++++ internal/checker/voice_test.go | 300 +++++++++++++++++++++ 6 files changed, 1264 insertions(+), 137 deletions(-) create mode 100644 internal/checker/voice.go create mode 100644 internal/checker/voice_test.go diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 4732c9a..309e676 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -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, }) } diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index de086a8..4a176e4 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -35,6 +35,12 @@ type fakeProxy struct { udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply + // udpDropEveryN, when > 0, drops every Nth packet through the relay + // (counted across the whole listener lifetime). N=2 → 50% loss; N=10 + // → 10%; N=1 → 100% loss; 0 → no drops. + udpDropEveryN atomic.Int32 + udpRelayCount atomic.Int32 + // API-passthrough hook: when a CONNECT targets this host:port, // the proxy dials apiTargetAddr and splices the conns instead of // sending a fake REP=00 + close. @@ -42,6 +48,11 @@ type fakeProxy struct { apiTargetPort uint16 apiTargetAddr string + // blockVoiceCONNECT, when true, makes any CONNECT to a hostname + // not equal to apiTargetHost return REP=05. Used by voice-srv + // negative scenarios. + blockVoiceCONNECT atomic.Bool + // timeoutFirstAttempt stalls the first connection on greet to // drive a timeout. Subsequent connections behave normally. timeoutFirstAttempt atomic.Int32 @@ -84,7 +95,8 @@ func newFakeProxy(t *testing.T, scenario string) *fakeProxy { func needsUDPRelay(scenario string) bool { switch scenario { - case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok": + case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok", + "voice_quality_warn", "voice_quality_fail", "voice_srv_blocked": return true default: return false @@ -184,6 +196,23 @@ func (fp *fakeProxy) handle(conn net.Conn) { switch cmdReq.cmd { case 0x01: // CONNECT + // voice-srv block: refuse CONNECT to anything that isn't the + // gateway/api passthrough target. Only checked when the test + // explicitly sets blockVoiceCONNECT — keeps gateway+api happy. + if fp.blockVoiceCONNECT.Load() { + isAPITarget := fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort + isGatewayTarget := cmdReq.port == 443 && (cmdReq.host == "127.0.0.1" || cmdReq.host == "localhost") && fp.apiTargetHost == "" + _ = isGatewayTarget + if !isAPITarget { + // Refuse only :443 voice probes; allow gateway probe + // (which has its own configured port from stubGatewayAddr, + // not 443). Logic: target port == 443 → voice probe → refuse. + if cmdReq.port == 443 { + _, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + return + } + } + } switch fp.scenario { case "connect_refused": _, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) @@ -255,6 +284,16 @@ func (fp *fakeProxy) runRelay(uconn *net.UDPConn) { if err != nil { return } + // Optional packet-drop simulation. udpDropEveryN of value 1 drops + // everything; 2 drops every other packet; 10 drops 10%. + if dropN := fp.udpDropEveryN.Load(); dropN > 0 { + c := fp.udpRelayCount.Add(1) + if c%dropN == 0 { + continue + } + } else { + fp.udpRelayCount.Add(1) + } if n < 10 { continue } @@ -478,15 +517,25 @@ func hostPort(addr string) (string, int) { // proxyConfig builds a Config pointed at the given fakeProxy with sane // short timeouts for tests. +// +// Note on voice-srv: the default Config.VoiceServerHostnames would hit +// real Discord DNS during go test — we don't want that. Override with a +// single local hostname ("localhost") so DNS always resolves to 127.0.0.1 +// and the SOCKS5 CONNECT goes back to the fake proxy itself, which +// returns REP=00 on a happy CONNECT or REP=05 when blockVoiceCONNECT is +// flipped. func proxyConfig(fp *fakeProxy, useAuth bool) Config { host, port := hostPort(fp.addr) cfg := Config{ - ProxyHost: host, - ProxyPort: port, - UseAuth: useAuth, - PerTestTimeout: 500 * time.Millisecond, - MaxRetries: 1, - RetryBackoff: 30 * time.Millisecond, + ProxyHost: host, + ProxyPort: port, + UseAuth: useAuth, + PerTestTimeout: 500 * time.Millisecond, + MaxRetries: 1, + RetryBackoff: 30 * time.Millisecond, + VoiceBurstCount: 10, + VoiceBurstInterval: 5 * time.Millisecond, + VoiceServerHostnames: []string{"localhost"}, } if useAuth { cfg.ProxyLogin = "u" @@ -560,7 +609,7 @@ func TestRun_HappyNoAuth(t *testing.T) { ch := Run(context.Background(), cfg) results := drainResults(t, ch, 10*time.Second) - expected := []string{"tcp", "greet", "connect", "udp", "stun", "api"} + expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "api"} finals := map[string]Result{} for _, id := range expected { r, ok := finalByID(results, id) @@ -579,6 +628,8 @@ func TestRun_HappyNoAuth(t *testing.T) { // Metrics format spot-checks. assert.Contains(t, finals["greet"].Metric, "no auth") assert.Equal(t, "REP=00", finals["connect"].Metric) + assert.Contains(t, finals["voice-quality"].Metric, "loss=") + assert.Contains(t, finals["voice-srv"].Metric, "1/1 regions") assert.Equal(t, "HTTP 200", finals["api"].Metric) } @@ -592,7 +643,7 @@ func TestRun_HappyWithAuth(t *testing.T) { ch := Run(context.Background(), cfg) results := drainResults(t, ch, 10*time.Second) - expected := []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api"} + expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api"} for _, id := range expected { r, ok := finalByID(results, id) require.True(t, ok, "missing %s; results=%+v", id, results) @@ -623,7 +674,7 @@ func TestRun_AuthRejected(t *testing.T) { assert.Equal(t, StatusFailed, rA.Status) assert.NotEmpty(t, rA.Hint) - for _, id := range []string{"connect", "udp", "stun", "api"} { + for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -648,7 +699,7 @@ func TestRun_AllMethodsRejected(t *testing.T) { assert.Equal(t, StatusFailed, rG.Status) assert.NotEmpty(t, rG.Hint) - for _, id := range []string{"connect", "udp", "stun", "api"} { + for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -680,9 +731,13 @@ func TestRun_ConnectRefused(t *testing.T) { rU, _ := finalByID(results, "udp") assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect") - // stun depends on udp → passes too. - rS, _ := finalByID(results, "stun") - assert.Equal(t, StatusPassed, rS.Status) + // voice-quality depends on udp → passes too. + rVQ, _ := finalByID(results, "voice-quality") + assert.Equal(t, StatusPassed, rVQ.Status) + + // voice-srv depends on connect → skipped. + rVS, _ := finalByID(results, "voice-srv") + assert.Equal(t, StatusSkipped, rVS.Status) // api depends on connect → skipped. rA, _ := finalByID(results, "api") @@ -708,8 +763,13 @@ func TestRun_UDPUnsupported(t *testing.T) { require.Equal(t, StatusFailed, rU.Status) assert.NotEmpty(t, rU.Hint) - rS, _ := finalByID(results, "stun") - assert.Equal(t, StatusSkipped, rS.Status) + // voice-quality depends on udp → skipped. + rVQ, _ := finalByID(results, "voice-quality") + assert.Equal(t, StatusSkipped, rVQ.Status) + + // voice-srv depends on connect (passed) → runs and passes. + rVS, _ := finalByID(results, "voice-srv") + assert.Equal(t, StatusPassed, rVS.Status) rA, _ := finalByID(results, "api") assert.Equal(t, StatusPassed, rA.Status) @@ -747,7 +807,7 @@ func TestRun_TimeoutThenOK(t *testing.T) { assert.Equal(t, 2, greetEvents[3].Attempt) // All seven non-auth tests should ultimately pass. - for _, id := range []string{"tcp", "greet", "connect", "udp", "stun", "api"} { + for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r) @@ -813,8 +873,9 @@ func TestRun_CancelledMidFlight(t *testing.T) { // Either: one cancelled-failed + rest cancelled-skipped, OR all // cancelled-skipped (if cancellation hit before next test even // started). Both are acceptable. - // Without auth, 5 tests remain after tcp (greet/connect/udp/stun/api). - // Cancel may race with greet completing successfully, so accept ≥4. + // Without auth, 6 tests remain after tcp (greet/connect/udp/ + // voice-quality/voice-srv/api). Cancel may race with greet + // completing successfully, so accept ≥4. assert.GreaterOrEqual(t, failed+skipped, 4, "expected at least 4 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results) } @@ -866,6 +927,79 @@ func TestRun_NegativeRetryClamped(t *testing.T) { assert.Equal(t, 500*time.Millisecond, out.RetryBackoff) } +// TestRun_VoiceQualityWarn drives the relay to drop ~1 in 10 packets, +// which puts the burst into the warn band (loss in (5, 15]%, jitter and +// p50 typically tiny on localhost). Asserts StatusWarn and that the +// metric reports a non-zero loss. +func TestRun_VoiceQualityWarn(t *testing.T) { + fp := newFakeProxy(t, "voice_quality_warn") + cfg := proxyConfig(fp, false) + cfg.DiscordGateway = stubGatewayAddr(t) + cfg.DiscordAPI = stubAPIServer(t, fp, 200) + cfg.StunServer = "127.0.0.1:65000" + // Burst of 30 with 1-in-10 drop → ~3 lost ≈ 10%. + cfg.VoiceBurstCount = 30 + cfg.VoiceBurstInterval = 5 * time.Millisecond + cfg.PerTestTimeout = 1 * time.Second + fp.udpDropEveryN.Store(10) + + ch := Run(context.Background(), cfg) + results := drainResults(t, ch, 15*time.Second) + + rVQ, ok := finalByID(results, "voice-quality") + require.True(t, ok) + assert.Equal(t, StatusWarn, rVQ.Status, "got %+v", rVQ) + assert.Contains(t, rVQ.Metric, "loss=") + assert.NotEmpty(t, rVQ.Hint) +} + +// TestRun_VoiceQualityFail drives the relay to drop 4 of every 5 packets +// (~80% loss) — well past the fail threshold. +func TestRun_VoiceQualityFail(t *testing.T) { + fp := newFakeProxy(t, "voice_quality_fail") + cfg := proxyConfig(fp, false) + cfg.DiscordGateway = stubGatewayAddr(t) + cfg.DiscordAPI = stubAPIServer(t, fp, 200) + cfg.StunServer = "127.0.0.1:65000" + cfg.VoiceBurstCount = 30 + cfg.VoiceBurstInterval = 3 * time.Millisecond + cfg.PerTestTimeout = 1 * time.Second + cfg.MaxRetries = 0 + // Drop everything: dropEveryN=1 means EVERY packet dropped → 100%. + // Use 2 for ~50%, 1 for 100. We want fail-band — pick 1 to guarantee + // "no replies received". + fp.udpDropEveryN.Store(1) + + ch := Run(context.Background(), cfg) + results := drainResults(t, ch, 15*time.Second) + + rVQ, ok := finalByID(results, "voice-quality") + require.True(t, ok) + assert.Equal(t, StatusFailed, rVQ.Status, "got %+v", rVQ) + assert.NotEmpty(t, rVQ.Hint) +} + +// TestRun_VoiceSrvAllResolvedNoneReachable: DNS resolves localhost but +// the proxy refuses CONNECT to :443. Expect StatusWarn with "0/1 regions +// reachable" metric. +func TestRun_VoiceSrvAllResolvedNoneReachable(t *testing.T) { + fp := newFakeProxy(t, "voice_srv_blocked") + cfg := proxyConfig(fp, false) + cfg.DiscordGateway = stubGatewayAddr(t) + cfg.DiscordAPI = stubAPIServer(t, fp, 200) + cfg.StunServer = "127.0.0.1:65000" + fp.blockVoiceCONNECT.Store(true) + + ch := Run(context.Background(), cfg) + results := drainResults(t, ch, 15*time.Second) + + rVS, ok := finalByID(results, "voice-srv") + require.True(t, ok) + assert.Equal(t, StatusWarn, rVS.Status, "got %+v", rVS) + assert.Equal(t, "0/1 regions reachable", rVS.Metric) + assert.NotEmpty(t, rVS.Hint) +} + func TestExtractRawHex(t *testing.T) { cases := []struct { in, want string diff --git a/internal/checker/hints.go b/internal/checker/hints.go index 64a8d37..489f90a 100644 --- a/internal/checker/hints.go +++ b/internal/checker/hints.go @@ -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, "; ") + "." +} diff --git a/internal/checker/hints_test.go b/internal/checker/hints_test.go index 5a4232d..662f5d3 100644 --- a/internal/checker/hints_test.go +++ b/internal/checker/hints_test.go @@ -18,7 +18,7 @@ func TestHintFor(t *testing.T) { 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"} { + for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api", "unknown"} { assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id) assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id) } @@ -42,8 +42,8 @@ func TestHintFor(t *testing.T) { {"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"}, + {"voice_quality_no_mapped_xor", "voice-quality", ErrSTUNNoMappedAddress, "XOR-MAPPED"}, + {"voice_quality_timeout_mentions_stun", "voice-quality", &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"}, @@ -111,13 +111,19 @@ func TestHintFor_PerStepBranches(t *testing.T) { {"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"}, + // voice-quality: every sentinel branch (collapsed in 2026-05-01 + // rewrite into a single user-visible message rather than + // per-error "магник cookie" / "семейство адресов" exposition) + {"voice_quality_too_short", "voice-quality", ErrSTUNTooShort, "мусор"}, + {"voice_quality_bad_magic", "voice-quality", ErrSTUNBadMagicCookie, "мусор"}, + {"voice_quality_not_success", "voice-quality", ErrSTUNNotSuccess, "мусор"}, + {"voice_quality_txid_mismatch", "voice-quality", ErrSTUNTxIDMismatch, "мусор"}, + {"voice_quality_unsupported_family", "voice-quality", ErrSTUNUnsupportedFamily, "мусор"}, + {"voice_quality_fallback", "voice-quality", errors.New("weird"), "качество"}, + + // voice-srv: DNS error, timeout, generic + {"voice_srv_timeout", "voice-srv", &timeoutOnlyError{}, "таймаут"}, + {"voice_srv_generic", "voice-srv", errors.New("boom"), "boom"}, // api: timeout vs generic {"api_timeout", "api", &timeoutOnlyError{}, "таймаут"}, @@ -146,14 +152,15 @@ func TestSocks5ReplyHint_DefaultStep(t *testing.T) { 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", + "tcp": "TCP", + "greet": "приветствие SOCKS5", + "auth": "авторизация SOCKS5", + "connect": "TCP-туннель к Discord", + "udp": "UDP ASSOCIATE", + "voice-quality": "качество UDP-канала", + "voice-srv": "доступность voice-серверов Discord", + "api": "Discord API", + "weirdo": "weirdo", } for in, want := range cases { t.Run(in, func(t *testing.T) { diff --git a/internal/checker/voice.go b/internal/checker/voice.go new file mode 100644 index 0000000..6a22861 --- /dev/null +++ b/internal/checker/voice.go @@ -0,0 +1,438 @@ +package checker + +// voice.go — predictive voice diagnostics. +// +// Two primitives sit on top of the SOCKS5/STUN building blocks already +// in this package: +// +// - runVoiceQualityBurst: fire a burst of STUN binding requests through +// an open SOCKS5 UDP relay, then derive packet-loss / jitter / +// percentile-RTT from the replies. A single round-trip says the relay +// accepts UDP; a 30-packet burst tells you whether voice will actually +// hold together. +// +// - runVoiceServerProbe: parallel-DNS a list of .discord.media +// hostnames, then SOCKS5 CONNECT to :443 on each, recording which +// regions are reachable through the proxy. DNS-resolves-but-CONNECT-fails +// is a very common Russian-DPI failure mode that all five preceding +// SOCKS5 sanity checks miss. + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "math" + "net" + "sort" + "sync" + "time" +) + +// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5 +// relay. All fields are zero on a hard failure (no replies at all). +type VoiceQualityResult struct { + Sent int + Received int + LossPct float64 // 0..100 + JitterMS float64 // mean abs of inter-arrival deltas in ms + P50RTTMS float64 // median round-trip in ms + P95RTTMS float64 // 95th percentile (informational, not gated) +} + +// runVoiceQualityBurst sends `count` STUN binding requests through the +// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort, +// spaced `interval` apart. It listens on udpConn until +// `time.Now() + max(interval, 200ms)` after the last send, then returns +// the aggregate result. +// +// Each outbound datagram has the SOCKS5 UDP header +// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by +// a 20-byte STUN binding request. We track each request by its +// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP +// header before being handed to ParseBindingResponse. +// +// Returns an error only when ctx is cancelled or stunHost can't be +// resolved to IPv4. A 100% loss is NOT an error — the caller decides +// what status to assign; we just report Sent=count, Received=0. +func runVoiceQualityBurst( + ctx context.Context, + udpConn net.PacketConn, + relayAddr *net.UDPAddr, + stunHost string, + stunPort uint16, + count int, + interval time.Duration, +) (VoiceQualityResult, error) { + if count <= 0 { + return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0") + } + + // Resolve stunHost to IPv4. + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost) + if err != nil { + return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err) + } + var stunIP4 net.IP + for _, ip := range ips { + if v4 := ip.To4(); v4 != nil { + stunIP4 = v4 + break + } + } + if stunIP4 == nil { + return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost) + } + + // Per-tx state: send-time + arrival-time. + type entry struct { + sentAt time.Time + arrivedAt time.Time + received bool + } + var ( + mu sync.Mutex + entries = make(map[[12]byte]*entry, count) + arrivals = make([]time.Time, 0, count) // for jitter (in arrival order) + rtts = make([]float64, 0, count) // milliseconds + ) + + // Reader goroutine: loops on ReadFrom until deadline expires. + doneRead := make(chan struct{}) + go func() { + defer close(doneRead) + buf := make([]byte, 1500) + for { + n, _, rerr := udpConn.ReadFrom(buf) + if rerr != nil { + // Deadline expired or conn closed — exit. + return + } + if n < 10 { + continue + } + // Validate SOCKS5 UDP wrapper, derive header length. + if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 { + continue + } + var hdrLen int + switch buf[3] { + case 0x01: + hdrLen = 10 + case 0x04: + hdrLen = 22 + case 0x03: + if n < 5 { + continue + } + hdrLen = 4 + 1 + int(buf[4]) + 2 + default: + continue + } + if n < hdrLen+20 { + continue + } + stunReply := buf[hdrLen:n] + // Pull the transaction ID out of the STUN header so we + // can look up the matching send-time. ParseBindingResponse + // rejects mismatched txIDs, so we feed it the *expected* + // id from the entries map. + var txID [12]byte + copy(txID[:], stunReply[8:20]) + + now := time.Now() + mu.Lock() + ent, ok := entries[txID] + if !ok || ent.received { + mu.Unlock() + continue + } + if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil { + mu.Unlock() + continue + } + ent.arrivedAt = now + ent.received = true + arrivals = append(arrivals, now) + rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0) + mu.Unlock() + } + }() + + // Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body + // is per-packet (fresh tx id each). + hdr := make([]byte, 0, 10) + hdr = append(hdr, 0x00, 0x00, 0x00, 0x01) + hdr = append(hdr, stunIP4...) + var portBuf [2]byte + binary.BigEndian.PutUint16(portBuf[:], stunPort) + hdr = append(hdr, portBuf[:]...) + + // Send burst. + ticker := time.NewTicker(interval) + defer ticker.Stop() + + sent := 0 +sendLoop: + for sent < count { + // Make a fresh tx id and STUN request. + txID, terr := NewTransactionID() + if terr != nil { + break + } + stunReq := EncodeBindingRequest(txID) + dgram := make([]byte, 0, len(hdr)+len(stunReq)) + dgram = append(dgram, hdr...) + dgram = append(dgram, stunReq...) + + // Record send-time *before* the write. Note: we register the + // entry into the map BEFORE Write so the reader can never get a + // reply for an unknown tx (would happen on a very fast localhost + // echo). + now := time.Now() + mu.Lock() + entries[txID] = &entry{sentAt: now} + mu.Unlock() + + if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil { + // Write failure aborts the burst — but we still wait for + // any in-flight replies. Treat as "sent so far". + break + } + sent++ + + if sent >= count { + break sendLoop + } + + // Wait for next tick OR ctx cancel. + select { + case <-ticker.C: + case <-ctx.Done(): + break sendLoop + } + } + + // Wait window for stragglers — at least 200ms past last send. + wait := interval + if wait < 200*time.Millisecond { + wait = 200 * time.Millisecond + } + deadline := time.Now().Add(wait) + _ = udpConn.SetReadDeadline(deadline) + + // Wait for reader to exit. ctx cancel still races: bound by deadline. + select { + case <-doneRead: + case <-ctx.Done(): + // Force the reader to exit ASAP by setting a past deadline. + _ = udpConn.SetReadDeadline(time.Unix(0, 1)) + <-doneRead + } + // Reset deadline so subsequent users of the conn aren't surprised. + _ = udpConn.SetReadDeadline(time.Time{}) + + // Compute aggregates. + mu.Lock() + defer mu.Unlock() + + received := len(rtts) + res := VoiceQualityResult{ + Sent: sent, + Received: received, + } + if sent > 0 { + res.LossPct = float64(sent-received) / float64(sent) * 100.0 + } + if received >= 2 { + // Sort arrivals to compute inter-arrival jitter in chronological order. + // arrivals is already chronological (appended as packets came in). + var diffs []float64 + for i := 1; i < len(arrivals); i++ { + d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0 + diffs = append(diffs, d) + } + // mean abs of consecutive deltas of inter-arrival diffs. + if len(diffs) >= 2 { + var sum float64 + for i := 1; i < len(diffs); i++ { + sum += math.Abs(diffs[i] - diffs[i-1]) + } + res.JitterMS = sum / float64(len(diffs)-1) + } else if len(diffs) == 1 { + // Only two arrivals — single delta, no second-order jitter. + res.JitterMS = 0 + } + } + if received > 0 { + // percentile. + sorted := make([]float64, len(rtts)) + copy(sorted, rtts) + sort.Float64s(sorted) + p50idx := len(sorted) / 2 + if p50idx >= len(sorted) { + p50idx = len(sorted) - 1 + } + res.P50RTTMS = sorted[p50idx] + p95idx := int(0.95 * float64(len(sorted))) + if p95idx >= len(sorted) { + p95idx = len(sorted) - 1 + } + res.P95RTTMS = sorted[p95idx] + } + + return res, nil +} + +// VoiceServerProbeResult lists Discord voice-domain hostnames split by +// outcome. Resolved is ordered by input position; Reachable is too. +type VoiceServerProbeResult struct { + Resolved []string // hostnames that got at least one A record + Reachable []string // hostnames that succeeded SOCKS5 CONNECT + Unresolved []string // hostnames that didn't resolve at all + UnreachableButResolved []string // resolved but proxy CONNECT failed +} + +// runVoiceServerProbe resolves each hostname and, for those that resolved +// to ≥ 1 IPv4, performs a SOCKS5 CONNECT to host:443 through proxyAddr. +// REP=00 is enough — we don't actually do TLS. +// +// DNS goes through the OS resolver (not the proxy). For tests, pass +// hostnames that always resolve locally (e.g. "localhost"). +// +// Returns an error only when ctx is cancelled. +func runVoiceServerProbe( + ctx context.Context, + hostnames []string, + proxyAddr string, + useAuth bool, + login, password string, + dialTimeout time.Duration, +) (VoiceServerProbeResult, error) { + if dialTimeout <= 0 { + dialTimeout = 1 * time.Second + } + + const dnsConcurrency = 16 + const connectConcurrency = 8 + + // Phase 1: parallel DNS. + type dnsOut struct { + idx int + host string + resolved bool + } + dnsResults := make([]dnsOut, len(hostnames)) + var wg sync.WaitGroup + dnsSem := make(chan struct{}, dnsConcurrency) + for i, h := range hostnames { + wg.Add(1) + dnsSem <- struct{}{} + go func(i int, h string) { + defer wg.Done() + defer func() { <-dnsSem }() + ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", h) + ok := err == nil && len(ips) > 0 + dnsResults[i] = dnsOut{idx: i, host: h, resolved: ok} + }(i, h) + } + wg.Wait() + + if cerr := ctx.Err(); cerr != nil { + return VoiceServerProbeResult{}, cerr + } + + res := VoiceServerProbeResult{} + // Build resolved/unresolved lists in input order. + resolvedHosts := make([]string, 0, len(hostnames)) + for _, dr := range dnsResults { + if dr.resolved { + res.Resolved = append(res.Resolved, dr.host) + resolvedHosts = append(resolvedHosts, dr.host) + } else { + res.Unresolved = append(res.Unresolved, dr.host) + } + } + + if len(resolvedHosts) == 0 { + return res, nil + } + + // Phase 2: parallel SOCKS5 CONNECT. + reachable := make([]bool, len(resolvedHosts)) + connectSem := make(chan struct{}, connectConcurrency) + wg = sync.WaitGroup{} + for i, h := range resolvedHosts { + wg.Add(1) + connectSem <- struct{}{} + go func(i int, h string) { + defer wg.Done() + defer func() { <-connectSem }() + reachable[i] = probeSocks5Connect(ctx, proxyAddr, useAuth, login, password, h, 443, dialTimeout) + }(i, h) + } + wg.Wait() + + if cerr := ctx.Err(); cerr != nil { + return res, cerr + } + for i, h := range resolvedHosts { + if reachable[i] { + res.Reachable = append(res.Reachable, h) + } else { + res.UnreachableButResolved = append(res.UnreachableButResolved, h) + } + } + return res, nil +} + +// probeSocks5Connect opens one fresh TCP connection to proxyAddr, runs +// the SOCKS5 handshake and CONNECT request to host:port, and returns +// true iff the proxy replied REP=00. Always closes the conn before +// returning. +func probeSocks5Connect( + parent context.Context, + proxyAddr string, + useAuth bool, + login, password string, + host string, + port uint16, + dialTimeout time.Duration, +) bool { + ctx, cancel := context.WithTimeout(parent, dialTimeout) + defer cancel() + + var d net.Dialer + conn, err := d.DialContext(ctx, "tcp", proxyAddr) + if err != nil { + return false + } + defer conn.Close() + + if _, _, gerr := socks5Greeting(ctx, conn, useAuth); gerr != nil { + return false + } + if useAuth { + if _, aerr := socks5Auth(ctx, conn, login, password); aerr != nil { + return false + } + } + if _, cerr := socks5Connect(ctx, conn, host, port); cerr != nil { + return false + } + return true +} + +// defaultVoiceHostnames is the built-in list used when +// Config.VoiceServerHostnames is empty. Order matters — the GUI shows +// the first three reachable regions in the metric line. +var defaultVoiceHostnames = []string{ + "russia.discord.media", "russia2.discord.media", + "frankfurt.discord.media", "europe.discord.media", + "singapore.discord.media", "japan.discord.media", + "us-east.discord.media", "us-west.discord.media", + "brazil.discord.media", "india.discord.media", + "hongkong.discord.media", "southkorea.discord.media", + "sydney.discord.media", "southafrica.discord.media", + "dubai.discord.media", "atlanta.discord.media", +} diff --git a/internal/checker/voice_test.go b/internal/checker/voice_test.go new file mode 100644 index 0000000..e71a370 --- /dev/null +++ b/internal/checker/voice_test.go @@ -0,0 +1,300 @@ +package checker + +import ( + "context" + "encoding/binary" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeUDPRelay listens on a UDP socket and echoes SOCKS5-wrapped STUN +// binding requests as a synthetic Binding Success Response, just like +// fakeProxy.runRelay in checker_test.go but standalone (no SOCKS5 TCP +// control channel needed). dropEveryN > 0 drops every Nth packet. +type fakeUDPRelay struct { + conn *net.UDPConn + addr *net.UDPAddr + dropEveryN atomic.Int32 + count atomic.Int32 +} + +func newFakeUDPRelay(t *testing.T) *fakeUDPRelay { + t.Helper() + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + uconn := pc.(*net.UDPConn) + r := &fakeUDPRelay{ + conn: uconn, + addr: uconn.LocalAddr().(*net.UDPAddr), + } + t.Cleanup(func() { _ = uconn.Close() }) + go r.serve() + return r +} + +func (r *fakeUDPRelay) serve() { + buf := make([]byte, 2048) + for { + n, src, err := r.conn.ReadFromUDP(buf) + if err != nil { + return + } + if dropN := r.dropEveryN.Load(); dropN > 0 { + c := r.count.Add(1) + if c%dropN == 0 { + continue + } + } else { + r.count.Add(1) + } + if n < 10 { + continue + } + if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 { + continue + } + var hdrLen int + switch buf[3] { + case 0x01: + hdrLen = 10 + case 0x04: + hdrLen = 22 + case 0x03: + if n < 5 { + continue + } + hdrLen = 4 + 1 + int(buf[4]) + 2 + default: + continue + } + if n < hdrLen+20 { + continue + } + stunReq := buf[hdrLen:n] + var txID [12]byte + copy(txID[:], stunReq[8:20]) + + ip4 := src.IP.To4() + if ip4 == nil { + continue + } + xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16) + xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie + + stunResp := make([]byte, 20+12) + binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse) + binary.BigEndian.PutUint16(stunResp[2:4], 12) + binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie) + copy(stunResp[8:20], txID[:]) + binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress) + binary.BigEndian.PutUint16(stunResp[22:24], 8) + stunResp[24] = 0 + stunResp[25] = 0x01 + binary.BigEndian.PutUint16(stunResp[26:28], xport) + binary.BigEndian.PutUint32(stunResp[28:32], xaddr) + + out := make([]byte, 0, 10+len(stunResp)) + out = append(out, 0x00, 0x00, 0x00, 0x01) + out = append(out, ip4...) + var portBuf [2]byte + binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port)) + out = append(out, portBuf[:]...) + out = append(out, stunResp...) + + _, _ = r.conn.WriteToUDP(out, src) + } +} + +// TestVoiceQualityBurst_Math: full 30-of-30 reception on localhost, all +// RTTs in single-digit milliseconds. +func TestVoiceQualityBurst_Math(t *testing.T) { + relay := newFakeUDPRelay(t) + clientPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer clientPC.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr, + "localhost", 19302, 30, 5*time.Millisecond) + require.NoError(t, err) + + assert.Equal(t, 30, res.Sent) + assert.Equal(t, 30, res.Received) + assert.InDelta(t, 0.0, res.LossPct, 0.001) + assert.Less(t, res.P50RTTMS, 50.0, "loopback p50 should be tiny") +} + +// TestVoiceQualityBurst_HalfLoss verifies the loss-percentage math when +// the relay drops half the packets. +func TestVoiceQualityBurst_HalfLoss(t *testing.T) { + relay := newFakeUDPRelay(t) + relay.dropEveryN.Store(2) // every other packet → 50% loss + + clientPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer clientPC.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr, + "localhost", 19302, 20, 3*time.Millisecond) + require.NoError(t, err) + + assert.Equal(t, 20, res.Sent) + assert.InDelta(t, 50.0, res.LossPct, 5.0, "expected ~50%% loss got %+v", res) +} + +// TestVoiceQualityBurst_AllDropped: dropEveryN=1 → 100% loss. Should NOT +// return an error; should report Sent=N, Received=0. +func TestVoiceQualityBurst_AllDropped(t *testing.T) { + relay := newFakeUDPRelay(t) + relay.dropEveryN.Store(1) + + clientPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer clientPC.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr, + "localhost", 19302, 10, 3*time.Millisecond) + require.NoError(t, err) + + assert.Equal(t, 10, res.Sent) + assert.Equal(t, 0, res.Received) + assert.InDelta(t, 100.0, res.LossPct, 0.001) + assert.Equal(t, 0.0, res.P50RTTMS) + assert.Equal(t, 0.0, res.JitterMS) +} + +// TestVoiceQualityBurst_ZeroCount: count=0 → error (defensive). +func TestVoiceQualityBurst_ZeroCount(t *testing.T) { + relay := newFakeUDPRelay(t) + clientPC, err := net.ListenPacket("udp", "127.0.0.1:0") + require.NoError(t, err) + defer clientPC.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + _, err = runVoiceQualityBurst(ctx, clientPC, relay.addr, + "localhost", 19302, 0, 5*time.Millisecond) + assert.Error(t, err) +} + +// TestVoiceServerProbe_HappyAndBlocked: against a fake SOCKS5 server, one +// hostname (localhost) succeeds (REP=00) and another (127.0.0.1) gets +// blocked (REP=05). Both resolve at the OS level, so Resolved should be +// {localhost, 127.0.0.1}; Reachable should be {localhost}; Unreachable +// should be {127.0.0.1}. +func TestVoiceServerProbe_HappyAndBlocked(t *testing.T) { + // Stand up a tiny fake SOCKS5 server that completes greet+CONNECT + // only for hostname "localhost"; CONNECT to "127.0.0.1" is refused + // with REP=05. We don't need full RFC 1928 — just enough to drive + // the probe path. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer ln.Close() + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + _ = c.SetDeadline(time.Now().Add(2 * time.Second)) + // Read greeting: VER NMETHODS METHODS... + hdr := make([]byte, 2) + if _, err := readFull(c, hdr); err != nil { + return + } + if hdr[0] != 0x05 { + return + } + methods := make([]byte, hdr[1]) + if _, err := readFull(c, methods); err != nil { + return + } + // Reply: no-auth. + _, _ = c.Write([]byte{0x05, 0x00}) + // Read CONNECT request: VER CMD RSV ATYP... + h2 := make([]byte, 4) + if _, err := readFull(c, h2); err != nil { + return + } + var host string + switch h2[3] { + case 0x01: + ip := make([]byte, 4) + _, _ = readFull(c, ip) + host = net.IP(ip).String() + case 0x03: + l := make([]byte, 1) + _, _ = readFull(c, l) + name := make([]byte, int(l[0])) + _, _ = readFull(c, name) + host = string(name) + } + port := make([]byte, 2) + _, _ = readFull(c, port) + // Reply REP=00 only for hostname "localhost"; refuse otherwise. + if host == "localhost" { + _, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + } else { + _, _ = c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) + } + }(c) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := runVoiceServerProbe(ctx, + []string{"localhost", "127.0.0.1"}, ln.Addr().String(), + false, "", "", 1*time.Second) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"localhost", "127.0.0.1"}, res.Resolved) + assert.Equal(t, []string{"localhost"}, res.Reachable) + assert.Equal(t, []string{"127.0.0.1"}, res.UnreachableButResolved) + assert.Empty(t, res.Unresolved) +} + +// TestVoiceServerProbe_EmptyHostnameList: zero-length input → zero-length +// Resolved/Reachable, no error. +func TestVoiceServerProbe_EmptyHostnameList(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + res, err := runVoiceServerProbe(ctx, + []string{}, "127.0.0.1:1", false, "", "", 100*time.Millisecond) + require.NoError(t, err) + + assert.Empty(t, res.Resolved) + assert.Empty(t, res.Reachable) + assert.Empty(t, res.Unresolved) +} + +// readFull is a tiny helper to avoid importing io just for this. +func readFull(c net.Conn, buf []byte) (int, error) { + got := 0 + for got < len(buf) { + n, err := c.Read(buf[got:]) + got += n + if err != nil { + return got, err + } + } + return got, nil +}