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
210
checker/rules_turn.go
Normal file
210
checker/rules_turn.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// turnOpenRelayRule flags servers that accept an unauthenticated TURN
|
||||
// Allocate (open relay, abuse vector) and warns on non-standard replies.
|
||||
type turnOpenRelayRule struct{}
|
||||
|
||||
func (r *turnOpenRelayRule) Name() string { return "stun_turn.turn_open_relay" }
|
||||
func (r *turnOpenRelayRule) Description() string {
|
||||
return "Verifies the TURN server requires authentication (challenges unauthenticated Allocate with 401)."
|
||||
}
|
||||
|
||||
func (r *turnOpenRelayRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Mode == "stun" || !hasTURNEndpoint(data) {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN endpoint to evaluate.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNNoAuth.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
switch {
|
||||
case ep.TURNNoAuth.OK:
|
||||
// Allocate accepted without credentials => open relay.
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.turn_open_relay.open",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: "TURN allocation accepted without authentication",
|
||||
Meta: map[string]any{"fix": "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification."},
|
||||
})
|
||||
case ep.TURNNoAuth.UnauthChallenge:
|
||||
// Expected 401 + REALM/NONCE, nothing to emit, pass below.
|
||||
default:
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.turn_open_relay.unexpected",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("unexpected response (code=%d): %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason),
|
||||
Meta: map[string]any{"fix": "Server did not behave like a standard TURN. Verify it actually implements RFC 5766."},
|
||||
})
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN Allocate probe attempted.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.turn_open_relay.ok", "Server correctly challenged unauthenticated Allocate requests.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnAuthRule evaluates the outcome of the authenticated TURN Allocate.
|
||||
type turnAuthRule struct{}
|
||||
|
||||
func (r *turnAuthRule) Name() string { return "stun_turn.turn_auth" }
|
||||
func (r *turnAuthRule) Description() string {
|
||||
return "Verifies the supplied TURN credentials (or REST shared secret) yield a successful Allocate."
|
||||
}
|
||||
|
||||
func (r *turnAuthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Mode == "stun" || !hasTURNEndpoint(data) {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN endpoint to evaluate.")}
|
||||
}
|
||||
if !data.HasCreds {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN credentials supplied; authenticated Allocate not attempted.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNAuth.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if ep.TURNAuth.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.turn_auth.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: joinMsg(ep.TURNAuth.Error, fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode)),
|
||||
Meta: map[string]any{"fix": allocateFix(ep.TURNAuth.ErrorCode)},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "Authenticated Allocate not attempted on any endpoint.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.turn_auth.ok", "Authenticated TURN Allocate succeeded on every endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnRelayPublicRule flags private relay addresses (missing relay-ip).
|
||||
type turnRelayPublicRule struct{}
|
||||
|
||||
func (r *turnRelayPublicRule) Name() string { return "stun_turn.relay_public" }
|
||||
func (r *turnRelayPublicRule) Description() string {
|
||||
return "Flags TURN servers whose allocated relay address is private/loopback (missing public relay-ip)."
|
||||
}
|
||||
|
||||
func (r *turnRelayPublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNAuth.OK {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if !ep.TURNAuth.IsPrivateRelay {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.relay_public.private",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("relay address is private: %s", ep.TURNAuth.RelayAddr),
|
||||
Meta: map[string]any{"fix": "Set `relay-ip=<public>` (coturn). The relay range must be publicly reachable for clients to use TURN."},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.relay_public.skipped", "No successful TURN allocation to evaluate.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.relay_public.ok", "Every relay address is public.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnRelayEchoRule reports relay-path breakage.
|
||||
type turnRelayEchoRule struct{}
|
||||
|
||||
func (r *turnRelayEchoRule) Name() string { return "stun_turn.relay_echo" }
|
||||
func (r *turnRelayEchoRule) Description() string {
|
||||
return "Verifies the TURN relay path can carry traffic to the configured probe peer (CreatePermission + Send)."
|
||||
}
|
||||
|
||||
func (r *turnRelayEchoRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.RelayEcho.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if ep.RelayEcho.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.relay_echo.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: ep.RelayEcho.Error,
|
||||
Meta: map[string]any{"fix": "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn)."},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.relay_echo.skipped", "No relay allocation available to exercise.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.relay_echo.ok", "Relay echo succeeded on every tested endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// allocateFix mirrors the coturn/RFC 5766 guidance the old Collect emitted.
|
||||
func allocateFix(code int) string {
|
||||
switch code {
|
||||
case 401:
|
||||
return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive."
|
||||
case 403:
|
||||
return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect."
|
||||
case 437:
|
||||
return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server."
|
||||
case 441:
|
||||
return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`."
|
||||
case 442:
|
||||
return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side."
|
||||
case 486:
|
||||
return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`."
|
||||
case 508:
|
||||
return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range."
|
||||
}
|
||||
return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport."
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue