package checker // voice.go — predictive voice diagnostics. // // 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" "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 }