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

83 lines
2.2 KiB
Go

package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rules returns the list of CheckRules exposed by the STUN/TURN checker.
// Each concern is its own rule (SRV for STUN, SRV for TURN, STUN
// binding, TURN open-relay probe, TURN authenticated allocation, relay
// echo, TLS transport, IPv6 coverage, …) so the UI can show a granular
// status instead of a single aggregated one.
func Rules() []sdk.CheckRule {
return []sdk.CheckRule{
&discoveryRule{},
&srvStunRule{},
&srvTurnRule{},
&dialRule{},
&stunBindingRule{},
&stunReflexivePublicRule{},
&stunLatencyRule{},
&turnOpenRelayRule{},
&turnAuthRule{},
&turnRelayPublicRule{},
&turnRelayEchoRule{},
&turnTLSTransportRule{},
&ipv6CoverageRule{},
}
}
// loadData fetches the observation; on error returns a CheckState that
// callers should emit directly.
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*StunTurnData, *sdk.CheckState) {
var data StunTurnData
if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil {
return nil, &sdk.CheckState{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err),
Code: "stun_turn.observation_error",
}
}
return &data, nil
}
func passState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusOK, Message: msg, Code: code}
}
func skippedState(code, msg string) sdk.CheckState {
return sdk.CheckState{Status: sdk.StatusUnknown, Message: msg, Code: code}
}
func epSubject(ep Endpoint) string {
if ep.URI != "" {
return ep.URI
}
return fmt.Sprintf("%s:%d/%s", ep.Host, ep.Port, ep.Transport)
}
// hasTURNEndpoint reports whether the observation contains at least one
// TURN endpoint (excluding STUN-only endpoints).
func hasTURNEndpoint(data *StunTurnData) bool {
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
return true
}
}
return false
}
// joinMsg concatenates non-empty parts with ": " between them.
func joinMsg(parts ...string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) != "" {
out = append(out, p)
}
}
return strings.Join(out, ": ")
}