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:
+44
-193
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user