From 168596bcb574c6deb9ebd54b990274eac420e930 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 1 May 2026 23:21:50 +0300 Subject: [PATCH] sboxrun: domain+IP-CIDR rules + remove voice-quality test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three follow-up fixes after the WinDivert→sing-box pivot: 1. Discord updater now routes through upstream. Previously only the process-name rule matched, but sing-box's TUN-side process detection on Windows mis-attributes the in-process Rust updater's TLS connection to e.g. steam.exe — the connection went direct and hit RKN block. Adding domain_suffix + ip_cidr rules for Cloudflare (162.159/16, 104.16/13, 172.64/13) and Fastly (199.232/16, 151.101/16) catches updates.discord.com regardless of which PID the kernel claims sent it. Verified via curl through mihomo: updates.discord.com responds 400 in 393ms (i.e. TLS handshake succeeds, only the path is wrong — proves the routing reaches it). 2. DiscordSystemHelper.exe added to TargetProcs alongside Update.exe (modern Discord builds use it for elevated updates). 3. UDP voice quality test removed from the checker. The STUN-via- relay burst measured private mihomo BND.ADDR (192.168.1.132) which is unroutable from external clients, so the test reported 100% loss every time despite voice actually working through sing-box's TUN+SOCKS5. The remaining 6 checks (TCP/greet/auth/ connect/UDP/api) cover what's actionable; voice quality is verified empirically by joining a Discord call. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/checker/checker.go | 237 ++++-------------- internal/checker/checker_test.go | 71 +----- internal/gui/app.go | 8 +- .../gui/frontend/src/components/shared.jsx | 1 - internal/sboxrun/config.go | 42 +++- 5 files changed, 97 insertions(+), 262 deletions(-) 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",