checker-stun-turn/checker/rules_transport.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

108 lines
3.6 KiB
Go

package checker
import (
"context"
"net"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// turnTLSTransportRule evaluates whether a TLS-capable transport is
// available and reports its version/cipher metadata. Kept separate from
// the generic dial rule because TURN-TLS deployments often need dedicated
// attention (port 5349, certificate covering the TURN hostname, …).
type turnTLSTransportRule struct{}
func (r *turnTLSTransportRule) Name() string { return "stun_turn.tls_transport" }
func (r *turnTLSTransportRule) Description() string {
return "Verifies that at least one TLS/DTLS transport (stuns/turns) succeeds when present in the endpoint set."
}
func (r *turnTLSTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
var (
secureCount, secureOK int
states []sdk.CheckState
)
for _, ep := range data.Endpoints {
if !ep.Endpoint.Secure {
continue
}
secureCount++
if ep.Dial.OK {
secureOK++
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.tls_transport.handshake_failed",
Subject: epSubject(ep.Endpoint),
Message: ep.Dial.Error,
Meta: map[string]any{
"fix": "TLS/DTLS handshake failed. Reissue a certificate covering the TURN hostname and reload the server (coturn: `cert=`/`pkey=`).",
},
})
}
if secureCount == 0 {
return []sdk.CheckState{skippedState("stun_turn.tls_transport.skipped", "No secure (stuns/turns) endpoint discovered.")}
}
if secureOK == 0 {
// All secure endpoints failed, already emitted per-endpoint
// crit states; return them as-is.
return states
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.tls_transport.ok", "TLS/DTLS handshake succeeded on every secure endpoint.")}
}
return states
}
// ipv6CoverageRule verifies at least one endpoint resolves to an IPv6
// address. It never marks an endpoint failed: missing AAAA records are a
// coverage concern, not an error.
type ipv6CoverageRule struct{}
func (r *ipv6CoverageRule) Name() string { return "stun_turn.ipv6_coverage" }
func (r *ipv6CoverageRule) Description() string {
return "Verifies at least one STUN/TURN hostname resolves to an IPv6 address."
}
func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "No endpoint discovered.")}
}
var anyResolved, anyV6 bool
for _, ep := range data.Endpoints {
for _, ipStr := range ep.ResolvedIPs {
anyResolved = true
ip := net.ParseIP(ipStr)
if ip != nil && ip.To4() == nil && strings.Contains(ipStr, ":") {
anyV6 = true
break
}
}
if anyV6 {
break
}
}
if !anyResolved {
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "Hostname resolution data unavailable.")}
}
if !anyV6 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "stun_turn.ipv6_coverage.missing",
Message: "No STUN/TURN endpoint resolves to an IPv6 address; IPv6-only clients will have no reachable server.",
Meta: map[string]any{"fix": "Publish AAAA records for your STUN/TURN hostnames and ensure the server listens on IPv6."},
}}
}
return []sdk.CheckState{passState("stun_turn.ipv6_coverage.ok", "At least one endpoint resolves to an IPv6 address.")}
}