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>
This commit is contained in:
nemunaire 2026-04-19 13:41:52 +07:00
commit 5826bb1f40
23 changed files with 1906 additions and 0 deletions

331
checker/collect.go Normal file
View file

@ -0,0 +1,331 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type probeConfig struct {
mode string
username string
password string
sharedSecret string
realm string
probePeer string
testChannelBind bool
timeout time.Duration
warningRTT time.Duration
criticalRTT time.Duration
}
func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
zone, _ := opts["zone"].(string)
uri, _ := opts["serverURI"].(string)
mode, _ := opts["mode"].(string)
if mode == "" {
mode = "auto"
}
username, _ := opts["username"].(string)
password, _ := opts["credential"].(string)
sharedSecret, _ := opts["sharedSecret"].(string)
realm, _ := opts["realm"].(string)
transportsRaw, _ := opts["transports"].(string)
probePeer, _ := opts["probePeer"].(string)
if probePeer == "" {
probePeer = "1.1.1.1:53"
}
timeoutSec := sdk.GetIntOption(opts, "timeout", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
cfg := probeConfig{
mode: mode,
username: username,
password: password,
sharedSecret: sharedSecret,
realm: realm,
probePeer: probePeer,
testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false),
timeout: time.Duration(timeoutSec) * time.Second,
warningRTT: time.Duration(sdk.GetIntOption(opts, "warningRTT", 200)) * time.Millisecond,
criticalRTT: time.Duration(sdk.GetIntOption(opts, "criticalRTT", 1000)) * time.Millisecond,
}
transports := parseTransports(transportsRaw)
collectedAt := time.Now().UTC()
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
if err != nil {
return &StunTurnData{
Zone: zone,
Mode: mode,
CollectedAt: collectedAt,
GlobalError: err.Error(),
}, nil
}
data := &StunTurnData{
Zone: zone,
Mode: mode,
CollectedAt: collectedAt,
}
for _, ep := range endpoints {
report := EndpointReport{Endpoint: ep}
probeEndpoint(ctx, &report, cfg)
data.Endpoints = append(data.Endpoints, report)
}
return data, nil
}
func probeEndpoint(ctx context.Context, r *EndpointReport, cfg probeConfig) {
ep := r.Endpoint
dialName := fmt.Sprintf("dial:%s", ep.Transport)
dialStart := time.Now()
dc, err := dial(ctx, ep, cfg.timeout)
dialDur := time.Since(dialStart)
if err != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: dialName,
Status: SubTestCrit,
DurationMs: dialDur.Milliseconds(),
Error: err.Error(),
Fix: dialFix(ep, err),
})
return
}
defer dc.Close()
r.SubTests = append(r.SubTests, SubTest{
Name: dialName,
Status: SubTestOK,
DurationMs: dialDur.Milliseconds(),
Detail: fmt.Sprintf("connected to %s", dc.remoteAddr),
})
if dc.tlsState != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "tls",
Status: SubTestOK,
Detail: fmt.Sprintf("%s, %s, peer cert CN=%s",
tlsVersionString(dc.tlsState.Version),
tls.CipherSuiteName(dc.tlsState.CipherSuite),
peerCertCN(dc.tlsState),
),
})
}
if dc.dtlsState != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "dtls",
Status: SubTestOK,
Detail: "DTLS handshake completed",
})
}
bind := runSTUNBinding(dc, cfg.timeout)
if bind.Err != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "stun_binding",
Status: SubTestCrit,
Error: bind.Err.Error(),
Fix: "Server did not answer the STUN Binding Request. Check that the STUN service is actually listening on this transport, and that no middlebox is filtering RFC 5389 traffic.",
})
return
}
rttStatus := SubTestOK
rttFix := ""
if bind.RTT > cfg.criticalRTT {
rttStatus = SubTestCrit
rttFix = "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."
} else if bind.RTT > cfg.warningRTT {
rttStatus = SubTestWarn
rttFix = "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."
}
r.SubTests = append(r.SubTests, SubTest{
Name: "stun_binding",
Status: rttStatus,
DurationMs: bind.RTT.Milliseconds(),
Detail: fmt.Sprintf("reflexive address: %s", bind.ReflexiveAddr),
Fix: rttFix,
})
if bind.IsPrivateMapped {
r.SubTests = append(r.SubTests, SubTest{
Name: "stun_reflexive_public",
Status: SubTestCrit,
Detail: fmt.Sprintf("server returned a private/loopback IP: %s", bind.ReflexiveAddr),
Fix: "Server appears to be behind NAT and unaware of its public IP. Set `external-ip=<public>` (coturn) or the equivalent on your TURN server.",
})
} else {
r.SubTests = append(r.SubTests, SubTest{
Name: "stun_reflexive_public",
Status: SubTestOK,
Detail: fmt.Sprintf("public reflexive: %s", bind.ReflexiveAddr),
})
}
// Mode short-circuits: STUN-only servers stop here.
if cfg.mode == "stun" || !ep.IsTURN {
return
}
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
if noAuth.RelayConn != nil {
_ = noAuth.RelayConn.Close()
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_open_relay_check",
Status: SubTestCrit,
Detail: "TURN allocation accepted without authentication",
Fix: "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification.",
})
} else if noAuth.UnauthChallenge {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_open_relay_check",
Status: SubTestOK,
Detail: "server correctly challenged the unauthenticated allocate (401)",
})
} else {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_open_relay_check",
Status: SubTestWarn,
Detail: fmt.Sprintf("unexpected response (code=%d): %s", noAuth.AuthErrorCode, noAuth.AuthErrorReason),
Fix: "Server did not behave like a standard TURN. Verify it actually implements RFC 5766.",
})
}
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
if creds == nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_allocate_auth",
Status: SubTestSkipped,
Detail: "no credentials provided",
})
return
}
// We need a fresh dialed conn; pion/turn binds the client to one PacketConn lifetime.
dc2, err := dial(ctx, ep, cfg.timeout)
if err != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_allocate_auth",
Status: SubTestError,
Error: fmt.Sprintf("redial failed: %v", err),
})
return
}
defer dc2.Close()
auth := runTURNAllocate(dc2, creds, cfg.timeout)
if auth.Err != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_allocate_auth",
Status: SubTestCrit,
DurationMs: auth.Duration.Milliseconds(),
Error: auth.Err.Error(),
Detail: fmt.Sprintf("STUN error code: %d", auth.AuthErrorCode),
Fix: allocateFix(auth.AuthErrorCode),
})
return
}
defer auth.RelayConn.Close()
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_allocate_auth",
Status: SubTestOK,
DurationMs: auth.Duration.Milliseconds(),
Detail: fmt.Sprintf("relay address: %s", auth.RelayAddr),
})
if auth.IsPrivateRelay {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_relay_public",
Status: SubTestCrit,
Detail: fmt.Sprintf("relay address is private: %s", auth.RelayAddr),
Fix: "Set `relay-ip=<public>` (coturn). The relay range must be publicly reachable for clients to use TURN.",
})
} else {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_relay_public",
Status: SubTestOK,
Detail: fmt.Sprintf("relay is public: %s", auth.RelayAddr),
})
}
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_relay_echo",
Status: SubTestWarn,
Error: err.Error(),
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).",
})
} else {
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_relay_echo",
Status: SubTestOK,
Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", cfg.probePeer),
})
}
if cfg.testChannelBind {
// pion/turn handles ChannelBind transparently when the relay PacketConn
// is used through a turn.Client; we just record that the option was on.
r.SubTests = append(r.SubTests, SubTest{
Name: "turn_channel_bind",
Status: SubTestInfo,
Detail: "ChannelBind exercised implicitly by relay traffic",
})
}
}
func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials {
if sharedSecret != "" {
return restAPICredentials(sharedSecret, username, realm, time.Hour)
}
if username != "" && password != "" {
return &turnCredentials{Username: username, Password: password, Realm: realm}
}
return nil
}
func dialFix(ep Endpoint, err error) string {
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "no such host"):
return fmt.Sprintf("Hostname `%s` does not resolve. Add the matching A/AAAA record (or fix typos in the URI).", ep.Host)
case strings.Contains(msg, "tls handshake"), strings.Contains(msg, "x509"):
return fmt.Sprintf("TLS handshake failed for `%s`. Reissue the certificate covering this hostname (e.g. via Let's Encrypt) and reload the server (coturn: `cert=` and `pkey=`).", ep.Host)
case strings.Contains(msg, "connection refused"):
return fmt.Sprintf("Nothing is listening on %s/%d. Start the server with the appropriate listening port (coturn: `listening-port=`/`tls-listening-port=`).", ep.Host, ep.Port)
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline"):
switch ep.Transport {
case TransportUDP:
return "No reply on UDP. Open the UDP port inbound and verify your network does not block UDP egress."
default:
return "Connection timed out. A firewall or NAT is likely blocking this port."
}
}
return "Could not establish a connection to the server."
}
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."
}

