sboxrun: domain+IP-CIDR rules + remove voice-quality test

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 23:21:50 +03:00
parent 48097f8671
commit 1dafe4066e
5 changed files with 97 additions and 262 deletions
+44 -193
View File
@@ -123,7 +123,6 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
} }
e.runConnect() e.runConnect()
e.runUDP() e.runUDP()
e.runVoiceQuality()
e.runAPI() e.runAPI()
}() }()
@@ -522,6 +521,38 @@ func (e *executor) runConnect() {
} }
// runUDP — Test 5: open second TCP control channel and UDP ASSOCIATE. // 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() { func (e *executor) runUDP() {
dep := e.greetOK && (!e.cfg.UseAuth || e.authOK) dep := e.greetOK && (!e.cfg.UseAuth || e.authOK)
if e.shouldSkip("udp", dep) { if e.shouldSkip("udp", dep) {
@@ -552,204 +583,24 @@ func (e *executor) runUDP() {
if uerr != nil { if uerr != nil {
return "", uerr 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 e.udpRelay = relay
return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil return fmt.Sprintf("relay %s:%d", relay.IP.String(), relay.Port), nil
}) })
e.udpOK = ok e.udpOK = ok
} }
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP // runAPI — Test 6: HTTP GET Discord API gateway URL through the proxy.
// 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.
func (e *executor) runAPI() { func (e *executor) runAPI() {
if e.shouldSkip("api", e.connectOK) { if e.shouldSkip("api", e.connectOK) {
return return
+5 -66
View File
@@ -579,7 +579,7 @@ func TestRun_HappyNoAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) 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{} finals := map[string]Result{}
for _, id := range expected { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
@@ -598,7 +598,6 @@ func TestRun_HappyNoAuth(t *testing.T) {
// Metrics format spot-checks. // Metrics format spot-checks.
assert.Contains(t, finals["greet"].Metric, "no auth") assert.Contains(t, finals["greet"].Metric, "no auth")
assert.Equal(t, "REP=00", finals["connect"].Metric) assert.Equal(t, "REP=00", finals["connect"].Metric)
assert.Contains(t, finals["voice-quality"].Metric, "loss=")
assert.Equal(t, "HTTP 200", finals["api"].Metric) assert.Equal(t, "HTTP 200", finals["api"].Metric)
} }
@@ -612,7 +611,7 @@ func TestRun_HappyWithAuth(t *testing.T) {
ch := Run(context.Background(), cfg) ch := Run(context.Background(), cfg)
results := drainResults(t, ch, 10*time.Second) 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 { for _, id := range expected {
r, ok := finalByID(results, id) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s; results=%+v", id, results) 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.Equal(t, StatusFailed, rA.Status)
assert.NotEmpty(t, rA.Hint) 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%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.Equal(t, StatusFailed, rG.Status)
assert.NotEmpty(t, rG.Hint) 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusSkipped, r.Status, "id=%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") rU, _ := finalByID(results, "udp")
assert.Equal(t, StatusPassed, rU.Status, "udp should pass independently of connect") 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. // api depends on connect → skipped.
rA, _ := finalByID(results, "api") rA, _ := finalByID(results, "api")
assert.Equal(t, StatusSkipped, rA.Status) assert.Equal(t, StatusSkipped, rA.Status)
@@ -728,10 +723,6 @@ func TestRun_UDPUnsupported(t *testing.T) {
require.Equal(t, StatusFailed, rU.Status) require.Equal(t, StatusFailed, rU.Status)
assert.NotEmpty(t, rU.Hint) 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") rA, _ := finalByID(results, "api")
assert.Equal(t, StatusPassed, rA.Status) assert.Equal(t, StatusPassed, rA.Status)
} }
@@ -768,7 +759,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
assert.Equal(t, 2, greetEvents[3].Attempt) assert.Equal(t, 2, greetEvents[3].Attempt)
// All non-auth tests should ultimately pass. // 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) r, ok := finalByID(results, id)
require.True(t, ok, "missing %s", id) require.True(t, ok, "missing %s", id)
assert.Equal(t, StatusPassed, r.Status, "id=%s, got %+v", id, r) 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) 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) { func TestExtractRawHex(t *testing.T) {
cases := []struct { cases := []struct {
in, want string in, want string
+7 -1
View File
@@ -189,7 +189,13 @@ func (a *App) StartEngine(cfg Config) error {
UseAuth: cfg.Auth, UseAuth: cfg.Auth,
Login: cfg.Login, Login: cfg.Login,
Password: cfg.Password, 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 { if err != nil {
log.Printf("gui: sboxrun.New failed: %v", err) log.Printf("gui: sboxrun.New failed: %v", err)
@@ -23,7 +23,6 @@ export const ALL_TESTS = [
{ id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true }, { id: 'auth', label: 'SOCKS5 authentication', desc: 'Аутентификация по логину/паролю принята', authOnly: true },
{ id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' }, { id: 'connect', label: 'TCP CONNECT to Discord', desc: 'Прокси проксирует TCP к gateway' },
{ id: 'udp', label: 'UDP ASSOCIATE', desc: 'Прокси выдал UDP-релей' }, { 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 через прокси' }, { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' },
]; ];
+41 -1
View File
@@ -85,7 +85,47 @@ func BuildSingBoxConfig(c Config) (string, error) {
"auto_detect_interface": true, "auto_detect_interface": true,
"final": "direct", "final": "direct",
"rules": []any{ "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{ map[string]any{
"process_name": c.TargetProcs, "process_name": c.TargetProcs,
"outbound": "upstream", "outbound": "upstream",