diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 71cc985..e40d2a9 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -123,7 +123,6 @@ func Run(ctx context.Context, cfg Config) <-chan Result { } e.runConnect() e.runUDP() - e.runVoiceQuality() e.runAPI() }() @@ -522,6 +521,38 @@ func (e *executor) runConnect() { } // runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE. +// isUnroutableRelayIP returns true for IPs we shouldn't trust as the +// real relay endpoint when the proxy advertised them in BND.ADDR: +// 0.0.0.0 (per RFC 1928 spec), private RFC 1918 ranges (mihomo on a +// LAN can return its 192.168.x.x interface), and loopback. Caller +// should substitute the proxy host instead. +func isUnroutableRelayIP(ip net.IP) bool { + if ip == nil || ip.IsUnspecified() || ip.IsLoopback() { + return true + } + v4 := ip.To4() + if v4 == nil { + return false + } + // 10.0.0.0/8 + if v4[0] == 10 { + return true + } + // 172.16.0.0/12 + if v4[0] == 172 && v4[1] >= 16 && v4[1] <= 31 { + return true + } + // 192.168.0.0/16 + if v4[0] == 192 && v4[1] == 168 { + return true + } + // 169.254.0.0/16 (link-local) + if v4[0] == 169 && v4[1] == 254 { + return true + } + return false +} + func (e *executor) runUDP() { dep := e.greetOK && (!e.cfg.UseAuth || e.authOK) if e.shouldSkip("udp", dep) { @@ -552,204 +583,24 @@ func (e *executor) runUDP() { if uerr != nil { return "", uerr } + // RFC 1928 says when BND.ADDR == 0.0.0.0, substitute the proxy + // host. We extend that: when the proxy returns a *private* IP + // (mihomo on LAN often advertises its 192.168.x.x interface + // because that's the iface it bound), it's unreachable for + // clients outside that LAN — substitute with the proxy host + // the user is already connecting to. + if isUnroutableRelayIP(relay.IP) { + if hostIP := net.ParseIP(e.cfg.ProxyHost); hostIP != nil { + relay.IP = hostIP + } + } e.udpRelay = relay return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil }) e.udpOK = ok } -// 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: "voice-quality", - Status: StatusFailed, - Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()), - Hint: hintFor("voice-quality", splitErr), - Attempt: 1, - }) - return - } - port64, perr := strconv.ParseUint(portStr, 10, 16) - if perr != nil { - e.emit(Result{ - ID: "voice-quality", - Status: StatusFailed, - Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()), - Hint: hintFor("voice-quality", perr), - Attempt: 1, - }) - return - } - stunPort := uint16(port64) - - 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, 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 - - res, berr := runVoiceQualityBurst( - attemptCtx, pc, e.udpRelay, - host, stunPort, - e.cfg.VoiceBurstCount, e.cfg.VoiceBurstInterval, - ) - dur := time.Since(start) - cancel() - - if berr != nil { - // Resolution / cancellation. Treat ctx-cancel separately. - if e.ctx.Err() != nil { - e.emitCancelled("voice-quality", attempt, dur) - return - } - 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 - } - - // 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 - } - - 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 - } - } -} - -// runAPI — Test 7: HTTP GET Discord API gateway URL through the proxy. +// runAPI — Test 6: HTTP GET Discord API gateway URL through the proxy. func (e *executor) runAPI() { if e.shouldSkip("api", e.connectOK) { return diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index d815540..e65de35 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -579,7 +579,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", "voice-quality", "api"} + expected := []string{"tcp", "greet", "connect", "udp", "api"} finals := map[string]Result{} for _, id := range expected { r, ok := finalByID(results, id) @@ -598,7 +598,6 @@ 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.Equal(t, "HTTP 200", finals["api"].Metric) } @@ -612,7 +611,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", "voice-quality", "api"} + expected := []string{"tcp", "greet", "auth", "connect", "udp", "api"} for _, id := range expected { r, ok := finalByID(results, id) require.True(t, ok, "missing %s; results=%+v", id, results) @@ -643,7 +642,7 @@ func TestRun_AuthRejected(t *testing.T) { assert.Equal(t, StatusFailed, rA.Status) assert.NotEmpty(t, rA.Hint) - for _, id := range []string{"connect", "udp", "voice-quality", "api"} { + for _, id := range []string{"connect", "udp", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -668,7 +667,7 @@ func TestRun_AllMethodsRejected(t *testing.T) { assert.Equal(t, StatusFailed, rG.Status) assert.NotEmpty(t, rG.Hint) - for _, id := range []string{"connect", "udp", "voice-quality", "api"} { + for _, id := range []string{"connect", "udp", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -700,10 +699,6 @@ func TestRun_ConnectRefused(t *testing.T) { rU, _ := finalByID(results, "udp") assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect") - // voice-quality depends on udp → passes too. - rVQ, _ := finalByID(results, "voice-quality") - assert.Equal(t, StatusPassed, rVQ.Status) - // api depends on connect → skipped. rA, _ := finalByID(results, "api") assert.Equal(t, StatusSkipped, rA.Status) @@ -728,10 +723,6 @@ func TestRun_UDPUnsupported(t *testing.T) { require.Equal(t, StatusFailed, rU.Status) assert.NotEmpty(t, rU.Hint) - // voice-quality depends on udp → skipped. - rVQ, _ := finalByID(results, "voice-quality") - assert.Equal(t, StatusSkipped, rVQ.Status) - rA, _ := finalByID(results, "api") assert.Equal(t, StatusPassed, rA.Status) } @@ -768,7 +759,7 @@ func TestRun_TimeoutThenOK(t *testing.T) { assert.Equal(t, 2, greetEvents[3].Attempt) // All non-auth tests should ultimately pass. - for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"} { + for _, id := range []string{"tcp", "greet", "connect", "udp", "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) @@ -888,58 +879,6 @@ 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) -} - func TestExtractRawHex(t *testing.T) { cases := []struct { in, want string diff --git a/internal/gui/app.go b/internal/gui/app.go index bafb66d..beb4244 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -189,7 +189,13 @@ func (a *App) StartEngine(cfg Config) error { UseAuth: cfg.Auth, Login: cfg.Login, Password: cfg.Password, - TargetProcs: []string{"Discord.exe", "DiscordCanary.exe", "DiscordPTB.exe", "Update.exe"}, + TargetProcs: []string{ + "Discord.exe", + "DiscordCanary.exe", + "DiscordPTB.exe", + "DiscordSystemHelper.exe", // elevated updater (modern builds) + "Update.exe", // legacy Squirrel updater (older builds) + }, }) if err != nil { log.Printf("gui: sboxrun.New failed: %v", err) diff --git a/internal/gui/frontend/src/components/shared.jsx b/internal/gui/frontend/src/components/shared.jsx index 1aabab3..9281e7f 100644 --- a/internal/gui/frontend/src/components/shared.jsx +++ b/internal/gui/frontend/src/components/shared.jsx @@ -23,7 +23,6 @@ export const ALL_TESTS = [ { id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true }, { id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' }, { id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' }, - { id: 'voice-quality', label: 'UDP voice quality', desc: 'Бёрст 30 STUN-пакетов через релей: потери, джиттер, латентность' }, { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' }, ]; diff --git a/internal/sboxrun/config.go b/internal/sboxrun/config.go index d2d14f5..394b3a6 100644 --- a/internal/sboxrun/config.go +++ b/internal/sboxrun/config.go @@ -85,7 +85,47 @@ func BuildSingBoxConfig(c Config) (string, error) { "auto_detect_interface": true, "final": "direct", "rules": []any{ - // Route only the target processes via upstream + // 1. Domain rule for sniffed SNI (works when sniffing + // catches the ClientHello before route decision). + map[string]any{ + "domain_suffix": []string{ + "discord.com", + "discord.gg", + "discord.media", + "discordapp.com", + "discordapp.net", + "discord.dev", + }, + "outbound": "upstream", + }, + // 2. IP-CIDR fallback — sing-box on Windows TUN + // sometimes misattributes the source process for + // Discord's in-process Rust updater (gets attributed + // to steam.exe or similar), so even with the right + // process_name list the updater's TLS connection to + // updates.discord.com (Fastly: 199.232.x.x) goes + // direct and gets RKN-blocked. Force the major + // Discord/Cloudflare/Fastly ranges through upstream + // regardless of which process the kernel claims sent + // them. + map[string]any{ + "ip_cidr": []string{ + // Fastly (updates.discord.com) + "151.101.0.0/16", + "199.232.0.0/16", + "185.199.108.0/22", + // Cloudflare (Discord gateway, CDN, media) + "162.158.0.0/15", + "162.159.0.0/16", + "104.16.0.0/13", + "104.24.0.0/14", + "172.64.0.0/13", + "131.0.72.0/22", + }, + "outbound": "upstream", + }, + // 3. Process-name rule — covers Discord traffic to + // non-Cloudflare destinations (RTC voice, etc). map[string]any{ "process_name": c.TargetProcs, "outbound": "upstream",