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.
This commit is contained in:
commit
6ad7d3f593
29 changed files with 2794 additions and 0 deletions
235
checker/collect.go
Normal file
235
checker/collect.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type probeConfig struct {
|
||||
mode string
|
||||
username string
|
||||
password string
|
||||
sharedSecret string
|
||||
realm string
|
||||
probePeer string
|
||||
testChannelBind bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Collect gathers raw STUN/TURN observations (SRV discovery, dial
|
||||
// outcome, STUN Binding result, TURN Allocate results, relay echo).
|
||||
// It performs NO judgement: severity, pass/fail, warning thresholds
|
||||
// and fix suggestions are left to the CheckRule layer.
|
||||
func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
zone, _ := opts["zone"].(string)
|
||||
uri, _ := opts["serverURI"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "auto"
|
||||
}
|
||||
username, _ := opts["username"].(string)
|
||||
password, _ := opts["credential"].(string)
|
||||
sharedSecret, _ := opts["sharedSecret"].(string)
|
||||
realm, _ := opts["realm"].(string)
|
||||
transportsRaw, _ := opts["transports"].(string)
|
||||
probePeer, _ := opts["probePeer"].(string)
|
||||
if probePeer == "" {
|
||||
probePeer = "1.1.1.1:53"
|
||||
}
|
||||
// Refuse to relay traffic toward private/loopback/link-local
|
||||
// destinations: a malicious caller could otherwise abuse the target
|
||||
// TURN server to port-scan the operator's internal network through us.
|
||||
if isPrivateAddr(probePeer) {
|
||||
return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer)
|
||||
}
|
||||
timeoutSec := sdk.GetIntOption(opts, "timeout", 5)
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 5
|
||||
}
|
||||
|
||||
cfg := probeConfig{
|
||||
mode: mode,
|
||||
username: username,
|
||||
password: password,
|
||||
sharedSecret: sharedSecret,
|
||||
realm: realm,
|
||||
probePeer: probePeer,
|
||||
testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false),
|
||||
timeout: time.Duration(timeoutSec) * time.Second,
|
||||
}
|
||||
|
||||
transports := parseTransports(transportsRaw)
|
||||
|
||||
warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200))
|
||||
critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000))
|
||||
|
||||
data := &StunTurnData{
|
||||
Zone: zone,
|
||||
Mode: mode,
|
||||
RequestedURI: uri,
|
||||
ProbePeer: probePeer,
|
||||
WarningRTTMs: warnRTT,
|
||||
CriticalRTT: critRTT,
|
||||
HasCreds: sharedSecret != "" || (username != "" && password != ""),
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
|
||||
if err != nil {
|
||||
data.GlobalError = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
probe := EndpointProbe{Endpoint: ep}
|
||||
collectEndpoint(ctx, &probe, cfg)
|
||||
data.Endpoints = append(data.Endpoints, probe)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// collectEndpoint runs every network probe we know how to run against
|
||||
// a single endpoint and records raw results on probe. It never assigns
|
||||
// severity.
|
||||
func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) {
|
||||
ep := probe.Endpoint
|
||||
|
||||
// Best-effort DNS lookup for IPv6 coverage rule.
|
||||
if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil {
|
||||
for _, ip := range ips {
|
||||
probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
dialStart := time.Now()
|
||||
dc, err := dial(ctx, ep, cfg.timeout)
|
||||
dialDur := time.Since(dialStart)
|
||||
if err != nil {
|
||||
probe.Dial = DialResult{
|
||||
OK: false,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
Error: err.Error(),
|
||||
}
|
||||
return
|
||||
}
|
||||
defer dc.Close()
|
||||
probe.Dial = DialResult{
|
||||
OK: true,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
RemoteAddr: dc.remoteAddr.String(),
|
||||
}
|
||||
if dc.tlsState != nil {
|
||||
probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version)
|
||||
probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite)
|
||||
probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState)
|
||||
}
|
||||
if dc.dtlsState != nil {
|
||||
probe.Dial.DTLSHandshake = true
|
||||
}
|
||||
|
||||
// STUN Binding probe, always attempted.
|
||||
bind := runSTUNBinding(dc, cfg.timeout)
|
||||
probe.STUNBinding.Attempted = true
|
||||
if bind.Err != nil {
|
||||
probe.STUNBinding.OK = false
|
||||
probe.STUNBinding.Error = bind.Err.Error()
|
||||
} else {
|
||||
probe.STUNBinding.OK = true
|
||||
probe.STUNBinding.RTTMs = bind.RTT.Milliseconds()
|
||||
if bind.ReflexiveAddr != nil {
|
||||
probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String()
|
||||
}
|
||||
probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped
|
||||
}
|
||||
|
||||
// TURN-only probes short-circuit when mode=stun or the scheme is
|
||||
// stun:/stuns:.
|
||||
if cfg.mode == "stun" || !ep.IsTURN {
|
||||
return
|
||||
}
|
||||
|
||||
// Unauthenticated TURN Allocate (open-relay probe).
|
||||
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
|
||||
probe.TURNNoAuth.Attempted = true
|
||||
probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds()
|
||||
probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode
|
||||
probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason
|
||||
probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge
|
||||
if noAuth.RelayConn != nil {
|
||||
probe.TURNNoAuth.OK = true
|
||||
if noAuth.RelayAddr != nil {
|
||||
probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String()
|
||||
}
|
||||
_ = noAuth.RelayConn.Close()
|
||||
if noAuth.Client != nil {
|
||||
noAuth.Client.Close()
|
||||
}
|
||||
} else if noAuth.Err != nil && !noAuth.UnauthChallenge {
|
||||
probe.TURNNoAuth.Error = noAuth.Err.Error()
|
||||
}
|
||||
|
||||
// Authenticated TURN Allocate, if credentials are provided.
|
||||
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
|
||||
if creds == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dc2, err := dial(ctx, ep, cfg.timeout)
|
||||
if err != nil {
|
||||
probe.TURNAuth.Attempted = true
|
||||
probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer dc2.Close()
|
||||
|
||||
auth := runTURNAllocate(dc2, creds, cfg.timeout)
|
||||
probe.TURNAuth.Attempted = true
|
||||
probe.TURNAuth.DurationMs = auth.Duration.Milliseconds()
|
||||
probe.TURNAuth.ErrorCode = auth.AuthErrorCode
|
||||
probe.TURNAuth.ErrorReason = auth.AuthErrorReason
|
||||
if auth.Err != nil {
|
||||
probe.TURNAuth.OK = false
|
||||
probe.TURNAuth.Error = auth.Err.Error()
|
||||
return
|
||||
}
|
||||
probe.TURNAuth.OK = true
|
||||
if auth.RelayAddr != nil {
|
||||
probe.TURNAuth.RelayAddr = auth.RelayAddr.String()
|
||||
}
|
||||
probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay
|
||||
defer func() {
|
||||
auth.RelayConn.Close()
|
||||
if auth.Client != nil {
|
||||
auth.Client.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Relay echo.
|
||||
probe.RelayEcho.Attempted = true
|
||||
probe.RelayEcho.PeerAddr = cfg.probePeer
|
||||
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
|
||||
probe.RelayEcho.OK = false
|
||||
probe.RelayEcho.Error = err.Error()
|
||||
} else {
|
||||
probe.RelayEcho.OK = true
|
||||
}
|
||||
|
||||
if cfg.testChannelBind {
|
||||
probe.ChannelBindRun = true
|
||||
}
|
||||
}
|
||||
|
||||
func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials {
|
||||
if sharedSecret != "" {
|
||||
return restAPICredentials(sharedSecret, username, realm, time.Hour)
|
||||
}
|
||||
if username != "" && password != "" {
|
||||
return &turnCredentials{Username: username, Password: password, Realm: realm}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue