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:
nemunaire 2026-04-19 13:41:52 +07:00
commit 7c7706fe3f
29 changed files with 2794 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
checker-stun-turn
*.so

17
Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM golang:1.25-alpine AS builder
ARG CHECKER_VERSION=custom-build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-stun-turn .
FROM scratch
COPY --from=builder /checker-stun-turn /checker-stun-turn
USER 65534:65534
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/checker-stun-turn", "-healthcheck"]
ENTRYPOINT ["/checker-stun-turn"]

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 The happyDomain Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

28
Makefile Normal file
View file

@ -0,0 +1,28 @@
CHECKER_NAME := checker-stun-turn
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
CHECKER_VERSION ?= custom-build
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
.PHONY: all plugin docker clean test
all: $(CHECKER_NAME)
$(CHECKER_NAME): $(CHECKER_SOURCES)
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
plugin: $(CHECKER_NAME).so
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
test:
go test -tags standalone ./...
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-stun-turn
Copyright (c) 2026 The happyDomain Authors
This product is licensed under the MIT License (see LICENSE).
-------------------------------------------------------------------------------
Third-party notices
-------------------------------------------------------------------------------
This product includes software developed as part of the checker-sdk-go
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
under the Apache License, Version 2.0:
checker-sdk-go
Copyright 2020-2026 The happyDomain Authors
This product includes software developed as part of the happyDomain
project (https://happydomain.org).
Portions of this code were originally written for the happyDomain
server (licensed under AGPL-3.0 and a commercial license) and are
made available there under the Apache License, Version 2.0 to enable
a permissively licensed ecosystem of checker plugins.
You may obtain a copy of the Apache License 2.0 at:
http://www.apache.org/licenses/LICENSE-2.0

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# checker-stun-turn
happyDomain checker that probes **STUN** and **TURN** servers end-to-end:
DNS / SRV discovery, TCP/UDP reachability, TLS / DTLS handshake, STUN binding,
open-relay check, authenticated TURN Allocate (long-term creds *or*
REST API shared secret), relay address sanity, and a `CreatePermission + Send`
round-trip through the relay.
Backed by [`github.com/pion/stun`](https://github.com/pion/stun) +
[`github.com/pion/turn`](https://github.com/pion/turn).
## Tests performed per endpoint
| Test | What it proves |
|-----------------------------|----------------|
| `dial:<transport>` | DNS resolves, port is reachable, TLS/DTLS handshake succeeds. |
| `tls` / `dtls` | Records TLS version + cipher + peer cert CN. |
| `stun_binding` | Server answers RFC 5389 Binding Request; measures RTT. |
| `stun_reflexive_public` | Server returned a *public* XOR-MAPPED address (not RFC1918). |
| `turn_open_relay_check` | Unauthenticated Allocate is rejected with 401 + REALM/NONCE. |
| `turn_allocate_auth` | Authenticated Allocate succeeds; relay address returned. |
| `turn_relay_public` | Relay address is publicly routable. |
| `turn_relay_echo` | CreatePermission + Send to the probe peer succeed. |
| `turn_channel_bind` | (optional) ChannelBind exercised via the relay conn. |
## Most common failures surfaced with a fix
Each failing sub-test carries a `Fix` field that is rendered prominently in the
HTML report (yellow callout at the top of the card *and* inline with each row).
Mapping:
- UDP/TCP dial timeouts → firewall/NAT guidance
- TLS handshake errors → certificate reissue guidance (coturn `cert=`/`pkey=`)
- STUN binding returns RFC1918 → `external-ip=` for coturn
- Unauthenticated Allocate accepted → enable `lt-cred-mech`, close the open relay
- Allocate 401 loop → check NTP (TURN nonces are time-sensitive)
- Allocate 441 → wrong username/password or wrong REST shared secret
- Allocate 442 → try different transport or enable it server-side
- Allocate 486/508 → quota / port-range issues on the server
- Relay address is private → set `relay-ip=` to a public IP
- Relay echo fails → `min-port`/`max-port` range not publicly reachable
## Usage
Build and run:
```
make # standalone binary
make plugin # .so plugin for happyDomain
./checker-stun-turn -listen :8080
```
Trigger a check:
```
curl -sX POST localhost:8080/collect -H 'content-type: application/json' -d '{
"options": {
"zone": "example.com",
"serverURI": "turns:turn.example.com:5349?transport=tcp",
"mode": "turn",
"username": "alice",
"credential": "s3cret",
"transports": "udp,tcp,tls",
"probePeer": "1.1.1.1:53",
"timeout": 5
}
}' | jq .
```
## Options
| Scope | Option | Type | Default | Notes |
|---------|-------------------|--------|---------------|-------|
| run | `zone` | string | (auto-filled) | used for `_stun._udp` / `_turn._udp` / `_turns._tcp` SRV discovery |
| run | `serverURI` | string | | explicit URI, RFC 7064/7065 |
| run | `mode` | choice | `auto` | `stun`, `turn`, `auto` |
| user | `username` | string | | long-term credentials |
| user | `credential` | secret | | long-term credentials |
| user | `sharedSecret` | secret | | REST-API auth (draft-uberti), takes precedence |
| user | `realm` | string | | optional explicit realm |
| user | `transports` | string | `udp,tcp,tls` | comma-separated among `udp,tcp,tls,dtls` |
| user | `probePeer` | string | `1.1.1.1:53` | target for the relay echo test |
| user | `testChannelBind` | bool | `false` | |
| user | `warningRTT` | uint | `200` ms | |
| user | `criticalRTT` | uint | `1000` ms | |
| user | `timeout` | uint | `5` s | per-probe |
## License
MIT (see LICENSE).

235
checker/collect.go Normal file
View file

@ -0,0 +1,235 @@
package checker
import (
"context"
"crypto/tls"
"fmt"
"net"
"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
}
// Collect gathers raw STUN/TURN observations (SRV discovery, dial
// outcome, STUN Binding result, TURN Allocate results, relay echo).
// It performs NO judgement: severity, pass/fail, warning thresholds
// and fix suggestions are left to the CheckRule layer.
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"
}
// Refuse to relay traffic toward private/loopback/link-local
// destinations: a malicious caller could otherwise abuse the target
// TURN server to port-scan the operator's internal network through us.
if isPrivateAddr(probePeer) {
return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer)
}
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,
}
transports := parseTransports(transportsRaw)
warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200))
critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000))
data := &StunTurnData{
Zone: zone,
Mode: mode,
RequestedURI: uri,
ProbePeer: probePeer,
WarningRTTMs: warnRTT,
CriticalRTT: critRTT,
HasCreds: sharedSecret != "" || (username != "" && password != ""),
CollectedAt: time.Now().UTC(),
}
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
if err != nil {
data.GlobalError = err.Error()
return data, nil
}
for _, ep := range endpoints {
probe := EndpointProbe{Endpoint: ep}
collectEndpoint(ctx, &probe, cfg)
data.Endpoints = append(data.Endpoints, probe)
}
return data, nil
}
// collectEndpoint runs every network probe we know how to run against
// a single endpoint and records raw results on probe. It never assigns
// severity.
func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) {
ep := probe.Endpoint
// Best-effort DNS lookup for IPv6 coverage rule.
if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil {
for _, ip := range ips {
probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String())
}
}
dialStart := time.Now()
dc, err := dial(ctx, ep, cfg.timeout)
dialDur := time.Since(dialStart)
if err != nil {
probe.Dial = DialResult{
OK: false,
DurationMs: dialDur.Milliseconds(),
Error: err.Error(),
}
return
}
defer dc.Close()
probe.Dial = DialResult{
OK: true,
DurationMs: dialDur.Milliseconds(),
RemoteAddr: dc.remoteAddr.String(),
}
if dc.tlsState != nil {
probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version)
probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite)
probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState)
}
if dc.dtlsState != nil {
probe.Dial.DTLSHandshake = true
}
// STUN Binding probe, always attempted.
bind := runSTUNBinding(dc, cfg.timeout)
probe.STUNBinding.Attempted = true
if bind.Err != nil {
probe.STUNBinding.OK = false
probe.STUNBinding.Error = bind.Err.Error()
} else {
probe.STUNBinding.OK = true
probe.STUNBinding.RTTMs = bind.RTT.Milliseconds()
if bind.ReflexiveAddr != nil {
probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String()
}
probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped
}
// TURN-only probes short-circuit when mode=stun or the scheme is
// stun:/stuns:.
if cfg.mode == "stun" || !ep.IsTURN {
return
}
// Unauthenticated TURN Allocate (open-relay probe).
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
probe.TURNNoAuth.Attempted = true
probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds()
probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode
probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason
probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge
if noAuth.RelayConn != nil {
probe.TURNNoAuth.OK = true
if noAuth.RelayAddr != nil {
probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String()
}
_ = noAuth.RelayConn.Close()
if noAuth.Client != nil {
noAuth.Client.Close()
}
} else if noAuth.Err != nil && !noAuth.UnauthChallenge {
probe.TURNNoAuth.Error = noAuth.Err.Error()
}
// Authenticated TURN Allocate, if credentials are provided.
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
if creds == nil {
return
}
dc2, err := dial(ctx, ep, cfg.timeout)
if err != nil {
probe.TURNAuth.Attempted = true
probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err)
return
}
defer dc2.Close()
auth := runTURNAllocate(dc2, creds, cfg.timeout)
probe.TURNAuth.Attempted = true
probe.TURNAuth.DurationMs = auth.Duration.Milliseconds()
probe.TURNAuth.ErrorCode = auth.AuthErrorCode
probe.TURNAuth.ErrorReason = auth.AuthErrorReason
if auth.Err != nil {
probe.TURNAuth.OK = false
probe.TURNAuth.Error = auth.Err.Error()
return
}
probe.TURNAuth.OK = true
if auth.RelayAddr != nil {
probe.TURNAuth.RelayAddr = auth.RelayAddr.String()
}
probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay
defer func() {
auth.RelayConn.Close()
if auth.Client != nil {
auth.Client.Close()
}
}()
// Relay echo.
probe.RelayEcho.Attempted = true
probe.RelayEcho.PeerAddr = cfg.probePeer
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
probe.RelayEcho.OK = false
probe.RelayEcho.Error = err.Error()
} else {
probe.RelayEcho.OK = true
}
if cfg.testChannelBind {
probe.ChannelBindRun = true
}
}
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
}

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 (p *stunTurnProvider) 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: Rules(),
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)
}
}

