checker-stun-turn/checker/stun.go
Pierre-Olivier Mercier 7c7706fe3f Initial commit
Adds a happyDomain checker that probes STUN/TURN servers end-to-end:
DNS/SRV discovery, UDP/TCP/TLS/DTLS dial, STUN binding + reflexive-addr
sanity, open-relay detection, authenticated TURN Allocate (long-term
creds or REST-API HMAC), public-relay check, CreatePermission + Send
round-trip through the relay, and optional ChannelBind.

Failing sub-tests carry a remediation string (`Fix`) that the HTML
report surfaces as a yellow headline callout and inline next to each
row. Mapping covers the most common coturn misconfigurations
(external-ip, relay-ip, lt-cred-mech, min-port/max-port, cert issues,
401 nonce drift, 441/442/486/508 allocation errors).

Implements sdk.EndpointDiscoverer (checker/discovery.go): every
stuns:/turns:/DTLS endpoint observed during Collect is published as a
DiscoveredEndpoint{Type: "tls"|"dtls"} so a downstream TLS checker can
verify certificates without re-parsing the observation.

Backed by pion/stun/v3 + pion/turn/v4 + pion/dtls/v3; SDK pinned to a
local replace until the EndpointDiscoverer interface ships in a tagged
release.
2026-04-26 19:55:05 +07:00

61 lines
1.7 KiB
Go

package checker
import (
"fmt"
"net"
"time"
"github.com/pion/turn/v4"
)
// stunBindingResult holds the outcome of a STUN Binding test.
type stunBindingResult struct {
RTT time.Duration
ReflexiveAddr net.Addr
IsPrivateMapped bool
Err error
}
// runSTUNBinding sends a STUN Binding Request to the remote and returns the
// reflexive (XOR-MAPPED) address along with the RTT. We construct a tiny
// turn.Client with no credentials; its SendBindingRequestTo path drives a
// vanilla STUN exchange (RFC 5389) and works on UDP/TCP/TLS/DTLS through
// the dialed PacketConn we hand it.
func runSTUNBinding(d *dialedConn, timeout time.Duration) stunBindingResult {
cfg := &turn.ClientConfig{
Conn: d.pc,
STUNServerAddr: d.remoteAddr.String(),
RTO: timeout,
Software: "happyDomain-checker-stun-turn",
}
client, err := turn.NewClient(cfg)
if err != nil {
return stunBindingResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
}
defer client.Close()
if err := client.Listen(); err != nil {
return stunBindingResult{Err: fmt.Errorf("client.Listen: %w", err)}
}
start := time.Now()
addr, err := client.SendBindingRequestTo(d.remoteAddr)
if err != nil {
return stunBindingResult{Err: err}
}
res := stunBindingResult{
RTT: time.Since(start),
ReflexiveAddr: addr,
}
if udpAddr, ok := addr.(*net.UDPAddr); ok {
res.IsPrivateMapped = isPrivate(udpAddr.IP)
} else if tcpAddr, ok := addr.(*net.TCPAddr); ok {
res.IsPrivateMapped = isPrivate(tcpAddr.IP)
}
return res
}
func isPrivate(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}