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.
108 lines
3.6 KiB
Go
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.")}
|
|
}
|