51
checker/discovery.go Normal file
View file

@ -0,0 +1,51 @@
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.Dial.OK {
continue
}
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
}

77
checker/interactive.go Normal file
View file

@ -0,0 +1,77 @@
//go:build standalone
package checker
import (
"errors"
"net/http"
"strconv"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// RenderForm exposes the inputs needed to drive a STUN/TURN check from
// the standalone HTML form. The fields mirror the canonical option
// documentation in Definition() (RunOpts then UserOpts) so the form
// stays in lock-step with the JSON schema served at /definition.
func (p *stunTurnProvider) RenderForm() []sdk.CheckerOptionField {
def := p.Definition()
fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.UserOpts))
fields = append(fields, def.Options.RunOpts...)
fields = append(fields, def.Options.UserOpts...)
return fields
}
// ParseForm turns the submitted form into a CheckerOptions. At least one
// of zone or serverURI must be provided so discoverEndpoints has
// something to work with.
func (p *stunTurnProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
zone := strings.TrimSpace(r.FormValue("zone"))
zone = strings.TrimSuffix(zone, ".")
uri := strings.TrimSpace(r.FormValue("serverURI"))
if zone == "" && uri == "" {
return nil, errors.New("either zone or serverURI is required")
}
opts := sdk.CheckerOptions{}
if zone != "" {
opts["zone"] = zone
}
if uri != "" {
opts["serverURI"] = uri
}
if v := strings.TrimSpace(r.FormValue("mode")); v != "" {
switch v {
case "auto", "stun", "turn":
opts["mode"] = v
default:
return nil, errors.New("mode must be one of: auto, stun, turn")
}
}
for _, k := range []string{"username", "credential", "sharedSecret", "realm", "transports", "probePeer"} {
if v := strings.TrimSpace(r.FormValue(k)); v != "" {
opts[k] = v
}
}
if v := r.FormValue("testChannelBind"); v != "" {
opts["testChannelBind"] = v == "true" || v == "on" || v == "1"
}
for _, k := range []string{"warningRTT", "criticalRTT", "timeout"} {
v := strings.TrimSpace(r.FormValue(k))
if v == "" {
continue
}
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return nil, errors.New(k + " must be a non-negative integer")
}
opts[k] = int(n)
}
return opts, nil
}