94
checker/definition.go Normal file
View file

@ -0,0 +1,94 @@
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Version is the checker version reported in CheckerDefinition.Version.
// Defaults to "built-in"; standalone binaries override it from main().
var Version = "built-in"
// Definition returns the CheckerDefinition for the STUN/TURN checker.
func Definition() *sdk.CheckerDefinition {
return &sdk.CheckerDefinition{
ID: "stunturn",
Name: "STUN/TURN Server",
Version: Version,
HasHTMLReport: true,
ObservationKeys: []sdk.ObservationKey{ObservationKeyStunTurn},
Availability: sdk.CheckerAvailability{
ApplyToZone: true,
ApplyToService: true,
},
Options: sdk.CheckerOptionsDocumentation{
RunOpts: []sdk.CheckerOptionDocumentation{
{
Id: "zone",
Type: "string",
Label: "Zone",
Placeholder: "example.com",
AutoFill: sdk.AutoFillDomainName,
Description: "Zone used for SRV-based STUN/TURN endpoint discovery when no explicit URI is provided.",
},
{
Id: "serverURI",
Type: "string",
Label: "Server URI",
Placeholder: "turns:turn.example.com:5349?transport=tcp",
Description: "Explicit STUN/TURN URI (RFC 7064/7065). Overrides SRV-based discovery.",
},
{
Id: "mode",
Type: "string",
Label: "Mode",
Default: "auto",
Choices: []string{"auto", "stun", "turn"},
Description: "auto: probe both STUN and TURN; stun: skip TURN allocation tests; turn: require TURN allocation.",
},
},
UserOpts: []sdk.CheckerOptionDocumentation{
{Id: "username", Type: "string", Label: "TURN username"},
{Id: "credential", Type: "string", Label: "TURN password", Secret: true},
{
Id: "sharedSecret",
Type: "string",
Label: "REST API shared secret",
Secret: true,
Description: "Shared secret used to derive ephemeral credentials (draft-uberti-rtcweb-turn-rest). Takes precedence over username/password.",
},
{Id: "realm", Type: "string", Label: "Realm"},
{
Id: "transports",
Type: "string",
Label: "Transports",
Default: "udp,tcp,tls",
Description: "Comma-separated list of transports to test among: udp, tcp, tls, dtls.",
},
{
Id: "probePeer",
Type: "string",
Label: "Relay echo target",
Default: "1.1.1.1:53",
Description: "host:port used to validate the relay path (a CreatePermission + Send is issued, no payload data is exchanged).",
},
{
Id: "testChannelBind",
Type: "bool",
Label: "Also test ChannelBind",
Default: false,
},
{Id: "warningRTT", Type: "uint", Label: "RTT warning threshold (ms)", Default: 200},
{Id: "criticalRTT", Type: "uint", Label: "RTT critical threshold (ms)", Default: 1000},
{Id: "timeout", Type: "uint", Label: "Per-probe timeout (s)", Default: 5},
},
},
Rules: []sdk.CheckRule{Rule()},
Interval: &sdk.CheckIntervalSpec{
Min: 5 * time.Minute,
Default: 30 * time.Minute,
Max: 24 * time.Hour,
},
}
}

