checker-stun-turn/checker/rule.go
Pierre-Olivier Mercier 7ff9f92305 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.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 19:35:43 +07:00

87 lines
2.1 KiB
Go

package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Rule returns the single aggregated STUN/TURN check rule.
func Rule() sdk.CheckRule {
return &stunTurnRule{}
}
type stunTurnRule struct{}
func (r *stunTurnRule) Name() string { return "stun_turn" }
func (r *stunTurnRule) Description() string {
return "Validates STUN binding and TURN allocation against the configured server(s)."
}
func (r *stunTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
var data StunTurnData
if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err),
Code: "stun_turn_obs_error",
}}
}
if data.GlobalError != "" {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: data.GlobalError,
Code: "stun_turn_discovery_error",
}}
}
if len(data.Endpoints) == 0 {
return []sdk.CheckState{{
Status: sdk.StatusError,
Message: "no endpoints to probe",
Code: "stun_turn_no_endpoints",
}}
}
worst := SubTestOK
var firstFailLine string
for _, ep := range data.Endpoints {
w := ep.Worst()
if statusRank(w) > statusRank(worst) {
worst = w
}
if firstFailLine == "" {
if f := ep.FirstFailure(); f != nil {
parts := []string{
fmt.Sprintf("[%s] %s", ep.Endpoint.URI, f.Name),
}
if f.Detail != "" {
parts = append(parts, f.Detail)
}
if f.Error != "" {
parts = append(parts, f.Error)
}
firstFailLine = strings.Join(parts, ": ")
if f.Fix != "" {
firstFailLine += ". Fix: " + f.Fix
}
}
}
}
state := sdk.CheckState{
Status: toSDKStatus(worst),
Code: fmt.Sprintf("stun_turn_%s", worst),
Meta: map[string]any{
"endpoint_count": len(data.Endpoints),
},
}
if firstFailLine != "" {
state.Message = firstFailLine
} else {
state.Message = fmt.Sprintf("All %d endpoint(s) healthy", len(data.Endpoints))
}
return []sdk.CheckState{state}
}