16
checker/provider.go Normal file
View file

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

400
checker/report.go Normal file
View file

@ -0,0 +1,400 @@
package checker
import (
"encoding/json"
"fmt"
"html/template"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type tmplHint struct {
StatusCSS string
StatusText string
Message string
Fix string
}
type tmplObservation struct {
Name string
Status string // "ok" | "fail" | "info" (for neutral data badge)
Detail string
Error string
}
type tmplEndpoint struct {
URI string
Transport string
Source string
Open bool
BadgeText string
BadgeCSS string
ResolvedIPs []string
Observations []tmplObservation
Hints []tmplHint
}
type tmplData struct {
Zone string
Mode string
OverallText string
OverallCSS string
HeadlineFix string
HeadlineDetail string
GlobalError string
GlobalHints []tmplHint // hints with no endpoint subject
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, .unknown { background: #e5e7eb; color: #374151; }
.fail { background: #fee2e2; color: #991b1b; }
.neutral { 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; margin-top: .4rem; }
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; }
.section-title { font-size: .78rem; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin: .6rem 0 .2rem; }
.hint { padding: .5rem .65rem; border-radius: 6px; margin-bottom: .35rem; background: #f9fafb; }
.hint-msg { font-size: .9rem; }
</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}}
{{if .GlobalHints}}
<div class="hd">
<h2>Global findings</h2>
{{range .GlobalHints}}
<div class="hint">
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
<span class="hint-msg">{{.Message}}</span>
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</div>
{{end}}
</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>
{{if .ResolvedIPs}} &middot; Resolved: <code>{{range $i, $ip := .ResolvedIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}</code>{{end}}
</p>
{{if .Hints}}
<div class="section-title">Findings</div>
{{range .Hints}}
<div class="hint">
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
<span class="hint-msg">{{.Message}}</span>
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
</div>
{{end}}
{{end}}
{{if .Observations}}
<div class="section-title">Observations</div>
<table>
<tr><th>Probe</th><th>Result</th><th>Detail</th></tr>
{{range .Observations}}
<tr>
<td><code>{{.Name}}</code></td>
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
<td>
{{if .Detail}}{{.Detail}}{{end}}
{{if .Error}}<div class="err">&#9888; {{.Error}}</div>{{end}}
</td>
</tr>
{{end}}
</table>
{{end}}
</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)
}
states := ctx.States()
// Group hints by endpoint subject.
type group struct {
worst sdk.Status
hints []tmplHint
first *tmplHint // first failing hint (for headline/open logic)
}
byEp := make(map[string]*group)
global := &group{worst: sdk.StatusOK}
for _, st := range states {
fix := ""
if st.Meta != nil {
if v, ok := st.Meta["fix"].(string); ok {
fix = v
}
}
css, txt := statusBadge(st.Status)
h := tmplHint{
StatusCSS: css,
StatusText: txt,
Message: st.Message,
Fix: fix,
}
var g *group
if st.Subject == "" {
g = global
} else {
g = byEp[st.Subject]
if g == nil {
g = &group{worst: sdk.StatusOK}
byEp[st.Subject] = g
}
}
g.hints = append(g.hints, h)
if statusSeverity(st.Status) > statusSeverity(g.worst) {
g.worst = st.Status
}
if g.first == nil && isFailing(st.Status) {
hh := h
g.first = &hh
}
}
td := tmplData{
Zone: d.Zone,
Mode: d.Mode,
GlobalError: d.GlobalError,
GlobalHints: global.hints,
}
worst := sdk.StatusOK
if statusSeverity(global.worst) > statusSeverity(worst) {
worst = global.worst
}
var headlineFix, headlineDetail string
if global.first != nil && global.first.Fix != "" {
headlineFix = global.first.Fix
headlineDetail = global.first.Message
}
for _, ep := range d.Endpoints {
subj := epSubject(ep.Endpoint)
g := byEp[subj]
te := tmplEndpoint{
URI: ep.Endpoint.URI,
Transport: string(ep.Endpoint.Transport),
Source: ep.Endpoint.Source,
ResolvedIPs: ep.ResolvedIPs,
Observations: observationsFor(ep),
}
epWorst := sdk.StatusOK
if g != nil {
te.Hints = g.hints
epWorst = g.worst
if statusSeverity(g.worst) > statusSeverity(worst) {
worst = g.worst
}
if headlineFix == "" && g.first != nil && g.first.Fix != "" {
headlineFix = g.first.Fix
headlineDetail = fmt.Sprintf("[%s] %s", ep.Endpoint.URI, g.first.Message)
}
}
te.BadgeCSS, te.BadgeText = statusBadge(epWorst)
te.Open = isFailing(epWorst)
td.Endpoints = append(td.Endpoints, te)
}
td.OverallCSS, td.OverallText = statusBadge(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
}
// observationsFor renders the raw probe observations for an endpoint,
// without any severity or fix guidance. This is the neutral, data-only
// view used both as the default and as a fallback when no CheckStates
// are available.
func observationsFor(ep EndpointProbe) []tmplObservation {
var out []tmplObservation
// Dial
dialName := fmt.Sprintf("dial:%s", ep.Endpoint.Transport)
if ep.Dial.OK {
detail := fmt.Sprintf("connected to %s in %d ms", ep.Dial.RemoteAddr, ep.Dial.DurationMs)
if ep.Dial.TLSVersion != "" {
detail += fmt.Sprintf("; TLS %s, %s, peer CN=%s", ep.Dial.TLSVersion, ep.Dial.TLSCipher, ep.Dial.TLSPeerCN)
}
if ep.Dial.DTLSHandshake {
detail += "; DTLS handshake completed"
}
out = append(out, tmplObservation{Name: dialName, Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{Name: dialName, Status: "fail", Error: ep.Dial.Error})
return out
}
// STUN binding
if ep.STUNBinding.Attempted {
if ep.STUNBinding.OK {
detail := fmt.Sprintf("reflexive address: %s (RTT %d ms)", ep.STUNBinding.ReflexiveAddr, ep.STUNBinding.RTTMs)
if ep.STUNBinding.IsPrivateMapped {
detail += " [private]"
}
out = append(out, tmplObservation{Name: "stun_binding", Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{Name: "stun_binding", Status: "fail", Error: ep.STUNBinding.Error})
}
}
// TURN no-auth probe
if ep.TURNNoAuth.Attempted {
switch {
case ep.TURNNoAuth.OK:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "allocation accepted without authentication"})
case ep.TURNNoAuth.UnauthChallenge:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "server challenged the unauthenticated allocate (401)"})
default:
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "info", Detail: fmt.Sprintf("code=%d %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason)})
}
}
// TURN authenticated allocate
if ep.TURNAuth.Attempted {
if ep.TURNAuth.OK {
detail := fmt.Sprintf("relay address: %s (%d ms)", ep.TURNAuth.RelayAddr, ep.TURNAuth.DurationMs)
if ep.TURNAuth.IsPrivateRelay {
detail += " [private]"
}
out = append(out, tmplObservation{Name: "turn_allocate_auth", Status: "ok", Detail: detail})
} else {
out = append(out, tmplObservation{
Name: "turn_allocate_auth",
Status: "fail",
Detail: fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode),
Error: ep.TURNAuth.Error,
})
}
}
// Relay echo
if ep.RelayEcho.Attempted {
if ep.RelayEcho.OK {
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "ok", Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", ep.RelayEcho.PeerAddr)})
} else {
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "fail", Error: ep.RelayEcho.Error})
}
}
if ep.ChannelBindRun {
out = append(out, tmplObservation{Name: "turn_channel_bind", Status: "info", Detail: "ChannelBind exercised implicitly by relay traffic"})
}
return out
}
func statusBadge(s sdk.Status) (cssClass, label string) {
switch s {
case sdk.StatusOK:
return "ok", "OK"
case sdk.StatusInfo:
return "info", "INFO"
case sdk.StatusWarn:
return "warn", "WARN"
case sdk.StatusCrit:
return "crit", "CRIT"
case sdk.StatusError:
return "error", "ERROR"
case sdk.StatusUnknown:
return "unknown", "UNKNOWN"
}
return "info", s.String()
}
func statusSeverity(s sdk.Status) int {
switch s {
case sdk.StatusOK:
return 0
case sdk.StatusUnknown:
return 1
case sdk.StatusInfo:
return 2
case sdk.StatusWarn:
return 3
case sdk.StatusCrit:
return 4
case sdk.StatusError:
return 5
}
return -1
}
func isFailing(s sdk.Status) bool {
return s == sdk.StatusWarn || s == sdk.StatusCrit || s == sdk.StatusError
}

83
checker/rule.go Normal file
View file

@ -0,0 +1,83 @@
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, ": ")
}

186
checker/rules_discovery.go Normal file
View file

@ -0,0 +1,186 @@
package checker
import (
"context"
"fmt"
"strings"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// discoveryRule reports the outcome of endpoint discovery (URI parse
// or SRV lookup). If Collect recorded a GlobalError, this is where it
// surfaces.
type discoveryRule struct{}
func (r *discoveryRule) Name() string { return "stun_turn.discovery" }
func (r *discoveryRule) Description() string {
return "Verifies that at least one STUN/TURN endpoint could be discovered (explicit URI or SRV lookup)."
}
func (r *discoveryRule) 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.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.discovery.empty",
}}
}
return []sdk.CheckState{passState("stun_turn.discovery.ok",
fmt.Sprintf("%d endpoint(s) discovered", len(data.Endpoints)))}
}
// srvStunRule verifies that at least one STUN (non-TURN) SRV/URI endpoint
// was obtained. Only meaningful in SRV-discovery mode.
type srvStunRule struct{}
func (r *srvStunRule) Name() string { return "stun_turn.srv_stun" }
func (r *srvStunRule) Description() string {
return "Verifies that at least one STUN endpoint is reachable via SRV (_stun/_stuns) or an explicit URI."
}
func (r *srvStunRule) 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.GlobalError != "" {
return []sdk.CheckState{skippedState("stun_turn.srv_stun.skipped",
"Discovery failed, SRV coverage could not be evaluated.")}
}
// Count endpoints whose source indicates STUN SRV (or URI form).
var stunCount, turnCount int
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
turnCount++
} else {
stunCount++
}
}
if stunCount == 0 && turnCount > 0 {
return []sdk.CheckState{{
Status: sdk.StatusInfo,
Code: "stun_turn.srv_stun.none",
Message: "No STUN-only endpoint discovered (TURN endpoints also expose STUN).",
}}
}
if stunCount == 0 {
return []sdk.CheckState{{
Status: sdk.StatusWarn,
Code: "stun_turn.srv_stun.missing",
Message: "No STUN endpoint discovered; clients may fail to obtain a reflexive address.",
}}
}
return []sdk.CheckState{passState("stun_turn.srv_stun.ok",
fmt.Sprintf("%d STUN endpoint(s) discovered", stunCount))}
}
// srvTurnRule verifies TURN endpoint coverage.
type srvTurnRule struct{}
func (r *srvTurnRule) Name() string { return "stun_turn.srv_turn" }
func (r *srvTurnRule) Description() string {
return "Verifies that at least one TURN endpoint is reachable via SRV (_turn/_turns) or an explicit URI."
}
func (r *srvTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
if data.GlobalError != "" {
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
"Discovery failed, TURN coverage could not be evaluated.")}
}
if data.Mode == "stun" {
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
"TURN coverage not evaluated (mode=stun).")}
}
var turnCount int
for _, ep := range data.Endpoints {
if ep.Endpoint.IsTURN {
turnCount++
}
}
if turnCount == 0 {
sev := sdk.StatusWarn
if data.Mode == "turn" {
sev = sdk.StatusCrit
}
return []sdk.CheckState{{
Status: sev,
Code: "stun_turn.srv_turn.missing",
Message: "No TURN endpoint discovered; clients behind symmetric NAT will have no relay path.",
}}
}
return []sdk.CheckState{passState("stun_turn.srv_turn.ok",
fmt.Sprintf("%d TURN endpoint(s) discovered", turnCount))}
}
// dialRule reports connectivity/handshake failures per endpoint.
type dialRule struct{}
func (r *dialRule) Name() string { return "stun_turn.dial" }
func (r *dialRule) Description() string {
return "Verifies that every discovered endpoint accepts a connection (TCP/TLS handshake or UDP socket)."
}
func (r *dialRule) 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.GlobalError != "" || len(data.Endpoints) == 0 {
return []sdk.CheckState{skippedState("stun_turn.dial.skipped", "No endpoint to evaluate.")}
}
var states []sdk.CheckState
for _, ep := range data.Endpoints {
if ep.Dial.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.dial.failed",
Subject: epSubject(ep.Endpoint),
Message: ep.Dial.Error,
Meta: map[string]any{"fix": dialFix(ep.Endpoint, ep.Dial.Error)},
})
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.dial.ok", "All discovered endpoints accepted a connection.")}
}
return states
}
// dialFix mirrors the fix phrasing the old Collect emitted for dial
// failures. Kept verbatim so users keep the same remediation guidance.
func dialFix(ep Endpoint, errMsg string) string {
msg := strings.ToLower(errMsg)
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."
}