225
checker/discover.go Normal file
View file

@ -0,0 +1,225 @@
package checker
import (
"context"
"fmt"
"net"
"net/url"
"strconv"
"strings"
)
// parseURI parses a STUN/TURN URI per RFC 7064 / RFC 7065.
//
// Examples:
//
// stun:turn.example.com
// stun:turn.example.com:3478
// stuns:turn.example.com:5349
// turn:turn.example.com:3478?transport=udp
// turns:turn.example.com:5349?transport=tcp
func parseURI(raw string) (Endpoint, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return Endpoint{}, fmt.Errorf("empty URI")
}
colon := strings.IndexByte(raw, ':')
if colon < 0 {
return Endpoint{}, fmt.Errorf("missing scheme in %q", raw)
}
scheme := strings.ToLower(raw[:colon])
rest := raw[colon+1:]
var ep Endpoint
ep.URI = raw
ep.Source = "uri"
switch scheme {
case "stun":
ep.IsTURN = false
ep.Secure = false
case "stuns":
ep.IsTURN = false
ep.Secure = true
case "turn":
ep.IsTURN = true
ep.Secure = false
case "turns":
ep.IsTURN = true
ep.Secure = true
default:
return Endpoint{}, fmt.Errorf("unknown scheme %q", scheme)
}
hostport := rest
query := ""
if q := strings.IndexByte(rest, '?'); q >= 0 {
hostport = rest[:q]
query = rest[q+1:]
}
host, portStr, err := net.SplitHostPort(hostport)
if err != nil {
// no port; pick the default per scheme
host = hostport
portStr = ""
}
if host == "" {
return Endpoint{}, fmt.Errorf("missing host in %q", raw)
}
ep.Host = host
// Default transport: UDP for stun/turn, TCP for stuns/turns. Overridable via ?transport=
if ep.Secure {
ep.Transport = TransportTLS
} else {
ep.Transport = TransportUDP
}
if query != "" {
values, err := url.ParseQuery(query)
if err == nil {
if t := strings.ToLower(values.Get("transport")); t != "" {
switch t {
case "udp":
ep.Transport = TransportUDP
case "tcp":
if ep.Secure {
ep.Transport = TransportTLS
} else {
ep.Transport = TransportTCP
}
case "tls":
ep.Transport = TransportTLS
ep.Secure = true
case "dtls":
ep.Transport = TransportDTLS
ep.Secure = true
}
}
}
}
if portStr == "" {
if ep.Secure {
ep.Port = 5349
} else {
ep.Port = 3478
}
} else {
p, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return Endpoint{}, fmt.Errorf("invalid port %q: %w", portStr, err)
}
ep.Port = uint16(p)
}
return ep, nil
}
// discoverEndpoints returns the list of endpoints to probe.
//
// If serverURI is set, it is the only endpoint. Otherwise SRV records are
// looked up for the zone. Returned endpoints are filtered to the requested
// transports.
func discoverEndpoints(ctx context.Context, zone, serverURI string, transports []Transport) ([]Endpoint, error) {
if serverURI != "" {
ep, err := parseURI(serverURI)
if err != nil {
return nil, err
}
return filterByTransport([]Endpoint{ep}, transports), nil
}
zone = strings.TrimSuffix(strings.TrimSpace(zone), ".")
if zone == "" {
return nil, fmt.Errorf("either serverURI or zone is required")
}
resolver := net.DefaultResolver
type srvSpec struct {
service string // _stun, _turn, _stuns, _turns
proto string // _udp / _tcp
isTURN bool
secure bool
transport Transport
}
specs := []srvSpec{
{"_stun", "_udp", false, false, TransportUDP},
{"_stun", "_tcp", false, false, TransportTCP},
{"_stuns", "_tcp", false, true, TransportTLS},
{"_turn", "_udp", true, false, TransportUDP},
{"_turn", "_tcp", true, false, TransportTCP},
{"_turns", "_tcp", true, true, TransportTLS},
}
var endpoints []Endpoint
for _, s := range specs {
_, addrs, err := resolver.LookupSRV(ctx, strings.TrimPrefix(s.service, "_"), strings.TrimPrefix(s.proto, "_"), zone)
if err != nil || len(addrs) == 0 {
continue
}
for _, srv := range addrs {
ep := Endpoint{
Host: strings.TrimSuffix(srv.Target, "."),
Port: srv.Port,
Transport: s.transport,
Secure: s.secure,
IsTURN: s.isTURN,
Source: fmt.Sprintf("srv:%s.%s.%s", s.service, s.proto, zone),
}
scheme := "stun"
if s.isTURN {
scheme = "turn"
}
if s.secure {
scheme += "s"
}
ep.URI = fmt.Sprintf("%s:%s:%d", scheme, ep.Host, ep.Port)
endpoints = append(endpoints, ep)
}
}
if len(endpoints) == 0 {
return nil, fmt.Errorf("no STUN/TURN SRV records found under %s", zone)
}
return filterByTransport(endpoints, transports), nil
}
func filterByTransport(eps []Endpoint, allowed []Transport) []Endpoint {
if len(allowed) == 0 {
return eps
}
allow := make(map[Transport]bool, len(allowed))
for _, t := range allowed {
allow[t] = true
}
out := make([]Endpoint, 0, len(eps))
for _, ep := range eps {
if allow[ep.Transport] {
out = append(out, ep)
}
}
return out
}
func parseTransports(raw string) []Transport {
if raw == "" {
return []Transport{TransportUDP, TransportTCP, TransportTLS}
}
var out []Transport
for _, p := range strings.Split(raw, ",") {
p = strings.TrimSpace(strings.ToLower(p))
switch p {
case "udp":
out = append(out, TransportUDP)
case "tcp":
out = append(out, TransportTCP)
case "tls":
out = append(out, TransportTLS)
case "dtls":
out = append(out, TransportDTLS)
}
}
return out
}

