spec: add voice-quality (burst loss/jitter) + voice-srv (Discord media probe)

Old single-shot stun test only proved one UDP packet round-tripped
through the relay. To predict whether voice will actually work the
checker now does two stronger tests:

- voice-quality: 30-packet STUN burst with loss/jitter/p50 metrics,
  with a "warn" tier between hard pass and hard fail.
- voice-srv: concurrent DNS resolve + SOCKS5 TCP probe to a list of
  Discord voice region hostnames; passes if any region is reachable.

Adds StatusWarn ("soft pass — show hint anyway") so the GUI can
distinguish "voice will work but glitchy" from green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 18:27:06 +03:00
parent c48bd96369
commit ea4202d4a3
@@ -52,8 +52,23 @@ type Config struct {
DiscordGateway string DiscordGateway string
DiscordAPI string DiscordAPI string
StunServer string StunServer string
// voice-quality burst tuning
VoiceBurstCount int // default 30
VoiceBurstInterval time.Duration // default 20ms
// voice-srv probe — empty list means "use the built-in default
// (russia/russia2/frankfurt/europe/singapore/japan/us-east/us-west/
// brazil/india/hongkong/southkorea/sydney/southafrica/dubai/atlanta).discord.media"
VoiceServerHostnames []string
} }
// 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). Frontend renders it like StatusPassed but
// keeps the Hint visible.
const StatusWarn Status = "warn"
// Run streams Results to the returned channel and closes it when finished // Run streams Results to the returned channel and closes it when finished
// or when ctx is cancelled. The first event for each test is Status=running; // or when ctx is cancelled. The first event for each test is Status=running;
// the next is the final state (passed/failed/skipped). On retry, another // the next is the final state (passed/failed/skipped). On retry, another
@@ -77,7 +92,8 @@ Sequential. Each test reuses sockets opened by previous tests when sensible.
| `auth` | Only emitted when UseAuth=true. RFC 1929 sub-negotiation: `01 LEN_LOGIN LOGIN LEN_PASS PASS`. Reads 2 bytes, expects `01 00`. | bad credentials (`01 != 00`) / short read | not in test list when UseAuth=false; skipped if `greet` failed | | `auth` | Only emitted when UseAuth=true. RFC 1929 sub-negotiation: `01 LEN_LOGIN LOGIN LEN_PASS PASS`. Reads 2 bytes, expects `01 00`. | bad credentials (`01 != 00`) / short read | not in test list when UseAuth=false; skipped if `greet` failed |
| `connect` | SOCKS5 CONNECT to `gateway.discord.gg:443` (ATYP=03 domain). Reads 10 bytes. Pass = REP=0x00. | REP != 0 (0x05 = connection refused, etc) / timeout | skipped if `greet`/`auth` failed | | `connect` | SOCKS5 CONNECT to `gateway.discord.gg:443` (ATYP=03 domain). Reads 10 bytes. Pass = REP=0x00. | REP != 0 (0x05 = connection refused, etc) / timeout | skipped if `greet`/`auth` failed |
| `udp` | UDP ASSOCIATE: opens **second** TCP control channel, redoes greeting+auth there, sends `05 03 00 01 00000000 0000`, reads 10-byte reply. Pass = REP=0x00 + valid relay endpoint in BND.ADDR/BND.PORT. | REP=0x07 (cmd unsupported), other REP, short read | skipped if `greet` failed | | `udp` | UDP ASSOCIATE: opens **second** TCP control channel, redoes greeting+auth there, sends `05 03 00 01 00000000 0000`, reads 10-byte reply. Pass = REP=0x00 + valid relay endpoint in BND.ADDR/BND.PORT. | REP=0x07 (cmd unsupported), other REP, short read | skipped if `greet` failed |
| `stun` | Through the relay endpoint from the previous step: send STUN binding request (20-byte header, magic cookie 0x2112A442, random transaction ID), wait up to PerTestTimeout for XOR-MAPPED-ADDRESS reply. Metric = round-trip ms. | timeout / malformed response / no XOR-MAPPED-ADDRESS attribute | skipped if `udp` failed | | `voice-quality` | Through the relay: send `VoiceBurstCount` (default 30) STUN binding requests to `cfg.StunServer`, spaced `VoiceBurstInterval` (default 20ms). Listen until `last_send + 1.5*PerTestTimeout`. Compute `loss%`, `jitter` (mean abs delta of inter-arrival deltas, à la RFC 3550 simplified), `p50 RTT`. Metric = `"loss=2% jitter=14ms p50=42ms"`. **Pass** = loss ≤ 5% AND jitter ≤ 30ms AND p50 ≤ 250ms. **Warn-pass** (status=passed but Hint set) = loss ≤ 15% AND jitter ≤ 60ms — voice will work with audible glitches. **Fail** = anything worse. | loss > 15% OR jitter > 60ms OR p50 > 400ms OR no replies at all | skipped if `udp` failed |
| `voice-srv` | Probe Discord voice servers. Concurrently DNS-resolve a hardcoded list of `<region>.discord.media` hostnames (`russia`, `russia2`, `frankfurt`, `europe`, `singapore`, `japan`, `us-east`, `us-west`, `brazil`, `india`, `hongkong`, `southkorea`, `sydney`, `southafrica`, `dubai`, `atlanta`) using OS resolver, 2s budget. For every resolved hostname: SOCKS5 CONNECT through proxy to `host:443` with 1s dial timeout, run them concurrently with a small worker pool (8). Metric = `"<N> regions reachable: russia, frankfurt, europe"` (top 3). **Pass** = ≥ 1 region reachable. **Warn-pass** = 0 reachable but ≥ 1 resolved (proxy filters Discord media IPs even though DNS works) — Hint will warn that voice may not work despite checks 1-5 passing. **Fail** = 0 hostnames resolved at all (DNS broken or Discord changed naming) | 0 hostnames resolved at all | skipped if `connect` failed |
| `api` | TCP CONNECT through the proxy to `discord.com:443`, do a tiny HTTPS GET `/api/v9/gateway`. Pass = HTTP 200 or 401 (Discord returns 401 unauthenticated, that still proves reachability). | non-200/401 / TLS handshake failed / connect refused | skipped if `connect` failed | | `api` | TCP CONNECT through the proxy to `discord.com:443`, do a tiny HTTPS GET `/api/v9/gateway`. Pass = HTTP 200 or 401 (Discord returns 401 unauthenticated, that still proves reachability). | non-200/401 / TLS handshake failed / connect refused | skipped if `connect` failed |
For each fail, the `Hint` field carries a Russian explanation (the GUI is For each fail, the `Hint` field carries a Russian explanation (the GUI is