154
checker/rules_stun.go Normal file
View file

@ -0,0 +1,154 @@
package checker
import (
"context"
"fmt"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// stunBindingRule verifies that the STUN Binding request succeeds on every
// reachable endpoint (returns a reflexive address).
type stunBindingRule struct{}
func (r *stunBindingRule) Name() string { return "stun_turn.stun_binding" }
func (r *stunBindingRule) Description() string {
return "Verifies that the STUN Binding request receives a XOR-MAPPED-ADDRESS reply."
}
func (r *stunBindingRule) 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.Dial.OK || !ep.STUNBinding.Attempted {
continue
}
seen = true
if ep.STUNBinding.OK {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.stun_binding.failed",
Subject: epSubject(ep.Endpoint),
Message: ep.STUNBinding.Error,
Meta: map[string]any{
"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.",
},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.stun_binding.skipped", "No endpoint completed a dial, STUN binding not evaluated.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.stun_binding.ok", "STUN Binding succeeded on every reachable endpoint.")}
}
return states
}
// stunReflexivePublicRule flags servers that return a private/loopback
// reflexive address (typically a TURN server behind NAT with missing
// external-ip configuration).
type stunReflexivePublicRule struct{}
func (r *stunReflexivePublicRule) Name() string { return "stun_turn.reflexive_public" }
func (r *stunReflexivePublicRule) Description() string {
return "Flags endpoints that return a private/loopback reflexive address (server unaware of its public IP)."
}
func (r *stunReflexivePublicRule) 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.STUNBinding.OK {
continue
}
seen = true
if !ep.STUNBinding.IsPrivateMapped {
continue
}
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.reflexive_public.private",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("server returned a private/loopback IP: %s", ep.STUNBinding.ReflexiveAddr),
Meta: map[string]any{
"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.",
},
})
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.reflexive_public.skipped", "No successful STUN Binding to evaluate.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.reflexive_public.ok", "Every reflexive address is public.")}
}
return states
}
// stunLatencyRule folds the warningRTT / criticalRTT thresholds the old
// Collect hard-coded into a dedicated rule.
type stunLatencyRule struct{}
func (r *stunLatencyRule) Name() string { return "stun_turn.stun_latency" }
func (r *stunLatencyRule) Description() string {
return "Compares the STUN Binding RTT against the configured warning/critical thresholds."
}
func (r *stunLatencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
data, errSt := loadData(ctx, obs)
if errSt != nil {
return []sdk.CheckState{*errSt}
}
warn := time.Duration(sdk.GetIntOption(opts, "warningRTT", int(data.WarningRTTMs))) * time.Millisecond
crit := time.Duration(sdk.GetIntOption(opts, "criticalRTT", int(data.CriticalRTT))) * time.Millisecond
if warn <= 0 {
warn = 200 * time.Millisecond
}
if crit <= 0 {
crit = 1000 * time.Millisecond
}
var states []sdk.CheckState
seen := false
for _, ep := range data.Endpoints {
if !ep.STUNBinding.OK {
continue
}
seen = true
rtt := time.Duration(ep.STUNBinding.RTTMs) * time.Millisecond
switch {
case rtt > crit:
states = append(states, sdk.CheckState{
Status: sdk.StatusCrit,
Code: "stun_turn.stun_latency.critical",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("STUN RTT %dms exceeds critical threshold %dms", ep.STUNBinding.RTTMs, crit.Milliseconds()),
Meta: map[string]any{"fix": "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."},
})
case rtt > warn:
states = append(states, sdk.CheckState{
Status: sdk.StatusWarn,
Code: "stun_turn.stun_latency.high",
Subject: epSubject(ep.Endpoint),
Message: fmt.Sprintf("STUN RTT %dms exceeds warning threshold %dms", ep.STUNBinding.RTTMs, warn.Milliseconds()),
Meta: map[string]any{"fix": "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."},
})
}
}
if !seen {
return []sdk.CheckState{skippedState("stun_turn.stun_latency.skipped", "No successful STUN Binding to evaluate.")}
}
if len(states) == 0 {
return []sdk.CheckState{passState("stun_turn.stun_latency.ok", "STUN RTT within acceptable thresholds on every endpoint.")}
}
return states
}