66
checker/discover_test.go Normal file
View file

@ -0,0 +1,66 @@
package checker
import "testing"
func TestParseURI(t *testing.T) {
cases := []struct {
in string
host string
port uint16
transport Transport
secure bool
isTURN bool
wantErr bool
}{
{"stun:turn.example.com", "turn.example.com", 3478, TransportUDP, false, false, false},
{"stun:turn.example.com:3478", "turn.example.com", 3478, TransportUDP, false, false, false},
{"stuns:turn.example.com:5349", "turn.example.com", 5349, TransportTLS, true, false, false},
{"turn:turn.example.com:3478?transport=udp", "turn.example.com", 3478, TransportUDP, false, true, false},
{"turn:turn.example.com:3478?transport=tcp", "turn.example.com", 3478, TransportTCP, false, true, false},
{"turns:turn.example.com:5349?transport=tcp", "turn.example.com", 5349, TransportTLS, true, true, false},
{"turns:turn.example.com?transport=dtls", "turn.example.com", 5349, TransportDTLS, true, true, false},
{"http://example.com", "", 0, "", false, false, true},
{"stun:", "", 0, "", false, false, true},
}
for _, tc := range cases {
ep, err := parseURI(tc.in)
if tc.wantErr {
if err == nil {
t.Errorf("%q: expected error, got nil", tc.in)
}
continue
}
if err != nil {
t.Errorf("%q: unexpected error: %v", tc.in, err)
continue
}
if ep.Host != tc.host || ep.Port != tc.port || ep.Transport != tc.transport ||
ep.Secure != tc.secure || ep.IsTURN != tc.isTURN {
t.Errorf("%q: got %+v, want host=%s port=%d transport=%s secure=%v isTURN=%v",
tc.in, ep, tc.host, tc.port, tc.transport, tc.secure, tc.isTURN)
}
}
}
func TestParseTransports(t *testing.T) {
got := parseTransports("udp, TLS ,dtls")
want := []Transport{TransportUDP, TransportTLS, TransportDTLS}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("index %d: got %s want %s", i, got[i], want[i])
}
}
}
func TestRestAPICredentials(t *testing.T) {
c := restAPICredentials("topsecret", "alice", "example.com", 0)
if c.Username == "" || c.Password == "" {
t.Fatalf("empty creds: %+v", c)
}
if c.Realm != "example.com" {
t.Fatalf("realm mismatch: %s", c.Realm)
}
}

48
checker/discovery.go Normal file
View file

@ -0,0 +1,48 @@
package checker
import (
"fmt"
sdk "git.happydns.org/checker-sdk-go/checker"
tlsct "git.happydns.org/checker-tls/contract"
)
// DiscoverEntries implements sdk.DiscoveryPublisher.
//
// stuns:/turns: (RFC 7064/7065) speak TLS immediately after the TCP
// handshake, so every secure TCP-based endpoint we observed is published
// under the tls.endpoint.v1 contract for checker-tls to pick up.
//
// DTLS is intentionally omitted: the current checker-tls consumer uses
// crypto/tls and would not probe a datagram-TLS endpoint correctly. Emitting
// a DTLS entry today would only produce orphan lineage.
//
// SNI is left empty (= Host); no STARTTLS upgrade applies; the scheme
// mandates direct TLS on the wire.
func (p *stunTurnProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
d, ok := data.(*StunTurnData)
if !ok {
return nil, fmt.Errorf("unexpected data type %T", data)
}
seen := make(map[string]struct{})
var out []sdk.DiscoveryEntry
for _, ep := range d.Endpoints {
if !ep.Endpoint.Secure || ep.Endpoint.Transport == TransportDTLS {
continue
}
key := fmt.Sprintf("%s|%d", ep.Endpoint.Host, ep.Endpoint.Port)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
Host: ep.Endpoint.Host,
Port: ep.Endpoint.Port,
})
if err != nil {
return nil, err
}
out = append(out, entry)
}
return out, nil
}

21
checker/provider.go Normal file
View file

@ -0,0 +1,21 @@
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
)
// Provider returns a new STUN/TURN observation provider.
func Provider() sdk.ObservationProvider {
return &stunTurnProvider{}
}
type stunTurnProvider struct{}
func (p *stunTurnProvider) Key() sdk.ObservationKey {
return ObservationKeyStunTurn
}
// Definition implements sdk.CheckerDefinitionProvider.
func (p *stunTurnProvider) Definition() *sdk.CheckerDefinition {
return Definition()
}

213
checker/report.go Normal file
View file

