internal/checker: voice-quality + voice-srv tests for predictive voice diagnosis
Replaces the single-packet `stun` test with two predictive voice tests:
- voice-quality: 30-packet STUN burst through the SOCKS5 UDP relay.
Computes loss%, jitter (RFC-3550-ish mean abs of inter-arrival
delta), p50/p95 RTT. Three-tier gating: pass (loss≤5%, jitter≤30,
p50≤250), warn (loss≤15%, jitter≤60, p50≤400 — voice glitches but
works), fail (anything worse, including 100% loss).
- voice-srv: parallel-DNS the 16-region <region>.discord.media
hostnames, then SOCKS5 CONNECT to :443 on each through the proxy.
Catches the very common Russian-DPI failure mode where the proxy
passes generic Discord.com TCP but blocks the .discord.media voice
CIDRs — a regression all 5 prior SOCKS5 sanity checks miss.
New StatusWarn = "warn" — soft pass with Hint kept visible. Counted as
passed in summary but flagged in UI.
Config gains VoiceBurstCount (default 30), VoiceBurstInterval (default
20ms), VoiceServerHostnames (default = built-in 16-region list).
Tests cover happy path, warn-tier (10% drop), fail-tier (100% drop),
voice-srv blocked, plus standalone unit tests on
runVoiceQualityBurst and runVoiceServerProbe with a fake UDP relay
and fake SOCKS5 server. Race + cover stays at 82.4%.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+269
-81
@@ -3,13 +3,12 @@ package checker
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -22,6 +21,12 @@ const (
|
|||||||
StatusPassed Status = "passed"
|
StatusPassed Status = "passed"
|
||||||
StatusFailed Status = "failed"
|
StatusFailed Status = "failed"
|
||||||
StatusSkipped Status = "skipped"
|
StatusSkipped Status = "skipped"
|
||||||
|
// 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, or all Discord voice domains resolve but
|
||||||
|
// the proxy filters their TCP). Frontend renders it like StatusPassed
|
||||||
|
// but keeps the Hint visible.
|
||||||
|
StatusWarn Status = "warn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result is one event in the diagnostic stream. Multiple Results may be
|
// Result is one event in the diagnostic stream. Multiple Results may be
|
||||||
@@ -53,6 +58,18 @@ type Config struct {
|
|||||||
DiscordGateway string
|
DiscordGateway string
|
||||||
DiscordAPI string
|
DiscordAPI string
|
||||||
StunServer string
|
StunServer string
|
||||||
|
|
||||||
|
// Voice-quality burst tuning (see runVoiceQuality). Defaults: 30
|
||||||
|
// 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.
|
// applyDefaults returns a copy of cfg with zero-valued knobs filled in.
|
||||||
@@ -84,6 +101,15 @@ func applyDefaults(cfg Config) Config {
|
|||||||
if cfg.StunServer == "" {
|
if cfg.StunServer == "" {
|
||||||
cfg.StunServer = "stun.l.google.com:19302"
|
cfg.StunServer = "stun.l.google.com:19302"
|
||||||
}
|
}
|
||||||
|
if cfg.VoiceBurstCount <= 0 {
|
||||||
|
cfg.VoiceBurstCount = 30
|
||||||
|
}
|
||||||
|
if cfg.VoiceBurstInterval <= 0 {
|
||||||
|
cfg.VoiceBurstInterval = 20 * time.Millisecond
|
||||||
|
}
|
||||||
|
if len(cfg.VoiceServerHostnames) == 0 {
|
||||||
|
cfg.VoiceServerHostnames = defaultVoiceHostnames
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +134,8 @@ func Run(ctx context.Context, cfg Config) <-chan Result {
|
|||||||
}
|
}
|
||||||
e.runConnect()
|
e.runConnect()
|
||||||
e.runUDP()
|
e.runUDP()
|
||||||
e.runStun()
|
e.runVoiceQuality()
|
||||||
|
e.runVoiceSrv()
|
||||||
e.runAPI()
|
e.runAPI()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -137,8 +164,9 @@ type executor struct {
|
|||||||
// udpClient is our local UDP socket used to talk to the relay.
|
// udpClient is our local UDP socket used to talk to the relay.
|
||||||
udpClient net.PacketConn
|
udpClient net.PacketConn
|
||||||
|
|
||||||
// Step gating: each xOK is set true on success.
|
// Step gating: each xOK is set true on success (or "soft pass"
|
||||||
tcpOK, greetOK, authOK, connectOK, udpOK bool
|
// warn for voice-quality / voice-srv).
|
||||||
|
tcpOK, greetOK, authOK, connectOK, udpOK, voiceQualityOK bool
|
||||||
|
|
||||||
// Cancellation latch. Once any test emits a "cancelled" failure,
|
// Cancellation latch. Once any test emits a "cancelled" failure,
|
||||||
// remaining tests emit a single Skipped result with the same reason.
|
// remaining tests emit a single Skipped result with the same reason.
|
||||||
@@ -542,19 +570,28 @@ func (e *executor) runUDP() {
|
|||||||
e.udpOK = ok
|
e.udpOK = ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// runStun — Test 6: STUN through the SOCKS5 UDP relay.
|
// runVoiceQuality — Test 6: 30-packet STUN burst through the SOCKS5 UDP
|
||||||
func (e *executor) runStun() {
|
// relay. Computes loss, jitter, p50/p95 RTT and gates on thresholds:
|
||||||
if e.shouldSkip("stun", e.udpOK) {
|
//
|
||||||
|
// - 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
|
host, portStr, splitErr := net.SplitHostPort(e.cfg.StunServer)
|
||||||
if splitErr != nil {
|
if splitErr != nil {
|
||||||
e.emit(Result{
|
e.emit(Result{
|
||||||
ID: "stun",
|
ID: "voice-quality",
|
||||||
Status: StatusFailed,
|
Status: StatusFailed,
|
||||||
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
|
Error: fmt.Sprintf("bad StunServer %q: %s", e.cfg.StunServer, splitErr.Error()),
|
||||||
Hint: hintFor("stun", splitErr),
|
Hint: hintFor("voice-quality", splitErr),
|
||||||
Attempt: 1,
|
Attempt: 1,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
@@ -562,105 +599,256 @@ func (e *executor) runStun() {
|
|||||||
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
port64, perr := strconv.ParseUint(portStr, 10, 16)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
e.emit(Result{
|
e.emit(Result{
|
||||||
ID: "stun",
|
ID: "voice-quality",
|
||||||
Status: StatusFailed,
|
Status: StatusFailed,
|
||||||
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
|
Error: fmt.Sprintf("bad StunServer port %q: %s", portStr, perr.Error()),
|
||||||
Hint: hintFor("stun", perr),
|
Hint: hintFor("voice-quality", perr),
|
||||||
Attempt: 1,
|
Attempt: 1,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stunPort := uint16(port64)
|
stunPort := uint16(port64)
|
||||||
|
|
||||||
e.runAttempt("stun", func(ctx context.Context) (string, error) {
|
maxAttempts := 1 + e.cfg.MaxRetries
|
||||||
// Resolve STUN host to an IPv4. We don't support IPv6 STUN.
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
ips, err := (&net.Resolver{}).LookupIP(ctx, "ip4", host)
|
if err := e.ctx.Err(); err != nil {
|
||||||
if err != nil {
|
e.emitCancelled("voice-quality", attempt, 0)
|
||||||
return "", fmt.Errorf("stun: lookup %s: %w", host, err)
|
return
|
||||||
}
|
|
||||||
var stunIP4 net.IP
|
|
||||||
for _, ip := range ips {
|
|
||||||
if v4 := ip.To4(); v4 != nil {
|
|
||||||
stunIP4 = v4
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if stunIP4 == nil {
|
|
||||||
return "", errors.New("stun: no IPv4 for STUN server")
|
|
||||||
}
|
}
|
||||||
|
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.
|
// Open a fresh local UDP socket per attempt.
|
||||||
if e.udpClient != nil {
|
if e.udpClient != nil {
|
||||||
_ = e.udpClient.Close()
|
_ = e.udpClient.Close()
|
||||||
e.udpClient = nil
|
e.udpClient = nil
|
||||||
}
|
}
|
||||||
pc, err := net.ListenPacket("udp", ":0")
|
pc, perr := net.ListenPacket("udp", ":0")
|
||||||
if err != nil {
|
if perr != nil {
|
||||||
return "", fmt.Errorf("stun: listen udp: %w", err)
|
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
|
e.udpClient = pc
|
||||||
if dl, ok := ctx.Deadline(); ok {
|
|
||||||
_ = pc.SetDeadline(dl)
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build SOCKS5 UDP datagram: RSV(2)=0 FRAG=0 ATYP=01 IP(4) PORT(2) STUN(20)
|
// 100% loss with no underlying error → the relay accepted UDP
|
||||||
txID, err := NewTransactionID()
|
// (per test 5) but nothing came back. Treat as transient on
|
||||||
if err != nil {
|
// the first attempt; permanent on the second.
|
||||||
return "", err
|
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
|
||||||
}
|
}
|
||||||
stunReq := EncodeBindingRequest(txID)
|
}
|
||||||
dgram := make([]byte, 0, 10+len(stunReq))
|
return
|
||||||
dgram = append(dgram, 0x00, 0x00, 0x00, 0x01)
|
|
||||||
dgram = append(dgram, stunIP4...)
|
|
||||||
var portBuf [2]byte
|
|
||||||
binary.BigEndian.PutUint16(portBuf[:], stunPort)
|
|
||||||
dgram = append(dgram, portBuf[:]...)
|
|
||||||
dgram = append(dgram, stunReq...)
|
|
||||||
|
|
||||||
start := time.Now()
|
|
||||||
if _, werr := pc.WriteTo(dgram, e.udpRelay); werr != nil {
|
|
||||||
return "", fmt.Errorf("stun: write to relay: %w", werr)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readBuf := make([]byte, 1500)
|
metric := fmt.Sprintf("loss=%.0f%% jitter=%.1fms p50=%.1fms",
|
||||||
n, _, rerr := pc.ReadFrom(readBuf)
|
res.LossPct, res.JitterMS, res.P50RTTMS)
|
||||||
if rerr != nil {
|
|
||||||
return "", fmt.Errorf("stun: read from relay: %w", rerr)
|
|
||||||
}
|
|
||||||
rtt := time.Since(start)
|
|
||||||
|
|
||||||
if n < 10 {
|
switch {
|
||||||
return "", fmt.Errorf("stun: relay reply too short (%d bytes)", n)
|
case res.LossPct <= 5.0 && res.JitterMS <= 30.0 && res.P50RTTMS <= 250.0:
|
||||||
}
|
e.emit(Result{
|
||||||
// Validate SOCKS5 UDP wrapper: RSV=00 00, FRAG=00, ATYP=01.
|
ID: "voice-quality",
|
||||||
if readBuf[0] != 0x00 || readBuf[1] != 0x00 || readBuf[2] != 0x00 {
|
Status: StatusPassed,
|
||||||
return "", fmt.Errorf("stun: bad SOCKS5 UDP header (raw=%x)", readBuf[:10])
|
Metric: metric,
|
||||||
}
|
Attempt: attempt,
|
||||||
// We sent IPv4, expect IPv4 reply.
|
Duration: dur,
|
||||||
var hdrLen int
|
})
|
||||||
switch readBuf[3] {
|
e.voiceQualityOK = true
|
||||||
case 0x01:
|
return
|
||||||
hdrLen = 10
|
case res.LossPct <= 15.0 && res.JitterMS <= 60.0 && res.P50RTTMS <= 400.0:
|
||||||
case 0x04:
|
e.emit(Result{
|
||||||
hdrLen = 22
|
ID: "voice-quality",
|
||||||
case 0x03:
|
Status: StatusWarn,
|
||||||
if n < 5 {
|
Metric: metric,
|
||||||
return "", fmt.Errorf("stun: truncated SOCKS5 UDP domain header")
|
Hint: voiceQualityWarnHint(res.LossPct, res.JitterMS, res.P50RTTMS),
|
||||||
}
|
Attempt: attempt,
|
||||||
hdrLen = 4 + 1 + int(readBuf[4]) + 2
|
Duration: dur,
|
||||||
|
})
|
||||||
|
e.voiceQualityOK = true
|
||||||
|
return
|
||||||
default:
|
default:
|
||||||
return "", fmt.Errorf("stun: unknown SOCKS5 UDP ATYP=0x%02X", readBuf[3])
|
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
|
||||||
}
|
}
|
||||||
if n < hdrLen {
|
|
||||||
return "", fmt.Errorf("stun: relay reply truncated (%d < %d)", n, hdrLen)
|
|
||||||
}
|
}
|
||||||
stunReply := readBuf[hdrLen:n]
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_, _, perr := ParseBindingResponse(stunReply, txID)
|
// runVoiceSrv — Test 7: probe Discord voice-domain reachability through
|
||||||
if perr != nil {
|
// the SOCKS5 proxy. Single attempt (DNS+connect bursts are slow and
|
||||||
return "", perr
|
// idempotent — retry budget better spent on transient handshake steps).
|
||||||
|
func (e *executor) runVoiceSrv() {
|
||||||
|
if e.shouldSkip("voice-srv", e.connectOK) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%dms RTT", rtt.Milliseconds()), nil
|
|
||||||
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ type fakeProxy struct {
|
|||||||
|
|
||||||
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
|
udpRelayAddr *net.UDPAddr // announced in UDP ASSOCIATE reply
|
||||||
|
|
||||||
|
// udpDropEveryN, when > 0, drops every Nth packet through the relay
|
||||||
|
// (counted across the whole listener lifetime). N=2 → 50% loss; N=10
|
||||||
|
// → 10%; N=1 → 100% loss; 0 → no drops.
|
||||||
|
udpDropEveryN atomic.Int32
|
||||||
|
udpRelayCount atomic.Int32
|
||||||
|
|
||||||
// API-passthrough hook: when a CONNECT targets this host:port,
|
// API-passthrough hook: when a CONNECT targets this host:port,
|
||||||
// the proxy dials apiTargetAddr and splices the conns instead of
|
// the proxy dials apiTargetAddr and splices the conns instead of
|
||||||
// sending a fake REP=00 + close.
|
// sending a fake REP=00 + close.
|
||||||
@@ -42,6 +48,11 @@ type fakeProxy struct {
|
|||||||
apiTargetPort uint16
|
apiTargetPort uint16
|
||||||
apiTargetAddr string
|
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
|
// timeoutFirstAttempt stalls the first connection on greet to
|
||||||
// drive a timeout. Subsequent connections behave normally.
|
// drive a timeout. Subsequent connections behave normally.
|
||||||
timeoutFirstAttempt atomic.Int32
|
timeoutFirstAttempt atomic.Int32
|
||||||
@@ -84,7 +95,8 @@ func newFakeProxy(t *testing.T, scenario string) *fakeProxy {
|
|||||||
|
|
||||||
func needsUDPRelay(scenario string) bool {
|
func needsUDPRelay(scenario string) bool {
|
||||||
switch scenario {
|
switch scenario {
|
||||||
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok":
|
case "happy_no_auth", "happy_with_auth", "udp_unsupported", "connect_refused", "timeout_then_ok",
|
||||||
|
"voice_quality_warn", "voice_quality_fail", "voice_srv_blocked":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -184,6 +196,23 @@ func (fp *fakeProxy) handle(conn net.Conn) {
|
|||||||
|
|
||||||
switch cmdReq.cmd {
|
switch cmdReq.cmd {
|
||||||
case 0x01: // CONNECT
|
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 {
|
switch fp.scenario {
|
||||||
case "connect_refused":
|
case "connect_refused":
|
||||||
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
_, _ = conn.Write([]byte{0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0})
|
||||||
@@ -255,6 +284,16 @@ func (fp *fakeProxy) runRelay(uconn *net.UDPConn) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Optional packet-drop simulation. udpDropEveryN of value 1 drops
|
||||||
|
// everything; 2 drops every other packet; 10 drops 10%.
|
||||||
|
if dropN := fp.udpDropEveryN.Load(); dropN > 0 {
|
||||||
|
c := fp.udpRelayCount.Add(1)
|
||||||
|
if c%dropN == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fp.udpRelayCount.Add(1)
|
||||||
|
}
|
||||||
if n < 10 {
|
if n < 10 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -478,6 +517,13 @@ func hostPort(addr string) (string, int) {
|
|||||||
|
|
||||||
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
// proxyConfig builds a Config pointed at the given fakeProxy with sane
|
||||||
// short timeouts for tests.
|
// 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 {
|
func proxyConfig(fp *fakeProxy, useAuth bool) Config {
|
||||||
host, port := hostPort(fp.addr)
|
host, port := hostPort(fp.addr)
|
||||||
cfg := Config{
|
cfg := Config{
|
||||||
@@ -487,6 +533,9 @@ func proxyConfig(fp *fakeProxy, useAuth bool) Config {
|
|||||||
PerTestTimeout: 500 * time.Millisecond,
|
PerTestTimeout: 500 * time.Millisecond,
|
||||||
MaxRetries: 1,
|
MaxRetries: 1,
|
||||||
RetryBackoff: 30 * time.Millisecond,
|
RetryBackoff: 30 * time.Millisecond,
|
||||||
|
VoiceBurstCount: 10,
|
||||||
|
VoiceBurstInterval: 5 * time.Millisecond,
|
||||||
|
VoiceServerHostnames: []string{"localhost"},
|
||||||
}
|
}
|
||||||
if useAuth {
|
if useAuth {
|
||||||
cfg.ProxyLogin = "u"
|
cfg.ProxyLogin = "u"
|
||||||
@@ -560,7 +609,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", "stun", "api"}
|
expected := []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "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)
|
||||||
@@ -579,6 +628,8 @@ 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.Contains(t, finals["voice-srv"].Metric, "1/1 regions")
|
||||||
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
assert.Equal(t, "HTTP 200", finals["api"].Metric)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,7 +643,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", "stun", "api"}
|
expected := []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "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)
|
||||||
@@ -623,7 +674,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", "stun", "api"} {
|
for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "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)
|
||||||
@@ -648,7 +699,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", "stun", "api"} {
|
for _, id := range []string{"connect", "udp", "voice-quality", "voice-srv", "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)
|
||||||
@@ -680,9 +731,13 @@ 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")
|
||||||
|
|
||||||
// stun depends on udp → passes too.
|
// voice-quality depends on udp → passes too.
|
||||||
rS, _ := finalByID(results, "stun")
|
rVQ, _ := finalByID(results, "voice-quality")
|
||||||
assert.Equal(t, StatusPassed, rS.Status)
|
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.
|
// api depends on connect → skipped.
|
||||||
rA, _ := finalByID(results, "api")
|
rA, _ := finalByID(results, "api")
|
||||||
@@ -708,8 +763,13 @@ 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)
|
||||||
|
|
||||||
rS, _ := finalByID(results, "stun")
|
// voice-quality depends on udp → skipped.
|
||||||
assert.Equal(t, StatusSkipped, rS.Status)
|
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")
|
rA, _ := finalByID(results, "api")
|
||||||
assert.Equal(t, StatusPassed, rA.Status)
|
assert.Equal(t, StatusPassed, rA.Status)
|
||||||
@@ -747,7 +807,7 @@ func TestRun_TimeoutThenOK(t *testing.T) {
|
|||||||
assert.Equal(t, 2, greetEvents[3].Attempt)
|
assert.Equal(t, 2, greetEvents[3].Attempt)
|
||||||
|
|
||||||
// All seven non-auth tests should ultimately pass.
|
// All seven non-auth tests should ultimately pass.
|
||||||
for _, id := range []string{"tcp", "greet", "connect", "udp", "stun", "api"} {
|
for _, id := range []string{"tcp", "greet", "connect", "udp", "voice-quality", "voice-srv", "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)
|
||||||
@@ -813,8 +873,9 @@ func TestRun_CancelledMidFlight(t *testing.T) {
|
|||||||
// Either: one cancelled-failed + rest cancelled-skipped, OR all
|
// Either: one cancelled-failed + rest cancelled-skipped, OR all
|
||||||
// cancelled-skipped (if cancellation hit before next test even
|
// cancelled-skipped (if cancellation hit before next test even
|
||||||
// started). Both are acceptable.
|
// started). Both are acceptable.
|
||||||
// Without auth, 5 tests remain after tcp (greet/connect/udp/stun/api).
|
// Without auth, 6 tests remain after tcp (greet/connect/udp/
|
||||||
// Cancel may race with greet completing successfully, so accept ≥4.
|
// 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)
|
assert.GreaterOrEqual(t, failed+skipped, 4, "expected at least 4 cancellation-marked results, got failed=%d skipped=%d all=%+v", failed, skipped, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,6 +927,79 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestExtractRawHex(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
in, want string
|
in, want string
|
||||||
|
|||||||
+74
-14
@@ -4,6 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,8 +36,10 @@ func tcpFriendlyName(testID string) string {
|
|||||||
return "TCP-туннель к Discord"
|
return "TCP-туннель к Discord"
|
||||||
case "udp":
|
case "udp":
|
||||||
return "UDP ASSOCIATE"
|
return "UDP ASSOCIATE"
|
||||||
case "stun":
|
case "voice-quality":
|
||||||
return "STUN round-trip"
|
return "качество UDP-канала"
|
||||||
|
case "voice-srv":
|
||||||
|
return "доступность voice-серверов Discord"
|
||||||
case "api":
|
case "api":
|
||||||
return "Discord API"
|
return "Discord API"
|
||||||
default:
|
default:
|
||||||
@@ -125,25 +128,35 @@ func hintFor(testID string, err error) string {
|
|||||||
}
|
}
|
||||||
return genericFallback(testID, err)
|
return genericFallback(testID, err)
|
||||||
|
|
||||||
case "stun":
|
case "voice-quality":
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, ErrSTUNNoMappedAddress):
|
case errors.Is(err, ErrSTUNNoMappedAddress):
|
||||||
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
return "STUN-ответ без XOR-MAPPED-ADDRESS — UDP-релей не пропускает обратный трафик."
|
||||||
case errors.Is(err, ErrSTUNTooShort):
|
case errors.Is(err, ErrSTUNTooShort),
|
||||||
return "STUN-ответ короче 20-байтного заголовка — релей возвращает мусор."
|
errors.Is(err, ErrSTUNBadMagicCookie),
|
||||||
case errors.Is(err, ErrSTUNBadMagicCookie):
|
errors.Is(err, ErrSTUNNotSuccess),
|
||||||
return "STUN-ответ без правильного magic cookie — релей возвращает мусор."
|
errors.Is(err, ErrSTUNTxIDMismatch),
|
||||||
case errors.Is(err, ErrSTUNNotSuccess):
|
errors.Is(err, ErrSTUNUnsupportedFamily):
|
||||||
return "STUN-сервер вернул не Binding Success — UDP-релей сломан."
|
return "STUN-релей возвращает мусор — голос работать не будет."
|
||||||
case errors.Is(err, ErrSTUNTxIDMismatch):
|
|
||||||
return "STUN-ответ с чужим transaction ID — релей путает пакеты."
|
|
||||||
case errors.Is(err, ErrSTUNUnsupportedFamily):
|
|
||||||
return "STUN-ответ с неподдерживаемым семейством адресов."
|
|
||||||
case isTimeout:
|
case isTimeout:
|
||||||
return "STUN-сервер не ответил вовремя — UDP-релей не работает в обе стороны."
|
return "STUN-релей не отвечает — UDP через прокси сильно теряет пакеты."
|
||||||
|
}
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
return "Не удалось разрезолвить STUN-сервер — проверь системный DNS."
|
||||||
}
|
}
|
||||||
return genericFallback(testID, err)
|
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":
|
case "api":
|
||||||
switch {
|
switch {
|
||||||
case isTimeout:
|
case isTimeout:
|
||||||
@@ -187,3 +200,50 @@ func socks5ReplyHint(step string, code byte) string {
|
|||||||
func genericFallback(testID string, err error) string {
|
func genericFallback(testID string, err error) string {
|
||||||
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
return fmt.Sprintf("Не удалось выполнить шаг «%s»: %s", tcpFriendlyName(testID), err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// voiceQualityWarnHint composes a warn-tier hint based on which threshold
|
||||||
|
// was violated. Thresholds match runVoiceQuality's warn band: loss>5,
|
||||||
|
// jitter>30, p50>250. Always returns non-empty.
|
||||||
|
func voiceQualityWarnHint(loss, jitter, p50 float64) string {
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
if loss > 5.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос будет с заиканиями", loss))
|
||||||
|
}
|
||||||
|
if jitter > 30.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("большой джиттер %.1fms — звук будет дёргаться", jitter))
|
||||||
|
}
|
||||||
|
if p50 > 250.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("высокая задержка %.0fms — заметная рассинхронизация при разговоре", p50))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
// Shouldn't happen — caller only invokes us in the warn band.
|
||||||
|
return "UDP-канал на грани приемлемого — возможны помехи в голосе."
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ") + "."
|
||||||
|
}
|
||||||
|
|
||||||
|
// voiceQualityFailHint composes a fail-tier hint. p95 is informational —
|
||||||
|
// included only when notably worse than p50.
|
||||||
|
func voiceQualityFailHint(loss, jitter, p50, p95 float64) string {
|
||||||
|
_ = p95
|
||||||
|
parts := make([]string, 0, 3)
|
||||||
|
if loss > 15.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%% — голос работать не будет", loss))
|
||||||
|
} else if loss > 5.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("Потери UDP %.0f%%", loss))
|
||||||
|
}
|
||||||
|
if jitter > 60.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("джиттер %.1fms — звук развалится", jitter))
|
||||||
|
} else if jitter > 30.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("джиттер %.1fms", jitter))
|
||||||
|
}
|
||||||
|
if p50 > 400.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("задержка %.0fms — голос идёт со значительной паузой", p50))
|
||||||
|
} else if p50 > 250.0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("задержка %.0fms", p50))
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return "UDP-канал не пригоден для голоса."
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "; ") + "."
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func TestHintFor(t *testing.T) {
|
|||||||
t.Run("context_canceled_uniform", func(t *testing.T) {
|
t.Run("context_canceled_uniform", func(t *testing.T) {
|
||||||
// Cancellation is always reported as «Проверка отменена.» across
|
// Cancellation is always reported as «Проверка отменена.» across
|
||||||
// all testIDs.
|
// all testIDs.
|
||||||
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "stun", "api", "unknown"} {
|
for _, id := range []string{"tcp", "greet", "auth", "connect", "udp", "voice-quality", "voice-srv", "api", "unknown"} {
|
||||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
|
assert.Equal(t, "Проверка отменена.", hintFor(id, context.Canceled), "id=%s", id)
|
||||||
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
|
assert.Equal(t, "Проверка отменена.", hintFor(id, context.DeadlineExceeded), "id=%s", id)
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ func TestHintFor(t *testing.T) {
|
|||||||
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
|
{"udp_unsupported_mentions_udp", "udp", ErrSocks5Reply{Code: 0x07}, "UDP"},
|
||||||
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
|
{"udp_unsupported_mentions_unsupported", "udp", ErrSocks5Reply{Code: 0x07}, "не поддерж"},
|
||||||
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
|
{"udp_atyp_ipv6", "udp", ErrUnsupportedRelayATYP, "IPv6"},
|
||||||
{"stun_no_mapped_xor", "stun", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
|
{"voice_quality_no_mapped_xor", "voice-quality", ErrSTUNNoMappedAddress, "XOR-MAPPED"},
|
||||||
{"stun_timeout_mentions_stun", "stun", &timeoutOnlyError{}, "STUN"},
|
{"voice_quality_timeout_mentions_stun", "voice-quality", &timeoutOnlyError{}, "STUN"},
|
||||||
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
{"api_timeout_mentions_api_or_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||||
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
|
{"unknown_test_fallback_id", "unknown_test", errors.New("oops"), "unknown_test"},
|
||||||
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
|
{"unknown_test_fallback_err", "unknown_test", errors.New("oops"), "oops"},
|
||||||
@@ -111,13 +111,19 @@ func TestHintFor_PerStepBranches(t *testing.T) {
|
|||||||
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
{"udp_unknown_rep", "udp", ErrSocks5Reply{Code: 0xFE}, "REP=FE"},
|
||||||
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
|
{"udp_fallback", "udp", errors.New("weird"), "UDP ASSOCIATE"},
|
||||||
|
|
||||||
// stun: every sentinel branch
|
// voice-quality: every sentinel branch (collapsed in 2026-05-01
|
||||||
{"stun_too_short", "stun", ErrSTUNTooShort, "20"},
|
// rewrite into a single user-visible message rather than
|
||||||
{"stun_bad_magic", "stun", ErrSTUNBadMagicCookie, "magic"},
|
// per-error "магник cookie" / "семейство адресов" exposition)
|
||||||
{"stun_not_success", "stun", ErrSTUNNotSuccess, "Binding"},
|
{"voice_quality_too_short", "voice-quality", ErrSTUNTooShort, "мусор"},
|
||||||
{"stun_txid_mismatch", "stun", ErrSTUNTxIDMismatch, "transaction"},
|
{"voice_quality_bad_magic", "voice-quality", ErrSTUNBadMagicCookie, "мусор"},
|
||||||
{"stun_unsupported_family", "stun", ErrSTUNUnsupportedFamily, "семейством"},
|
{"voice_quality_not_success", "voice-quality", ErrSTUNNotSuccess, "мусор"},
|
||||||
{"stun_fallback", "stun", errors.New("weird"), "STUN"},
|
{"voice_quality_txid_mismatch", "voice-quality", ErrSTUNTxIDMismatch, "мусор"},
|
||||||
|
{"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 vs generic
|
||||||
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
{"api_timeout", "api", &timeoutOnlyError{}, "таймаут"},
|
||||||
@@ -151,7 +157,8 @@ func TestTcpFriendlyName(t *testing.T) {
|
|||||||
"auth": "авторизация SOCKS5",
|
"auth": "авторизация SOCKS5",
|
||||||
"connect": "TCP-туннель к Discord",
|
"connect": "TCP-туннель к Discord",
|
||||||
"udp": "UDP ASSOCIATE",
|
"udp": "UDP ASSOCIATE",
|
||||||
"stun": "STUN round-trip",
|
"voice-quality": "качество UDP-канала",
|
||||||
|
"voice-srv": "доступность voice-серверов Discord",
|
||||||
"api": "Discord API",
|
"api": "Discord API",
|
||||||
"weirdo": "weirdo",
|
"weirdo": "weirdo",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,438 @@
|
|||||||
|
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 <region>.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.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VoiceQualityResult is the outcome of a UDP burst through a SOCKS5
|
||||||
|
// relay. All fields are zero on a hard failure (no replies at all).
|
||||||
|
type VoiceQualityResult struct {
|
||||||
|
Sent int
|
||||||
|
Received int
|
||||||
|
LossPct float64 // 0..100
|
||||||
|
JitterMS float64 // mean abs of inter-arrival deltas in ms
|
||||||
|
P50RTTMS float64 // median round-trip in ms
|
||||||
|
P95RTTMS float64 // 95th percentile (informational, not gated)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runVoiceQualityBurst sends `count` STUN binding requests through the
|
||||||
|
// already-open SOCKS5 UDP relay (relayAddr) to stunHost:stunPort,
|
||||||
|
// spaced `interval` apart. It listens on udpConn until
|
||||||
|
// `time.Now() + max(interval, 200ms)` after the last send, then returns
|
||||||
|
// the aggregate result.
|
||||||
|
//
|
||||||
|
// Each outbound datagram has the SOCKS5 UDP header
|
||||||
|
// (RSV 00 00, FRAG 00, ATYP 01, DST_IPv4(4), DST_PORT(2)) followed by
|
||||||
|
// a 20-byte STUN binding request. We track each request by its
|
||||||
|
// transaction ID. Replies are stripped of their 10-byte SOCKS5 UDP
|
||||||
|
// header before being handed to ParseBindingResponse.
|
||||||
|
//
|
||||||
|
// Returns an error only when ctx is cancelled or stunHost can't be
|
||||||
|
// resolved to IPv4. A 100% loss is NOT an error — the caller decides
|
||||||
|
// what status to assign; we just report Sent=count, Received=0.
|
||||||
|
func runVoiceQualityBurst(
|
||||||
|
ctx context.Context,
|
||||||
|
udpConn net.PacketConn,
|
||||||
|
relayAddr *net.UDPAddr,
|
||||||
|
stunHost string,
|
||||||
|
stunPort uint16,
|
||||||
|
count int,
|
||||||
|
interval time.Duration,
|
||||||
|
) (VoiceQualityResult, error) {
|
||||||
|
if count <= 0 {
|
||||||
|
return VoiceQualityResult{}, errors.New("voice-quality: burst count must be > 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve stunHost to IPv4.
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip4", stunHost)
|
||||||
|
if err != nil {
|
||||||
|
return VoiceQualityResult{}, fmt.Errorf("voice-quality: lookup %s: %w", stunHost, err)
|
||||||
|
}
|
||||||
|
var stunIP4 net.IP
|
||||||
|
for _, ip := range ips {
|
||||||
|
if v4 := ip.To4(); v4 != nil {
|
||||||
|
stunIP4 = v4
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if stunIP4 == nil {
|
||||||
|
return VoiceQualityResult{}, fmt.Errorf("voice-quality: no IPv4 for %s", stunHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-tx state: send-time + arrival-time.
|
||||||
|
type entry struct {
|
||||||
|
sentAt time.Time
|
||||||
|
arrivedAt time.Time
|
||||||
|
received bool
|
||||||
|
}
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
entries = make(map[[12]byte]*entry, count)
|
||||||
|
arrivals = make([]time.Time, 0, count) // for jitter (in arrival order)
|
||||||
|
rtts = make([]float64, 0, count) // milliseconds
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reader goroutine: loops on ReadFrom until deadline expires.
|
||||||
|
doneRead := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(doneRead)
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for {
|
||||||
|
n, _, rerr := udpConn.ReadFrom(buf)
|
||||||
|
if rerr != nil {
|
||||||
|
// Deadline expired or conn closed — exit.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Validate SOCKS5 UDP wrapper, derive header length.
|
||||||
|
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var hdrLen int
|
||||||
|
switch buf[3] {
|
||||||
|
case 0x01:
|
||||||
|
hdrLen = 10
|
||||||
|
case 0x04:
|
||||||
|
hdrLen = 22
|
||||||
|
case 0x03:
|
||||||
|
if n < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n < hdrLen+20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stunReply := buf[hdrLen:n]
|
||||||
|
// Pull the transaction ID out of the STUN header so we
|
||||||
|
// can look up the matching send-time. ParseBindingResponse
|
||||||
|
// rejects mismatched txIDs, so we feed it the *expected*
|
||||||
|
// id from the entries map.
|
||||||
|
var txID [12]byte
|
||||||
|
copy(txID[:], stunReply[8:20])
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
mu.Lock()
|
||||||
|
ent, ok := entries[txID]
|
||||||
|
if !ok || ent.received {
|
||||||
|
mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, _, perr := ParseBindingResponse(stunReply, txID); perr != nil {
|
||||||
|
mu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ent.arrivedAt = now
|
||||||
|
ent.received = true
|
||||||
|
arrivals = append(arrivals, now)
|
||||||
|
rtts = append(rtts, float64(now.Sub(ent.sentAt).Microseconds())/1000.0)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Build base SOCKS5 UDP header (RSV+FRAG+ATYP+IP+PORT). STUN body
|
||||||
|
// is per-packet (fresh tx id each).
|
||||||
|
hdr := make([]byte, 0, 10)
|
||||||
|
hdr = append(hdr, 0x00, 0x00, 0x00, 0x01)
|
||||||
|
hdr = append(hdr, stunIP4...)
|
||||||
|
var portBuf [2]byte
|
||||||
|
binary.BigEndian.PutUint16(portBuf[:], stunPort)
|
||||||
|
hdr = append(hdr, portBuf[:]...)
|
||||||
|
|
||||||
|
// Send burst.
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
sent := 0
|
||||||
|
sendLoop:
|
||||||
|
for sent < count {
|
||||||
|
// Make a fresh tx id and STUN request.
|
||||||
|
txID, terr := NewTransactionID()
|
||||||
|
if terr != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
stunReq := EncodeBindingRequest(txID)
|
||||||
|
dgram := make([]byte, 0, len(hdr)+len(stunReq))
|
||||||
|
dgram = append(dgram, hdr...)
|
||||||
|
dgram = append(dgram, stunReq...)
|
||||||
|
|
||||||
|
// Record send-time *before* the write. Note: we register the
|
||||||
|
// entry into the map BEFORE Write so the reader can never get a
|
||||||
|
// reply for an unknown tx (would happen on a very fast localhost
|
||||||
|
// echo).
|
||||||
|
now := time.Now()
|
||||||
|
mu.Lock()
|
||||||
|
entries[txID] = &entry{sentAt: now}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
if _, werr := udpConn.WriteTo(dgram, relayAddr); werr != nil {
|
||||||
|
// Write failure aborts the burst — but we still wait for
|
||||||
|
// any in-flight replies. Treat as "sent so far".
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sent++
|
||||||
|
|
||||||
|
if sent >= count {
|
||||||
|
break sendLoop
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for next tick OR ctx cancel.
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
case <-ctx.Done():
|
||||||
|
break sendLoop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait window for stragglers — at least 200ms past last send.
|
||||||
|
wait := interval
|
||||||
|
if wait < 200*time.Millisecond {
|
||||||
|
wait = 200 * time.Millisecond
|
||||||
|
}
|
||||||
|
deadline := time.Now().Add(wait)
|
||||||
|
_ = udpConn.SetReadDeadline(deadline)
|
||||||
|
|
||||||
|
// Wait for reader to exit. ctx cancel still races: bound by deadline.
|
||||||
|
select {
|
||||||
|
case <-doneRead:
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Force the reader to exit ASAP by setting a past deadline.
|
||||||
|
_ = udpConn.SetReadDeadline(time.Unix(0, 1))
|
||||||
|
<-doneRead
|
||||||
|
}
|
||||||
|
// Reset deadline so subsequent users of the conn aren't surprised.
|
||||||
|
_ = udpConn.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
// Compute aggregates.
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
received := len(rtts)
|
||||||
|
res := VoiceQualityResult{
|
||||||
|
Sent: sent,
|
||||||
|
Received: received,
|
||||||
|
}
|
||||||
|
if sent > 0 {
|
||||||
|
res.LossPct = float64(sent-received) / float64(sent) * 100.0
|
||||||
|
}
|
||||||
|
if received >= 2 {
|
||||||
|
// Sort arrivals to compute inter-arrival jitter in chronological order.
|
||||||
|
// arrivals is already chronological (appended as packets came in).
|
||||||
|
var diffs []float64
|
||||||
|
for i := 1; i < len(arrivals); i++ {
|
||||||
|
d := float64(arrivals[i].Sub(arrivals[i-1]).Microseconds()) / 1000.0
|
||||||
|
diffs = append(diffs, d)
|
||||||
|
}
|
||||||
|
// mean abs of consecutive deltas of inter-arrival diffs.
|
||||||
|
if len(diffs) >= 2 {
|
||||||
|
var sum float64
|
||||||
|
for i := 1; i < len(diffs); i++ {
|
||||||
|
sum += math.Abs(diffs[i] - diffs[i-1])
|
||||||
|
}
|
||||||
|
res.JitterMS = sum / float64(len(diffs)-1)
|
||||||
|
} else if len(diffs) == 1 {
|
||||||
|
// Only two arrivals — single delta, no second-order jitter.
|
||||||
|
res.JitterMS = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if received > 0 {
|
||||||
|
// percentile.
|
||||||
|
sorted := make([]float64, len(rtts))
|
||||||
|
copy(sorted, rtts)
|
||||||
|
sort.Float64s(sorted)
|
||||||
|
p50idx := len(sorted) / 2
|
||||||
|
if p50idx >= len(sorted) {
|
||||||
|
p50idx = len(sorted) - 1
|
||||||
|
}
|
||||||
|
res.P50RTTMS = sorted[p50idx]
|
||||||
|
p95idx := int(0.95 * float64(len(sorted)))
|
||||||
|
if p95idx >= len(sorted) {
|
||||||
|
p95idx = len(sorted) - 1
|
||||||
|
}
|
||||||
|
res.P95RTTMS = sorted[p95idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
package checker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeUDPRelay listens on a UDP socket and echoes SOCKS5-wrapped STUN
|
||||||
|
// binding requests as a synthetic Binding Success Response, just like
|
||||||
|
// fakeProxy.runRelay in checker_test.go but standalone (no SOCKS5 TCP
|
||||||
|
// control channel needed). dropEveryN > 0 drops every Nth packet.
|
||||||
|
type fakeUDPRelay struct {
|
||||||
|
conn *net.UDPConn
|
||||||
|
addr *net.UDPAddr
|
||||||
|
dropEveryN atomic.Int32
|
||||||
|
count atomic.Int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeUDPRelay(t *testing.T) *fakeUDPRelay {
|
||||||
|
t.Helper()
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
uconn := pc.(*net.UDPConn)
|
||||||
|
r := &fakeUDPRelay{
|
||||||
|
conn: uconn,
|
||||||
|
addr: uconn.LocalAddr().(*net.UDPAddr),
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = uconn.Close() })
|
||||||
|
go r.serve()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *fakeUDPRelay) serve() {
|
||||||
|
buf := make([]byte, 2048)
|
||||||
|
for {
|
||||||
|
n, src, err := r.conn.ReadFromUDP(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if dropN := r.dropEveryN.Load(); dropN > 0 {
|
||||||
|
c := r.count.Add(1)
|
||||||
|
if c%dropN == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
r.count.Add(1)
|
||||||
|
}
|
||||||
|
if n < 10 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if buf[0] != 0x00 || buf[1] != 0x00 || buf[2] != 0x00 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var hdrLen int
|
||||||
|
switch buf[3] {
|
||||||
|
case 0x01:
|
||||||
|
hdrLen = 10
|
||||||
|
case 0x04:
|
||||||
|
hdrLen = 22
|
||||||
|
case 0x03:
|
||||||
|
if n < 5 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hdrLen = 4 + 1 + int(buf[4]) + 2
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n < hdrLen+20 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stunReq := buf[hdrLen:n]
|
||||||
|
var txID [12]byte
|
||||||
|
copy(txID[:], stunReq[8:20])
|
||||||
|
|
||||||
|
ip4 := src.IP.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xport := uint16(src.Port) ^ uint16(stunMagicCookie>>16)
|
||||||
|
xaddr := binary.BigEndian.Uint32(ip4) ^ stunMagicCookie
|
||||||
|
|
||||||
|
stunResp := make([]byte, 20+12)
|
||||||
|
binary.BigEndian.PutUint16(stunResp[0:2], stunBindingSuccessResponse)
|
||||||
|
binary.BigEndian.PutUint16(stunResp[2:4], 12)
|
||||||
|
binary.BigEndian.PutUint32(stunResp[4:8], stunMagicCookie)
|
||||||
|
copy(stunResp[8:20], txID[:])
|
||||||
|
binary.BigEndian.PutUint16(stunResp[20:22], stunAttrXORMappedAddress)
|
||||||
|
binary.BigEndian.PutUint16(stunResp[22:24], 8)
|
||||||
|
stunResp[24] = 0
|
||||||
|
stunResp[25] = 0x01
|
||||||
|
binary.BigEndian.PutUint16(stunResp[26:28], xport)
|
||||||
|
binary.BigEndian.PutUint32(stunResp[28:32], xaddr)
|
||||||
|
|
||||||
|
out := make([]byte, 0, 10+len(stunResp))
|
||||||
|
out = append(out, 0x00, 0x00, 0x00, 0x01)
|
||||||
|
out = append(out, ip4...)
|
||||||
|
var portBuf [2]byte
|
||||||
|
binary.BigEndian.PutUint16(portBuf[:], uint16(src.Port))
|
||||||
|
out = append(out, portBuf[:]...)
|
||||||
|
out = append(out, stunResp...)
|
||||||
|
|
||||||
|
_, _ = r.conn.WriteToUDP(out, src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoiceQualityBurst_Math: full 30-of-30 reception on localhost, all
|
||||||
|
// RTTs in single-digit milliseconds.
|
||||||
|
func TestVoiceQualityBurst_Math(t *testing.T) {
|
||||||
|
relay := newFakeUDPRelay(t)
|
||||||
|
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientPC.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||||
|
"localhost", 19302, 30, 5*time.Millisecond)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 30, res.Sent)
|
||||||
|
assert.Equal(t, 30, res.Received)
|
||||||
|
assert.InDelta(t, 0.0, res.LossPct, 0.001)
|
||||||
|
assert.Less(t, res.P50RTTMS, 50.0, "loopback p50 should be tiny")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoiceQualityBurst_HalfLoss verifies the loss-percentage math when
|
||||||
|
// the relay drops half the packets.
|
||||||
|
func TestVoiceQualityBurst_HalfLoss(t *testing.T) {
|
||||||
|
relay := newFakeUDPRelay(t)
|
||||||
|
relay.dropEveryN.Store(2) // every other packet → 50% loss
|
||||||
|
|
||||||
|
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientPC.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||||
|
"localhost", 19302, 20, 3*time.Millisecond)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 20, res.Sent)
|
||||||
|
assert.InDelta(t, 50.0, res.LossPct, 5.0, "expected ~50%% loss got %+v", res)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoiceQualityBurst_AllDropped: dropEveryN=1 → 100% loss. Should NOT
|
||||||
|
// return an error; should report Sent=N, Received=0.
|
||||||
|
func TestVoiceQualityBurst_AllDropped(t *testing.T) {
|
||||||
|
relay := newFakeUDPRelay(t)
|
||||||
|
relay.dropEveryN.Store(1)
|
||||||
|
|
||||||
|
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientPC.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
res, err := runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||||
|
"localhost", 19302, 10, 3*time.Millisecond)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, 10, res.Sent)
|
||||||
|
assert.Equal(t, 0, res.Received)
|
||||||
|
assert.InDelta(t, 100.0, res.LossPct, 0.001)
|
||||||
|
assert.Equal(t, 0.0, res.P50RTTMS)
|
||||||
|
assert.Equal(t, 0.0, res.JitterMS)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVoiceQualityBurst_ZeroCount: count=0 → error (defensive).
|
||||||
|
func TestVoiceQualityBurst_ZeroCount(t *testing.T) {
|
||||||
|
relay := newFakeUDPRelay(t)
|
||||||
|
clientPC, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer clientPC.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, err = runVoiceQualityBurst(ctx, clientPC, relay.addr,
|
||||||
|
"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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user