108
checker/rules_transport.go Normal file
View file

@ -0,0 +1,108 @@
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.")}
}

210
checker/rules_turn.go Normal file
View 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."
}

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
}

148
checker/transport.go Normal file
View file

@ -0,0 +1,148 @@
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)
}
// Use the dual-stack wildcard ("") so the kernel can pick an IPv6
// source when the resolved server address is IPv6.
conn, err := net.ListenPacket("udp", ":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")
}
}

189
checker/turn.go Normal file
View file

@ -0,0 +1,189 @@
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 {
Client *turn.Client // non-nil iff RelayConn is non-nil; caller must Close after RelayConn.
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{
Client: client,
RelayConn: relay,
RelayAddr: relay.LocalAddr(),
Duration: dur,
}
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
res.IsPrivateRelay = isPrivate(udpAddr.IP)
}
// Client is intentionally not Close()d here: closing it before the
// caller is done with RelayConn would tear down the underlying PacketConn.
// The caller is responsible for Close()ing RelayConn first, then Client.
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)
}
// Defence in depth against SSRF: even if the literal probePeer passed
// the upfront check, its DNS resolution might land on private space.
if isPrivate(addr.IP) {
return fmt.Errorf("probePeer %q resolves to private address %s", peer, addr.IP)
}
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
return err
}
// One-byte payload: enough to trigger CreatePermission + Send on the
// TURN data path. We do not expect or wait for a peer reply.
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
}

