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 7ff9f92305
23 changed files with 1902 additions and 0 deletions

2
.gitignore vendored Normal file
View file

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

14
Dockerfile Normal file
View file

@ -0,0 +1,14 @@
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 -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-stun-turn .
FROM scratch
COPY --from=builder /checker-stun-turn /checker-stun-turn
EXPOSE 8080
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 -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/
test:
go test ./...
docker:
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
clean:
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so

26
NOTICE Normal file
View file

@ -0,0 +1,26 @@
checker-matrix
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

91
README.md Normal file
View file

@ -0,0 +1,91 @@
# 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) —
no out-of-tree testsuite to host.
## 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 :8082
```
Trigger a check:
```
curl -sX POST localhost:8082/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).

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
}

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.0.0
git.happydns.org/checker-tls v0.1.0
github.com/pion/dtls/v3 v3.0.4
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/stun/v3 v3.0.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
)

28
go.sum Normal file
View file

@ -0,0 +1,28 @@
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"
sdk "git.happydns.org/checker-sdk-go/checker"
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
server := sdk.NewServer(stunturn.Provider())
if err := server.ListenAndServe(*listenAddr); err != nil {
log.Fatalf("server error: %v", err)
}
}

18
plugin/plugin.go Normal file
View file

@ -0,0 +1,18 @@
// 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
return stunturn.Definition(), stunturn.Provider(), nil
}