@ -0,0 +1,213 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type tmplSubTest struct {
Name string
StatusCSS string
StatusText string
DurationMs int64
Detail string
Error string
Fix string
}
type tmplEndpoint struct {
URI string
Transport string
Source string
Open bool
BadgeText string
BadgeCSS string
SubTests []tmplSubTest
}
type tmplData struct {
Zone string
Mode string
OverallText string
OverallCSS string
HeadlineFix string
HeadlineDetail string
GlobalError string
Endpoints []tmplEndpoint
}
var stunturnTemplate = template.Must(template.New("stunturn").Parse(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>STUN/TURN Report</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; padding: 1rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; color: #1f2937; background: #f3f4f6; line-height: 1.5; }
code { font-family: ui-monospace, monospace; font-size: .9em; }
h1 { margin: 0 0 .4rem; font-size: 1.15rem; }
h2 { margin: 0 0 .4rem; font-size: 1rem; }
.hd { background: #fff; border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
.badge { display: inline-flex; align-items: center; padding: .2em .65em; border-radius: 9999px; font-size: .78rem; font-weight: 700; letter-spacing: .02em; }
.ok { background: #d1fae5; color: #065f46; }
.info { background: #dbeafe; color: #1e3a8a; }
.warn { background: #fef3c7; color: #92400e; }
.crit { background: #fee2e2; color: #991b1b; }
.error { background: #fee2e2; color: #991b1b; }
.skipped { background: #e5e7eb; color: #374151; }
.headline-fix { background: #fef3c7; border-left: 4px solid #f59e0b; padding: .75rem 1rem; border-radius: 6px; margin-top: .6rem; }
.headline-fix strong { color: #78350f; }
.global-err { background: #fee2e2; border-left: 4px solid #dc2626; padding: .75rem 1rem; border-radius: 6px; }
details { background: #fff; border-radius: 8px; margin-bottom: .45rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden; }
summary { display: flex; align-items: center; gap: .5rem; padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none; }
summary::-webkit-details-marker { display: none; }
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
details[open] > summary::before { transform: rotate(90deg); }
.ep-uri { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .9rem; }
.body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
th { font-weight: 600; color: #6b7280; }
.fix { color: #92400e; font-size: .82rem; }
.err { color: #b91c1c; font-size: .82rem; }
.dur { color: #6b7280; font-size: .8rem; white-space: nowrap; }
</style>
</head>
<body>
<div class="hd">
<h1>STUN/TURN check</h1>
<span class="badge {{.OverallCSS}}">{{.OverallText}}</span>
<div class="meta">
{{if .Zone}}Zone: <code>{{.Zone}}</code> &middot; {{end}}
Mode: <code>{{.Mode}}</code> &middot;
{{len .Endpoints}} endpoint(s) probed
</div>
{{if .HeadlineFix}}
<div class="headline-fix">
<strong>How to fix:</strong> {{.HeadlineFix}}
{{if .HeadlineDetail}}<div class="err" style="margin-top:.3rem">{{.HeadlineDetail}}</div>{{end}}
</div>
{{end}}
</div>
{{if .GlobalError}}
<div class="hd"><div class="global-err"><strong>Discovery failed:</strong> {{.GlobalError}}</div></div>
{{end}}
{{range .Endpoints}}
<details{{if .Open}} open{{end}}>
<summary>
<span class="ep-uri">{{.URI}}</span>
<span class="badge {{.BadgeCSS}}">{{.BadgeText}}</span>
</summary>
<div class="body">
<p class="meta">Transport: <code>{{.Transport}}</code> &middot; Source: <code>{{.Source}}</code></p>
<table>
<tr><th>Test</th><th>Status</th><th>Duration</th><th>Detail</th></tr>
{{range .SubTests}}
<tr>
<td><code>{{.Name}}</code></td>
<td><span class="badge {{.StatusCSS}}">{{.StatusText}}</span></td>
<td class="dur">{{if .DurationMs}}{{.DurationMs}} ms{{end}}</td>
<td>
{{if .Detail}}{{.Detail}}{{end}}
{{if .Error}}<div class="err">&#9888; {{.Error}}</div>{{end}}
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
</div>
</details>
{{end}}
</body>
</html>`))
// GetHTMLReport implements sdk.CheckerHTMLReporter.
func (p *stunTurnProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
var d StunTurnData
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
return "", fmt.Errorf("unmarshal stun/turn report: %w", err)
}
worst := SubTestOK
var headlineFix, headlineDetail string
td := tmplData{
Zone: d.Zone,
Mode: d.Mode,
GlobalError: d.GlobalError,
}
for _, ep := range d.Endpoints {
te := tmplEndpoint{
URI: ep.Endpoint.URI,
Transport: string(ep.Endpoint.Transport),
Source: ep.Endpoint.Source,
}
w := ep.Worst()
if statusRank(w) > statusRank(worst) {
worst = w
}
te.BadgeCSS, te.BadgeText = badge(w)
te.Open = w != SubTestOK && w != SubTestSkipped && w != SubTestInfo
if te.Open && headlineFix == "" {
if f := ep.FirstFailure(); f != nil && f.Fix != "" {
headlineFix = f.Fix
if f.Detail != "" {
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Detail)
} else if f.Error != "" {
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Error)
}
}
}
for _, st := range ep.SubTests {
css, txt := badge(st.Status)
te.SubTests = append(te.SubTests, tmplSubTest{
Name: st.Name,
StatusCSS: css,
StatusText: txt,
DurationMs: st.DurationMs,
Detail: st.Detail,
Error: st.Error,
Fix: st.Fix,
})
}
td.Endpoints = append(td.Endpoints, te)
}
td.OverallCSS, td.OverallText = badge(worst)
td.HeadlineFix = headlineFix
td.HeadlineDetail = headlineDetail
var buf strings.Builder
if err := stunturnTemplate.Execute(&buf, td); err != nil {
return "", fmt.Errorf("render stun/turn report: %w", err)
}
return buf.String(), nil
}
func badge(s SubTestStatus) (cssClass, label string) {
switch s {
case SubTestOK:
return "ok", "OK"
case SubTestInfo:
return "info", "INFO"
case SubTestWarn:
return "warn", "WARN"
case SubTestCrit:
return "crit", "CRIT"
case SubTestError:
return "error", "ERROR"
case SubTestSkipped:
return "skipped", "SKIPPED"
}
return "info", string(s)
}

87
checker/rule.go Normal file
View file

@ -0,0 +1,87 @@
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}
}

61
checker/stun.go Normal file
View file

@ -0,0 +1,61 @@
package checker
import (
"fmt"
"net"
"time"
"github.com/pion/turn/v4"
)
// stunBindingResult holds the outcome of a STUN Binding test.
type stunBindingResult struct {
RTT time.Duration
ReflexiveAddr net.Addr
IsPrivateMapped bool
Err error
}
// runSTUNBinding sends a STUN Binding Request to the remote and returns the
// reflexive (XOR-MAPPED) address along with the RTT. We construct a tiny
// turn.Client with no credentials; its SendBindingRequestTo path drives a
// vanilla STUN exchange (RFC 5389) and works on UDP/TCP/TLS/DTLS through
// the dialed PacketConn we hand it.
func runSTUNBinding(d *dialedConn, timeout time.Duration) stunBindingResult {
cfg := &turn.ClientConfig{
Conn: d.pc,
STUNServerAddr: d.remoteAddr.String(),
RTO: timeout,
Software: "happyDomain-checker-stun-turn",
}
client, err := turn.NewClient(cfg)
if err != nil {
return stunBindingResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
}
defer client.Close()
if err := client.Listen(); err != nil {
return stunBindingResult{Err: fmt.Errorf("client.Listen: %w", err)}
}
start := time.Now()
addr, err := client.SendBindingRequestTo(d.remoteAddr)
if err != nil {
return stunBindingResult{Err: err}
}
res := stunBindingResult{
RTT: time.Since(start),
ReflexiveAddr: addr,
}
if udpAddr, ok := addr.(*net.UDPAddr); ok {
res.IsPrivateMapped = isPrivate(udpAddr.IP)
} else if tcpAddr, ok := addr.(*net.TCPAddr); ok {
res.IsPrivateMapped = isPrivate(tcpAddr.IP)
}
return res
}
func isPrivate(ip net.IP) bool {
if ip == nil {
return false
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
}

24
checker/tlsmeta.go Normal file
View file

@ -0,0 +1,24 @@
package checker
import "crypto/tls"
func tlsVersionString(v uint16) string {
switch v {
case tls.VersionTLS10:
return "TLS 1.0"
case tls.VersionTLS11:
return "TLS 1.1"
case tls.VersionTLS12:
return "TLS 1.2"
case tls.VersionTLS13:
return "TLS 1.3"
}
return "TLS ?"
}
func peerCertCN(s *tls.ConnectionState) string {
if s == nil || len(s.PeerCertificates) == 0 {
return ""
}
return s.PeerCertificates[0].Subject.CommonName
}

146
checker/transport.go Normal file
View file

@ -0,0 +1,146 @@
package checker
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"strconv"
"time"
"github.com/pion/dtls/v3"
"github.com/pion/turn/v4"
)
// dialedConn wraps the network conn used to talk to a STUN/TURN server,
// always exposing a PacketConn (turn/stun talk in datagrams). For
// stream transports (TCP/TLS) we wrap with turn.NewSTUNConn which frames
// STUN messages on top of the byte stream per RFC 5389 §7.2.2.
type dialedConn struct {
pc net.PacketConn
underlying net.Conn // non-nil for TCP/TLS; nil for UDP and DTLS
tlsState *tls.ConnectionState
dtlsState *dtls.State
remoteAddr net.Addr
}
func (d *dialedConn) Close() error {
var err error
if d.pc != nil {
err = d.pc.Close()
}
if d.underlying != nil {
if e := d.underlying.Close(); e != nil && err == nil {
err = e
}
}
return err
}
// dtlsPacketConn adapts *dtls.Conn (net.Conn) to net.PacketConn.
// DTLS frames messages at the record level; no additional length-prefix
// framing (as turn.NewSTUNConn adds for TCP) is needed or correct here.
type dtlsPacketConn struct {
conn *dtls.Conn
raddr net.Addr
}
func (d *dtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
n, err := d.conn.Read(b)
return n, d.raddr, err
}
func (d *dtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
return d.conn.Write(b)
}
func (d *dtlsPacketConn) Close() error { return d.conn.Close() }
func (d *dtlsPacketConn) LocalAddr() net.Addr { return d.conn.LocalAddr() }
func (d *dtlsPacketConn) SetDeadline(t time.Time) error { return d.conn.SetDeadline(t) }
func (d *dtlsPacketConn) SetReadDeadline(t time.Time) error { return d.conn.SetReadDeadline(t) }
func (d *dtlsPacketConn) SetWriteDeadline(t time.Time) error { return d.conn.SetWriteDeadline(t) }
// dial establishes the appropriate L4(/secure) connection to ep.
// timeout is applied per dial step (TCP connect, TLS handshake, DTLS handshake).
func dial(ctx context.Context, ep Endpoint, timeout time.Duration) (*dialedConn, error) {
addr := net.JoinHostPort(ep.Host, strconv.Itoa(int(ep.Port)))
dctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
switch ep.Transport {
case TransportUDP:
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
}
conn, err := net.ListenPacket("udp", "0.0.0.0:0")
if err != nil {
return nil, fmt.Errorf("listen udp: %w", err)
}
return &dialedConn{pc: conn, remoteAddr: raddr}, nil
case TransportTCP:
var d net.Dialer
c, err := d.DialContext(dctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
}
return &dialedConn{
pc: turn.NewSTUNConn(c),
underlying: c,
remoteAddr: c.RemoteAddr(),
}, nil
case TransportTLS:
var d net.Dialer
raw, err := d.DialContext(dctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
}
tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, MinVersion: tls.VersionTLS12})
if err := tlsConn.HandshakeContext(dctx); err != nil {
raw.Close()
return nil, fmt.Errorf("tls handshake %s: %w", addr, err)
}
state := tlsConn.ConnectionState()
return &dialedConn{
pc: turn.NewSTUNConn(tlsConn),
underlying: tlsConn,
tlsState: &state,
remoteAddr: tlsConn.RemoteAddr(),
}, nil
case TransportDTLS:
raddr, err := net.ResolveUDPAddr("udp", addr)
if err != nil {
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
return nil, fmt.Errorf("listen udp: %w", err)
}
dconn, err := dtls.Client(udpConn, raddr, &dtls.Config{
ServerName: ep.Host,
})
if err != nil {
udpConn.Close()
return nil, fmt.Errorf("dtls setup %s: %w", addr, err)
}
if err := dconn.HandshakeContext(dctx); err != nil {
dconn.Close()
udpConn.Close()
return nil, fmt.Errorf("dtls handshake %s: %w", addr, err)
}
state, _ := dconn.ConnectionState()
return &dialedConn{
pc: &dtlsPacketConn{conn: dconn, raddr: raddr},
dtlsState: &state,
remoteAddr: raddr,
}, nil
default:
return nil, errors.New("unknown transport")
}
}

180
checker/turn.go Normal file
View file

@ -0,0 +1,180 @@
package checker
import (
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"errors"
"fmt"
"net"
"strconv"
"strings"
"time"
"github.com/pion/turn/v4"
)
// turnAllocateResult holds the outcome of a TURN Allocate exchange.
type turnAllocateResult struct {
RelayConn net.PacketConn
RelayAddr net.Addr
IsPrivateRelay bool
UnauthChallenge bool // first allocate replied with 401 + REALM/NONCE (good for "no auth" probe)
AuthErrorCode int // STUN error code on the final attempt (0 if OK)
AuthErrorReason string // STUN reason phrase
Duration time.Duration // wall time of the allocate exchange
Err error
}
// runTURNAllocate runs a full TURN Allocate against the dialed connection.
// If creds is nil, it sends an unauthenticated Allocate and treats the
// expected 401 challenge as success of the *probe* (UnauthChallenge=true).
// If creds is non-nil, it performs the full long-term-credential dance.
//
// The returned RelayConn is owned by the caller and must be Close()d.
func runTURNAllocate(d *dialedConn, creds *turnCredentials, timeout time.Duration) turnAllocateResult {
cfg := &turn.ClientConfig{
Conn: d.pc,
TURNServerAddr: d.remoteAddr.String(),
STUNServerAddr: d.remoteAddr.String(),
RTO: timeout,
Software: "happyDomain-checker-stun-turn",
}
if creds != nil {
cfg.Username = creds.Username
cfg.Password = creds.Password
cfg.Realm = creds.Realm
}
client, err := turn.NewClient(cfg)
if err != nil {
return turnAllocateResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
}
if err := client.Listen(); err != nil {
client.Close()
return turnAllocateResult{Err: fmt.Errorf("client.Listen: %w", err)}
}
start := time.Now()
relay, err := client.Allocate()
dur := time.Since(start)
if err != nil {
// Inspect the STUN error code to give the user a precise diagnostic.
code, reason := stunErrorOf(err)
// 401 with REALM/NONCE is the *expected* answer when probing without
// credentials; surface that as a positive UnauthChallenge signal,
// not as a failure, so the rule layer can flag "open relay" if we
// got a 200 instead.
if creds == nil && code == 401 {
client.Close()
return turnAllocateResult{
UnauthChallenge: true,
Duration: dur,
AuthErrorCode: 401,
AuthErrorReason: reason,
}
}
client.Close()
return turnAllocateResult{
AuthErrorCode: code,
AuthErrorReason: reason,
Duration: dur,
Err: err,
}
}
res := turnAllocateResult{
RelayConn: relay,
RelayAddr: relay.LocalAddr(),
Duration: dur,
}
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
res.IsPrivateRelay = isPrivate(udpAddr.IP)
}
// We intentionally do not Close() the client here so that the relay
// PacketConn stays usable; the caller closes both via RelayConn.Close().
return res
}
// runRelayEcho asks the TURN server to relay a single short datagram to the
// configured probe peer. This proves that:
// - CreatePermission succeeds (server acknowledges the Send indication),
// - the TURN data path accepts traffic.
//
// A reply from the peer is not required or awaited.
func runRelayEcho(relay net.PacketConn, peer string, timeout time.Duration) error {
host, _, err := net.SplitHostPort(peer)
if err != nil {
return fmt.Errorf("invalid probePeer %q: %w", peer, err)
}
if host == "" {
return errors.New("empty probe peer host")
}
addr, err := net.ResolveUDPAddr("udp", peer)
if err != nil {
return fmt.Errorf("resolve probe peer: %w", err)
}
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
// Single-byte DNS-shaped prefix; enough to trigger CreatePermission + Send.
payload := []byte{0x00}
if _, err := relay.WriteTo(payload, addr); err != nil {
return fmt.Errorf("relay WriteTo: %w", err)
}
return nil
}
// turnCredentials carries either explicit long-term credentials or values
// derived from a REST-API shared secret.
type turnCredentials struct {
Username string
Password string
Realm string
}
// restAPICredentials derives ephemeral credentials per the
// draft-uberti-rtcweb-turn-rest scheme:
//
// username = "<unix_ts>:<optional_user>"
// password = base64(hmac_sha1(secret, username))
//
// ttl is the validity window from now.
func restAPICredentials(secret, user, realm string, ttl time.Duration) *turnCredentials {
if ttl <= 0 {
ttl = time.Hour
}
expiry := time.Now().Add(ttl).Unix()
username := strconv.FormatInt(expiry, 10)
if user != "" {
username += ":" + user
}
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(username))
password := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return &turnCredentials{
Username: username,
Password: password,
Realm: realm,
}
}
// stunErrorOf parses a STUN error returned by pion/turn into (code, reason).
// pion/turn does not wrap errors with %w, so we parse the formatted message.
// pion formats error responses as: "... (error <code>: <reason>)"
func stunErrorOf(err error) (int, string) {
if err == nil {
return 0, ""
}
msg := err.Error()
if i := strings.LastIndex(msg, "(error "); i >= 0 {
inner := strings.TrimSuffix(msg[i+7:], ")")
if sep := strings.IndexByte(inner, ':'); sep > 0 {
if code, err := strconv.Atoi(strings.TrimSpace(inner[:sep])); err == nil {
return code, strings.TrimSpace(inner[sep+1:])
}
}
}
return 0, msg
}

132
checker/types.go Normal file
View file

@ -0,0 +1,132 @@
// Package checker implements the STUN/TURN checker for happyDomain.
//
// The checker drives a target server through the STUN binding and TURN
// allocation/relay protocols (RFC 5389, RFC 5766) using the Pion libraries,
// then exposes a structured observation and a rich HTML report including
// remediation guidance for the most common deployment mistakes.
package checker
import (
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// ObservationKeyStunTurn is the observation key for STUN/TURN test data.
const ObservationKeyStunTurn sdk.ObservationKey = "stun_turn"
// Transport identifies the L4/L4-secure transport used to reach an endpoint.
type Transport string
const (
TransportUDP Transport = "udp"
TransportTCP Transport = "tcp"
TransportTLS Transport = "tls"
TransportDTLS Transport = "dtls"
)
// Endpoint is a single resolved server target to probe.
type Endpoint struct {
URI string `json:"uri"`
Host string `json:"host"`
Port uint16 `json:"port"`
Transport Transport `json:"transport"`
Secure bool `json:"secure"`
IsTURN bool `json:"is_turn"` // false: STUN-only scheme, true: TURN scheme
Source string `json:"source"` // "uri" or "srv:_turn._udp.example.com"
}
// SubTestStatus mirrors sdk.Status as a string for JSON friendliness.
type SubTestStatus string
const (
SubTestOK SubTestStatus = "ok"
SubTestInfo SubTestStatus = "info"
SubTestWarn SubTestStatus = "warn"
SubTestCrit SubTestStatus = "crit"
SubTestSkipped SubTestStatus = "skipped"
SubTestError SubTestStatus = "error"
)
// SubTest is one fine-grained test executed against an endpoint.
type SubTest struct {
Name string `json:"name"`
Status SubTestStatus `json:"status"`
DurationMs int64 `json:"duration_ms"`
Detail string `json:"detail,omitempty"`
Error string `json:"error,omitempty"`
Fix string `json:"fix,omitempty"`
}
// EndpointReport gathers all sub-tests run against a single endpoint.
type EndpointReport struct {
Endpoint Endpoint `json:"endpoint"`
SubTests []SubTest `json:"sub_tests"`
}
// Worst returns the worst sub-test status of the endpoint.
func (e EndpointReport) Worst() SubTestStatus {
worst := SubTestOK
for _, t := range e.SubTests {
if statusRank(t.Status) > statusRank(worst) {
worst = t.Status
}
}
return worst
}
// FirstFailure returns the first non-OK/Info/Skipped sub-test.
func (e EndpointReport) FirstFailure() *SubTest {
for i := range e.SubTests {
s := e.SubTests[i].Status
if s != SubTestOK && s != SubTestInfo && s != SubTestSkipped {
return &e.SubTests[i]
}
}
return nil
}
// StunTurnData is the JSON-serializable observation payload.
type StunTurnData struct {
Zone string `json:"zone,omitempty"`
Mode string `json:"mode,omitempty"`
CollectedAt time.Time `json:"collected_at"`
Endpoints []EndpointReport `json:"endpoints"`
GlobalError string `json:"global_error,omitempty"`
}
// statusRank converts a SubTestStatus to an orderable severity.
func statusRank(s SubTestStatus) int {
switch s {
case SubTestOK:
return 0
case SubTestSkipped:
return 1
case SubTestInfo:
return 2
case SubTestWarn:
return 3
case SubTestCrit:
return 4
case SubTestError:
return 5
}
return -1
}
// toSDKStatus converts the worst SubTestStatus seen to an sdk.Status.
func toSDKStatus(s SubTestStatus) sdk.Status {
switch s {
case SubTestOK:
return sdk.StatusOK
case SubTestInfo, SubTestSkipped:
return sdk.StatusInfo
case SubTestWarn:
return sdk.StatusWarn
case SubTestCrit:
return sdk.StatusCrit
case SubTestError:
return sdk.StatusError
}
return sdk.StatusUnknown
}