53
checker/turn_test.go Normal file
View file

@ -0,0 +1,53 @@
package checker
import (
"errors"
"fmt"
"testing"
"github.com/pion/stun/v3"
)
// TestStunErrorOf_PinPionFormat builds an error using pion's own
// ErrorCodeAttribute.String() so that any future change to the format
// pion/turn uses ("... (error <code>: <reason>)") makes this test fail
// loudly instead of silently breaking our diagnostic parsing.
func TestStunErrorOf_PinPionFormat(t *testing.T) {
cases := []struct {
name string
code stun.ErrorCode
reason string
wantCode int
wantReason string
}{
{"unauthorized", stun.CodeUnauthorized, "Unauthorized", 401, "Unauthorized"},
{"stale nonce", stun.CodeStaleNonce, "Stale Nonce", 438, "Stale Nonce"},
{"server error", stun.CodeServerError, "Server Error", 500, "Server Error"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
attr := stun.ErrorCodeAttribute{Code: tc.code, Reason: []byte(tc.reason)}
// Mirror pion/turn/v4@v4.0.0 client.go:296:
// fmt.Errorf("%s (error %s)", res.Type, code)
err := fmt.Errorf("error response (error %s)", attr)
gotCode, gotReason := stunErrorOf(err)
if gotCode != tc.wantCode || gotReason != tc.wantReason {
t.Errorf("stunErrorOf(%q) = (%d, %q); want (%d, %q): pion error format may have changed",
err.Error(), gotCode, gotReason, tc.wantCode, tc.wantReason)
}
})
}
}
func TestStunErrorOf_NoMatch(t *testing.T) {
code, reason := stunErrorOf(errors.New("plain error"))
if code != 0 {
t.Errorf("expected code 0 for unparseable error, got %d", code)
}
if reason != "plain error" {
t.Errorf("expected reason to fall back to message, got %q", reason)
}
if c, r := stunErrorOf(nil); c != 0 || r != "" {
t.Errorf("nil error: got (%d, %q), want (0, \"\")", c, r)
}
}

133
checker/types.go Normal file
View file

