diff --git a/docs/superpowers/specs/2026-05-01-checker-design.md b/docs/superpowers/specs/2026-05-01-checker-design.md index 5e2e06e..e0de6e3 100644 --- a/docs/superpowers/specs/2026-05-01-checker-design.md +++ b/docs/superpowers/specs/2026-05-01-checker-design.md @@ -56,11 +56,6 @@ type Config struct { // 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 @@ -93,7 +88,6 @@ Sequential. Each test reuses sockets opened by previous tests when sensible. | `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 | | `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 `.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 = `" 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 | For each fail, the `Hint` field carries a Russian explanation (the GUI is diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 309e676..71cc985 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -8,7 +8,6 @@ import ( "net/http" "regexp" "strconv" - "strings" "time" ) @@ -63,13 +62,6 @@ type Config struct { // packets, 20ms between sends. VoiceBurstCount int VoiceBurstInterval time.Duration - - // VoiceServerHostnames is the list of Discord voice-domain hostnames - // probed in the voice-srv test. Empty means "use the built-in 16-region - // default" (russia, russia2, frankfurt, europe, singapore, japan, - // us-east, us-west, brazil, india, hongkong, southkorea, sydney, - // southafrica, dubai, atlanta — all under .discord.media). - VoiceServerHostnames []string } // applyDefaults returns a copy of cfg with zero-valued knobs filled in. @@ -107,9 +99,6 @@ func applyDefaults(cfg Config) Config { if cfg.VoiceBurstInterval <= 0 { cfg.VoiceBurstInterval = 20 * time.Millisecond } - if len(cfg.VoiceServerHostnames) == 0 { - cfg.VoiceServerHostnames = defaultVoiceHostnames - } return cfg } @@ -135,7 +124,6 @@ func Run(ctx context.Context, cfg Config) <-chan Result { e.runConnect() e.runUDP() e.runVoiceQuality() - e.runVoiceSrv() e.runAPI() }() @@ -165,7 +153,7 @@ type executor struct { udpClient net.PacketConn // Step gating: each xOK is set true on success (or "soft pass" - // warn for voice-quality / voice-srv). + // warn for voice-quality). tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool // Cancellation latch. Once any test emits a "cancelled" failure, @@ -761,97 +749,6 @@ func (e *executor) runVoiceQuality() { } } -// runVoiceSrv — Test 7: probe Discord voice-domain reachability through -// the SOCKS5 proxy. Single attempt (DNS+connect bursts are slow and -// idempotent — retry budget better spent on transient handshake steps). -func (e *executor) runVoiceSrv() { - if e.shouldSkip("voice-srv", e.connectOK) { - return - } - - attempt := 1 - e.emit(Result{ID: "voice-srv", Status: StatusRunning, Attempt: attempt}) - attemptCtx, cancel := context.WithTimeout(e.ctx, e.cfg.PerTestTimeout) - defer cancel() - start := time.Now() - - // Per-host dial timeout — fits inside PerTestTimeout but isn't the - // whole budget (we run 8 in parallel, so total wall-clock is roughly - // ceil(N/8) * dialTimeout + DNS). - dialTimeout := e.cfg.PerTestTimeout / 4 - if dialTimeout < 500*time.Millisecond { - dialTimeout = 500 * time.Millisecond - } - - res, err := runVoiceServerProbe( - attemptCtx, e.cfg.VoiceServerHostnames, e.proxyAddr(), - e.cfg.UseAuth, e.cfg.ProxyLogin, e.cfg.ProxyPassword, dialTimeout, - ) - dur := time.Since(start) - - if err != nil { - if e.ctx.Err() != nil { - e.emitCancelled("voice-srv", attempt, dur) - return - } - e.emit(Result{ - ID: "voice-srv", - Status: StatusFailed, - Error: err.Error(), - Hint: hintFor("voice-srv", err), - Attempt: attempt, - Duration: dur, - }) - return - } - - if len(res.Resolved) == 0 { - e.emit(Result{ - ID: "voice-srv", - Status: StatusFailed, - Error: "no Discord voice domain resolved", - Hint: "DNS не возвращает A-записи для *.discord.media — проверь системный DNS.", - Attempt: attempt, - Duration: dur, - }) - return - } - - if len(res.Reachable) == 0 { - e.emit(Result{ - ID: "voice-srv", - Status: StatusWarn, - Metric: fmt.Sprintf("0/%d regions reachable", len(res.Resolved)), - Hint: "Discord voice-домены резолвятся, но прокси не пропускает к ним TCP — голос с большой вероятностью не заработает.", - Attempt: attempt, - Duration: dur, - }) - return - } - - // Top-3 region prefixes for the metric line. - top := res.Reachable - if len(top) > 3 { - top = top[:3] - } - prefixes := make([]string, 0, len(top)) - for _, h := range top { - prefix := h - if i := strings.Index(h, ".discord.media"); i > 0 { - prefix = h[:i] - } - prefixes = append(prefixes, prefix) - } - metric := fmt.Sprintf("%d/%d regions: %s", len(res.Reachable), len(res.Resolved), strings.Join(prefixes, ", ")) - e.emit(Result{ - ID: "voice-srv", - Status: StatusPassed, - Metric: metric, - Attempt: attempt, - Duration: dur, - }) -} - // runAPI — Test 7: HTTP GET Discord API gateway URL through the proxy. func (e *executor) runAPI() { if e.shouldSkip("api", e.connectOK) { diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 4a176e4..d815540 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -48,11 +48,6 @@ type fakeProxy struct { apiTargetPort uint16 apiTargetAddr string - // blockVoiceCONNECT, when true, makes any CONNECT to a hostname - // not equal to apiTargetHost return REP=05. Used by voice-srv - // negative scenarios. - blockVoiceCONNECT atomic.Bool - // timeoutFirstAttempt stalls the first connection on greet to // drive a timeout. Subsequent connections behave normally. timeoutFirstAttempt atomic.Int32 @@ -96,7 +91,7 @@ func newFakeProxy(t *testing.T, scenario string) *fakeProxy { func needsUDPRelay(scenario string) bool { switch scenario { case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok", - "voice_quality_warn", "voice_quality_fail", "voice_srv_blocked": + "voice_quality_warn", "voice_quality_fail": return true default: return false @@ -196,23 +191,6 @@ func (fp *fakeProxy) handle(conn net.Conn) { switch cmdReq.cmd { case 0x01: // CONNECT - // voice-srv block: refuse CONNECT to anything that isn't the - // gateway/api passthrough target. Only checked when the test - // explicitly sets blockVoiceCONNECT — keeps gateway+api happy. - if fp.blockVoiceCONNECT.Load() { - isAPITarget := fp.apiTargetHost != "" && cmdReq.host == fp.apiTargetHost && cmdReq.port == fp.apiTargetPort - isGatewayTarget := cmdReq.port == 443 && (cmdReq.host == "127.0.0.1" || cmdReq.host == "localhost") && fp.apiTargetHost == "" - _ = isGatewayTarget - if !isAPITarget { - // Refuse only :443 voice probes; allow gateway probe - // (which has its own configured port from stubGatewayAddr, - // not 443). Logic: target port == 443 → voice probe → refuse. - if cmdReq.port == 443 { - _, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) - return - } - } - } switch fp.scenario { case "connect_refused": _, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) @@ -517,25 +495,17 @@ func hostPort(addr string) (string, int) { // proxyConfig builds a Config pointed at the given fakeProxy with sane // short timeouts for tests. -// -// Note on voice-srv: the default Config.VoiceServerHostnames would hit -// real Discord DNS during go test — we don't want that. Override with a -// single local hostname ("localhost") so DNS always resolves to 127.0.0.1 -// and the SOCKS5 CONNECT goes back to the fake proxy itself, which -// returns REP=00 on a happy CONNECT or REP=05 when blockVoiceCONNECT is -// flipped. func proxyConfig(fp *fakeProxy, useAuth bool) Config { host, port := hostPort(fp.addr) cfg := Config{ - ProxyHost: host, - ProxyPort: port, - UseAuth: useAuth, - PerTestTimeout: 500 * time.Millisecond, - MaxRetries: 1, - RetryBackoff: 30 * time.Millisecond, - VoiceBurstCount: 10, - VoiceBurstInterval: 5 * time.Millisecond, - VoiceServerHostnames: []string{"localhost"}, + ProxyHost: host, + ProxyPort: port, + UseAuth: useAuth, + PerTestTimeout: 500 * time.Millisecond, + MaxRetries: 1, + RetryBackoff: 30 * time.Millisecond, + VoiceBurstCount: 10, + VoiceBurstInterval: 5 * time.Millisecond, } if useAuth { cfg.ProxyLogin = "u" @@ -609,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", "voice-srv", "api"} + expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "api"} finals := map[string]Result{} for _, id := range expected { r, ok := finalByID(results, id) @@ -629,7 +599,6 @@ func TestRun_HappyNoAuth(t *testing.T) { 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.Contains(t, finals["voice-srv"].Metric, "1/1 regions") assert.Equal(t, "HTTP 200", finals["api"].Metric) } @@ -643,7 +612,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", "voice-srv", "api"} + expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api"} for _, id := range expected { r, ok := finalByID(results, id) require.True(t, ok, "missing %s; results=%+v", id, results) @@ -674,7 +643,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", "voice-srv", "api"} { + for _, id := range []string{"connect", "udp", "voice-quality", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -699,7 +668,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", "voice-srv", "api"} { + for _, id := range []string{"connect", "udp", "voice-quality", "api"} { r, ok := finalByID(results, id) require.True(t, ok, "missing %s", id) assert.Equal(t, StatusSkipped, r.Status, "id=%s", id) @@ -735,10 +704,6 @@ func TestRun_ConnectRefused(t *testing.T) { rVQ, _ := finalByID(results, "voice-quality") assert.Equal(t, StatusPassed, rVQ.Status) - // voice-srv depends on connect → skipped. - rVS, _ := finalByID(results, "voice-srv") - assert.Equal(t, StatusSkipped, rVS.Status) - // api depends on connect → skipped. rA, _ := finalByID(results, "api") assert.Equal(t, StatusSkipped, rA.Status) @@ -767,10 +732,6 @@ func TestRun_UDPUnsupported(t *testing.T) { rVQ, _ := finalByID(results, "voice-quality") assert.Equal(t, StatusSkipped, rVQ.Status) - // voice-srv depends on connect (passed) → runs and passes. - rVS, _ := finalByID(results, "voice-srv") - assert.Equal(t, StatusPassed, rVS.Status) - rA, _ := finalByID(results, "api") assert.Equal(t, StatusPassed, rA.Status) } @@ -806,8 +767,8 @@ func TestRun_TimeoutThenOK(t *testing.T) { assert.Equal(t, StatusPassed, greetEvents[3].Status) assert.Equal(t, 2, greetEvents[3].Attempt) - // All seven non-auth tests should ultimately pass. - for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "api"} { + // All non-auth tests should ultimately pass. + for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "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) @@ -873,10 +834,10 @@ func TestRun_CancelledMidFlight(t *testing.T) { // Either: one cancelled-failed + rest cancelled-skipped, OR all // cancelled-skipped (if cancellation hit before next test even // started). Both are acceptable. - // Without auth, 6 tests remain after tcp (greet/connect/udp/ - // voice-quality/voice-srv/api). Cancel may race with greet - // completing successfully, so accept ≥4. - assert.GreaterOrEqual(t, failed+skipped, 4, "expected at least 4 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results) + // Without auth, 5 tests remain after tcp (greet/connect/udp/ + // voice-quality/api). Cancel may race with greet + // completing successfully, so accept ≥3. + assert.GreaterOrEqual(t, failed+skipped, 3, "expected at least 3 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results) } func TestRun_AppliesDefaults(t *testing.T) { @@ -979,27 +940,6 @@ func TestRun_VoiceQualityFail(t *testing.T) { assert.NotEmpty(t, rVQ.Hint) } -// TestRun_VoiceSrvAllResolvedNoneReachable: DNS resolves localhost but -// the proxy refuses CONNECT to :443. Expect StatusWarn with "0/1 regions -// reachable" metric. -func TestRun_VoiceSrvAllResolvedNoneReachable(t *testing.T) { - fp := newFakeProxy(t, "voice_srv_blocked") - cfg := proxyConfig(fp, false) - cfg.DiscordGateway = stubGatewayAddr(t) - cfg.DiscordAPI = stubAPIServer(t, fp, 200) - cfg.StunServer = "127.0.0.1:65000" - fp.blockVoiceCONNECT.Store(true) - - ch := Run(context.Background(), cfg) - results := drainResults(t, ch, 15*time.Second) - - rVS, ok := finalByID(results, "voice-srv") - require.True(t, ok) - assert.Equal(t, StatusWarn, rVS.Status, "got %+v", rVS) - assert.Equal(t, "0/1 regions reachable", rVS.Metric) - assert.NotEmpty(t, rVS.Hint) -} - func TestExtractRawHex(t *testing.T) { cases := []struct { in, want string diff --git a/internal/checker/hints.go b/internal/checker/hints.go index 489f90a..08da423 100644 --- a/internal/checker/hints.go +++ b/internal/checker/hints.go @@ -38,8 +38,6 @@ func tcpFriendlyName(testID string) string { return "UDP ASSOCIATE" case "voice-quality": return "качество UDP-канала" - case "voice-srv": - return "доступность voice-серверов Discord" case "api": return "Discord API" default: @@ -147,16 +145,6 @@ func hintFor(testID string, err error) string { } return genericFallback(testID, err) - case "voice-srv": - var dnsErr *net.DNSError - if errors.As(err, &dnsErr) { - return "DNS не резолвит voice-домены Discord." - } - if isTimeout { - return "Не удалось проверить voice-серверы Discord вовремя — таймаут." - } - return fmt.Sprintf("Не удалось проверить доступность Discord voice-серверов: %s.", err.Error()) - case "api": switch { case isTimeout: diff --git a/internal/checker/hints_test.go b/internal/checker/hints_test.go index 662f5d3..aeb728c 100644 --- a/internal/checker/hints_test.go +++ b/internal/checker/hints_test.go @@ -18,7 +18,7 @@ func TestHintFor(t *testing.T) { t.Run("context_canceled_uniform", func(t *testing.T) { // Cancellation is always reported as «Проверка отменена.» across // all testIDs. - for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api", "unknown"} { + for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "api", "unknown"} { assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id) assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id) } @@ -121,10 +121,6 @@ func TestHintFor_PerStepBranches(t *testing.T) { {"voice_quality_unsupported_family", "voice-quality", ErrSTUNUnsupportedFamily, "мусор"}, {"voice_quality_fallback", "voice-quality", errors.New("weird"), "качество"}, - // voice-srv: DNS error, timeout, generic - {"voice_srv_timeout", "voice-srv", &timeoutOnlyError{}, "таймаут"}, - {"voice_srv_generic", "voice-srv", errors.New("boom"), "boom"}, - // api: timeout vs generic {"api_timeout", "api", &timeoutOnlyError{}, "таймаут"}, {"api_generic", "api", errors.New("tls boom"), "TLS"}, @@ -158,7 +154,6 @@ func TestTcpFriendlyName(t *testing.T) { "connect": "TCP-туннель к Discord", "udp": "UDP ASSOCIATE", "voice-quality": "качество UDP-канала", - "voice-srv": "доступность voice-серверов Discord", "api": "Discord API", "weirdo": "weirdo", } diff --git a/internal/checker/voice.go b/internal/checker/voice.go index 6a22861..b005e66 100644 --- a/internal/checker/voice.go +++ b/internal/checker/voice.go @@ -2,20 +2,11 @@ package checker // voice.go — predictive voice diagnostics. // -// Two primitives sit on top of the SOCKS5/STUN building blocks already -// in this package: -// -// - runVoiceQualityBurst: fire a burst of STUN binding requests through -// an open SOCKS5 UDP relay, then derive packet-loss / jitter / -// percentile-RTT from the replies. A single round-trip says the relay -// accepts UDP; a 30-packet burst tells you whether voice will actually -// hold together. -// -// - runVoiceServerProbe: parallel-DNS a list of .discord.media -// hostnames, then SOCKS5 CONNECT to :443 on each, recording which -// regions are reachable through the proxy. DNS-resolves-but-CONNECT-fails -// is a very common Russian-DPI failure mode that all five preceding -// SOCKS5 sanity checks miss. +// runVoiceQualityBurst fires a burst of STUN binding requests through +// an open SOCKS5 UDP relay, then derives packet-loss / jitter / +// percentile-RTT from the replies. A single round-trip says the relay +// accepts UDP; a 30-packet burst tells you whether voice will actually +// hold together. import ( "context" @@ -283,156 +274,3 @@ sendLoop: return res, nil } - -// VoiceServerProbeResult lists Discord voice-domain hostnames split by -// outcome. Resolved is ordered by input position; Reachable is too. -type VoiceServerProbeResult struct { - Resolved []string // hostnames that got at least one A record - Reachable []string // hostnames that succeeded SOCKS5 CONNECT - Unresolved []string // hostnames that didn't resolve at all - UnreachableButResolved []string // resolved but proxy CONNECT failed -} - -// runVoiceServerProbe resolves each hostname and, for those that resolved -// to ≥ 1 IPv4, performs a SOCKS5 CONNECT to host:443 through proxyAddr. -// REP=00 is enough — we don't actually do TLS. -// -// DNS goes through the OS resolver (not the proxy). For tests, pass -// hostnames that always resolve locally (e.g. "localhost"). -// -// Returns an error only when ctx is cancelled. -func runVoiceServerProbe( - ctx context.Context, - hostnames []string, - proxyAddr string, - useAuth bool, - login, password string, - dialTimeout time.Duration, -) (VoiceServerProbeResult, error) { - if dialTimeout <= 0 { - dialTimeout = 1 * time.Second - } - - const dnsConcurrency = 16 - const connectConcurrency = 8 - - // Phase 1: parallel DNS. - type dnsOut struct { - idx int - host string - resolved bool - } - dnsResults := make([]dnsOut, len(hostnames)) - var wg sync.WaitGroup - dnsSem := make(chan struct{}, dnsConcurrency) - for i, h := range hostnames { - wg.Add(1) - dnsSem <- struct{}{} - go func(i int, h string) { - defer wg.Done() - defer func() { <-dnsSem }() - ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", h) - ok := err == nil && len(ips) > 0 - dnsResults[i] = dnsOut{idx: i, host: h, resolved: ok} - }(i, h) - } - wg.Wait() - - if cerr := ctx.Err(); cerr != nil { - return VoiceServerProbeResult{}, cerr - } - - res := VoiceServerProbeResult{} - // Build resolved/unresolved lists in input order. - resolvedHosts := make([]string, 0, len(hostnames)) - for _, dr := range dnsResults { - if dr.resolved { - res.Resolved = append(res.Resolved, dr.host) - resolvedHosts = append(resolvedHosts, dr.host) - } else { - res.Unresolved = append(res.Unresolved, dr.host) - } - } - - if len(resolvedHosts) == 0 { - return res, nil - } - - // Phase 2: parallel SOCKS5 CONNECT. - reachable := make([]bool, len(resolvedHosts)) - connectSem := make(chan struct{}, connectConcurrency) - wg = sync.WaitGroup{} - for i, h := range resolvedHosts { - wg.Add(1) - connectSem <- struct{}{} - go func(i int, h string) { - defer wg.Done() - defer func() { <-connectSem }() - reachable[i] = probeSocks5Connect(ctx, proxyAddr, useAuth, login, password, h, 443, dialTimeout) - }(i, h) - } - wg.Wait() - - if cerr := ctx.Err(); cerr != nil { - return res, cerr - } - for i, h := range resolvedHosts { - if reachable[i] { - res.Reachable = append(res.Reachable, h) - } else { - res.UnreachableButResolved = append(res.UnreachableButResolved, h) - } - } - return res, nil -} - -// probeSocks5Connect opens one fresh TCP connection to proxyAddr, runs -// the SOCKS5 handshake and CONNECT request to host:port, and returns -// true iff the proxy replied REP=00. Always closes the conn before -// returning. -func probeSocks5Connect( - parent context.Context, - proxyAddr string, - useAuth bool, - login, password string, - host string, - port uint16, - dialTimeout time.Duration, -) bool { - ctx, cancel := context.WithTimeout(parent, dialTimeout) - defer cancel() - - var d net.Dialer - conn, err := d.DialContext(ctx, "tcp", proxyAddr) - if err != nil { - return false - } - defer conn.Close() - - if _, _, gerr := socks5Greeting(ctx, conn, useAuth); gerr != nil { - return false - } - if useAuth { - if _, aerr := socks5Auth(ctx, conn, login, password); aerr != nil { - return false - } - } - if _, cerr := socks5Connect(ctx, conn, host, port); cerr != nil { - return false - } - return true -} - -// defaultVoiceHostnames is the built-in list used when -// Config.VoiceServerHostnames is empty. Order matters — the GUI shows -// the first three reachable regions in the metric line. -var defaultVoiceHostnames = []string{ - "russia.discord.media", "russia2.discord.media", - "frankfurt.discord.media", "europe.discord.media", - "singapore.discord.media", "japan.discord.media", - "us-east.discord.media", "us-west.discord.media", - "brazil.discord.media", "india.discord.media", - "hongkong.discord.media", "southkorea.discord.media", - "sydney.discord.media", "southafrica.discord.media", - "dubai.discord.media", "atlanta.discord.media", -} diff --git a/internal/checker/voice_test.go b/internal/checker/voice_test.go index e71a370..6613d31 100644 --- a/internal/checker/voice_test.go +++ b/internal/checker/voice_test.go @@ -190,111 +190,3 @@ func TestVoiceQualityBurst_ZeroCount(t *testing.T) { "localhost", 19302, 0, 5*time.Millisecond) assert.Error(t, err) } - -// TestVoiceServerProbe_HappyAndBlocked: against a fake SOCKS5 server, one -// hostname (localhost) succeeds (REP=00) and another (127.0.0.1) gets -// blocked (REP=05). Both resolve at the OS level, so Resolved should be -// {localhost, 127.0.0.1}; Reachable should be {localhost}; Unreachable -// should be {127.0.0.1}. -func TestVoiceServerProbe_HappyAndBlocked(t *testing.T) { - // Stand up a tiny fake SOCKS5 server that completes greet+CONNECT - // only for hostname "localhost"; CONNECT to "127.0.0.1" is refused - // with REP=05. We don't need full RFC 1928 — just enough to drive - // the probe path. - ln, err := net.Listen("tcp", "127.0.0.1:0") - require.NoError(t, err) - defer ln.Close() - go func() { - for { - c, err := ln.Accept() - if err != nil { - return - } - go func(c net.Conn) { - defer c.Close() - _ = c.SetDeadline(time.Now().Add(2 * time.Second)) - // Read greeting: VER NMETHODS METHODS... - hdr := make([]byte, 2) - if _, err := readFull(c, hdr); err != nil { - return - } - if hdr[0] != 0x05 { - return - } - methods := make([]byte, hdr[1]) - if _, err := readFull(c, methods); err != nil { - return - } - // Reply: no-auth. - _, _ = c.Write([]byte{0x05, 0x00}) - // Read CONNECT request: VER CMD RSV ATYP... - h2 := make([]byte, 4) - if _, err := readFull(c, h2); err != nil { - return - } - var host string - switch h2[3] { - case 0x01: - ip := make([]byte, 4) - _, _ = readFull(c, ip) - host = net.IP(ip).String() - case 0x03: - l := make([]byte, 1) - _, _ = readFull(c, l) - name := make([]byte, int(l[0])) - _, _ = readFull(c, name) - host = string(name) - } - port := make([]byte, 2) - _, _ = readFull(c, port) - // Reply REP=00 only for hostname "localhost"; refuse otherwise. - if host == "localhost" { - _, _ = c.Write([]byte{0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) - } else { - _, _ = c.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0}) - } - }(c) - } - }() - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - res, err := runVoiceServerProbe(ctx, - []string{"localhost", "127.0.0.1"}, ln.Addr().String(), - false, "", "", 1*time.Second) - require.NoError(t, err) - - assert.ElementsMatch(t, []string{"localhost", "127.0.0.1"}, res.Resolved) - assert.Equal(t, []string{"localhost"}, res.Reachable) - assert.Equal(t, []string{"127.0.0.1"}, res.UnreachableButResolved) - assert.Empty(t, res.Unresolved) -} - -// TestVoiceServerProbe_EmptyHostnameList: zero-length input → zero-length -// Resolved/Reachable, no error. -func TestVoiceServerProbe_EmptyHostnameList(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) - defer cancel() - - res, err := runVoiceServerProbe(ctx, - []string{}, "127.0.0.1:1", false, "", "", 100*time.Millisecond) - require.NoError(t, err) - - assert.Empty(t, res.Resolved) - assert.Empty(t, res.Reachable) - assert.Empty(t, res.Unresolved) -} - -// readFull is a tiny helper to avoid importing io just for this. -func readFull(c net.Conn, buf []byte) (int, error) { - got := 0 - for got < len(buf) { - n, err := c.Read(buf[got:]) - got += n - if err != nil { - return got, err - } - } - return got, nil -} diff --git a/internal/gui/app.go b/internal/gui/app.go index c7e26ee..fd65de5 100644 --- a/internal/gui/app.go +++ b/internal/gui/app.go @@ -68,7 +68,7 @@ type Config struct { // for them on the "check:result" event. Mirrors checker.Result but with // Duration converted to milliseconds (int) for the JS side. type CheckResult struct { - ID string `json:"id"` // tcp / greet / auth / connect / udp / voice-quality / voice-srv / api + ID string `json:"id"` // tcp / greet / auth / connect / udp / voice-quality / api Status string `json:"status"` // running | passed | warn | failed | skipped Metric string `json:"metric,omitempty"` Error string `json:"error,omitempty"` diff --git a/internal/gui/frontend/src/components/shared.jsx b/internal/gui/frontend/src/components/shared.jsx index ef02303..e2a7fad 100644 --- a/internal/gui/frontend/src/components/shared.jsx +++ b/internal/gui/frontend/src/components/shared.jsx @@ -24,7 +24,6 @@ export const ALL_TESTS = [ { 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: 'voice-srv', label: 'Discord voice servers', desc: 'Какие регионы Discord media доступны через прокси' }, { id: 'api', label: 'Discord API reachable', desc: 'HTTPS до discord.com через прокси' }, ];