@ -0,0 +1,133 @@
// 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 (
"net"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// isPrivateAddr returns whether s parses as a private/loopback/link-local
// address. Accepts either a bare IP or a host:port form. Hostnames return
// false; the caller should resolve first if needed.
func isPrivateAddr(s string) bool {
ip := net.ParseIP(s)
if ip == nil {
host, _, err := net.SplitHostPort(s)
if err != nil {
return false
}
ip = net.ParseIP(host)
}
return isPrivate(ip)
}
// 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"
}
// DialResult holds the raw outcome of establishing the L4(/secure)
// connection to an endpoint. Collected without judgement.
type DialResult struct {
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms"`
RemoteAddr string `json:"remote_addr,omitempty"`
Error string `json:"error,omitempty"`
// TLS metadata populated when the underlying transport used TLS.
TLSVersion string `json:"tls_version,omitempty"`
TLSCipher string `json:"tls_cipher,omitempty"`
TLSPeerCN string `json:"tls_peer_cn,omitempty"`
// DTLSHandshake records whether a DTLS handshake was performed.
DTLSHandshake bool `json:"dtls_handshake,omitempty"`
}
// STUNBindingObservation holds the raw outcome of a STUN Binding probe.
type STUNBindingObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
RTTMs int64 `json:"rtt_ms"`
ReflexiveAddr string `json:"reflexive_addr,omitempty"`
IsPrivateMapped bool `json:"is_private_mapped,omitempty"`
Error string `json:"error,omitempty"`
}
// TURNAllocateObservation holds the raw outcome of a TURN Allocate
// attempt (either unauthenticated probe or authenticated attempt).
type TURNAllocateObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
DurationMs int64 `json:"duration_ms"`
RelayAddr string `json:"relay_addr,omitempty"`
IsPrivateRelay bool `json:"is_private_relay,omitempty"`
UnauthChallenge bool `json:"unauth_challenge,omitempty"`
ErrorCode int `json:"error_code,omitempty"`
ErrorReason string `json:"error_reason,omitempty"`
Error string `json:"error,omitempty"`
}
// RelayEchoObservation holds the raw outcome of the relay echo probe.
type RelayEchoObservation struct {
Attempted bool `json:"attempted"`
OK bool `json:"ok"`
PeerAddr string `json:"peer_addr,omitempty"`
Error string `json:"error,omitempty"`
}
// EndpointProbe holds the raw, unjudged observation for a single endpoint.
type EndpointProbe struct {
Endpoint Endpoint `json:"endpoint"`
// ResolvedIPs lists the A/AAAA addresses we observed for this host
// (populated when the endpoint was reached). Used for IPv6 coverage.
ResolvedIPs []string `json:"resolved_ips,omitempty"`
Dial DialResult `json:"dial"`
STUNBinding STUNBindingObservation `json:"stun_binding"`
TURNNoAuth TURNAllocateObservation `json:"turn_noauth"`
TURNAuth TURNAllocateObservation `json:"turn_auth"`
RelayEcho RelayEchoObservation `json:"relay_echo"`
ChannelBindRun bool `json:"channel_bind_run,omitempty"`
}
// StunTurnData is the JSON-serializable observation payload. It now
// carries only raw per-endpoint probe outcomes; rules do the judging.
type StunTurnData struct {
Zone string `json:"zone,omitempty"`
Mode string `json:"mode,omitempty"`
RequestedURI string `json:"requested_uri,omitempty"`
HasCreds bool `json:"has_creds,omitempty"`
ProbePeer string `json:"probe_peer,omitempty"`
WarningRTTMs int64 `json:"warning_rtt_ms,omitempty"`
CriticalRTT int64 `json:"critical_rtt_ms,omitempty"`
CollectedAt time.Time `json:"collected_at"`
Endpoints []EndpointProbe `json:"endpoints"`
GlobalError string `json:"global_error,omitempty"`
}

20
go.mod Normal file
View file

@ -0,0 +1,20 @@
module git.happydns.org/checker-stun-turn
go 1.25.0
require (
git.happydns.org/checker-sdk-go v1.5.0
git.happydns.org/checker-tls v0.6.2
github.com/pion/dtls/v3 v3.0.4
github.com/pion/stun/v3 v3.0.0
github.com/pion/turn/v4 v4.0.0
)
require (
github.com/pion/logging v0.2.2 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/wlynxg/anet v0.0.3 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
)

32
go.sum Normal file
View file

@ -0,0 +1,32 @@
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

26
main.go Normal file
View file

@ -0,0 +1,26 @@
package main
import (
"flag"
"log"
"git.happydns.org/checker-sdk-go/checker/server"
stunturn "git.happydns.org/checker-stun-turn/checker"
)
// Version is the standalone binary's version, overridden via
// `go build -ldflags "-X main.Version=..."`.
var Version = "custom-build"
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
func main() {
flag.Parse()
stunturn.Version = Version
srv := server.New(stunturn.Provider())
if err := srv.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

19
plugin/plugin.go Normal file
View file

@ -0,0 +1,19 @@
// Command plugin is the happyDomain plugin entrypoint for the STUN/TURN checker.
//
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
// runtime by happyDomain.
package main
import (
sdk "git.happydns.org/checker-sdk-go/checker"
stunturn "git.happydns.org/checker-stun-turn/checker"
)
var Version = "custom-build"
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the .so.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
stunturn.Version = Version
prvd := stunturn.Provider()
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
}