commit 7ff9f92305285598701f944d5b0452bd17d2b191 Author: Pierre-Olivier Mercier Date: Sun Apr 19 13:41:52 2026 +0700 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a224e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +checker-stun-turn +*.so diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..123be32 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..07d44d8 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4f4f460 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..8dab13f --- /dev/null +++ b/NOTICE @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ee48841 --- /dev/null +++ b/README.md @@ -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:` | 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). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..f6a0d6b --- /dev/null +++ b/checker/collect.go @@ -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=` (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=` (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." +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..5b1c9a5 --- /dev/null +++ b/checker/definition.go @@ -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, + }, + } +} diff --git a/checker/discover.go b/checker/discover.go new file mode 100644 index 0000000..03b8b58 --- /dev/null +++ b/checker/discover.go @@ -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 +} diff --git a/checker/discover_test.go b/checker/discover_test.go new file mode 100644 index 0000000..5333d2d --- /dev/null +++ b/checker/discover_test.go @@ -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) + } +} diff --git a/checker/discovery.go b/checker/discovery.go new file mode 100644 index 0000000..9756d18 --- /dev/null +++ b/checker/discovery.go @@ -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 +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..163fba7 --- /dev/null +++ b/checker/provider.go @@ -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() +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..c4f1993 --- /dev/null +++ b/checker/report.go @@ -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(` + + + +STUN/TURN Report + + + + +
+

STUN/TURN check

+ {{.OverallText}} +
+ {{if .Zone}}Zone: {{.Zone}} · {{end}} + Mode: {{.Mode}} · + {{len .Endpoints}} endpoint(s) probed +
+ {{if .HeadlineFix}} +
+ How to fix: {{.HeadlineFix}} + {{if .HeadlineDetail}}
{{.HeadlineDetail}}
{{end}} +
+ {{end}} +
+ +{{if .GlobalError}} +
Discovery failed: {{.GlobalError}}
+{{end}} + +{{range .Endpoints}} + + + {{.URI}} + {{.BadgeText}} + +
+

Transport: {{.Transport}} · Source: {{.Source}}

+ + + {{range .SubTests}} + + + + + + + {{end}} +
TestStatusDurationDetail
{{.Name}}{{.StatusText}}{{if .DurationMs}}{{.DurationMs}} ms{{end}} + {{if .Detail}}{{.Detail}}{{end}} + {{if .Error}}
⚠ {{.Error}}
{{end}} + {{if .Fix}}
Fix: {{.Fix}}
{{end}} +
+
+ +{{end}} + + +`)) + +// 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) +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..2984689 --- /dev/null +++ b/checker/rule.go @@ -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} +} diff --git a/checker/stun.go b/checker/stun.go new file mode 100644 index 0000000..f0d376a --- /dev/null +++ b/checker/stun.go @@ -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() +} diff --git a/checker/tlsmeta.go b/checker/tlsmeta.go new file mode 100644 index 0000000..bcfebcd --- /dev/null +++ b/checker/tlsmeta.go @@ -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 +} diff --git a/checker/transport.go b/checker/transport.go new file mode 100644 index 0000000..5400830 --- /dev/null +++ b/checker/transport.go @@ -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") + } +} diff --git a/checker/turn.go b/checker/turn.go new file mode 100644 index 0000000..4e22ac5 --- /dev/null +++ b/checker/turn.go @@ -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 = ":" +// 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 : )" +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 +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..a75c341 --- /dev/null +++ b/checker/types.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4380ab9 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2b14518 --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..810259d --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..3beeba3 --- /dev/null +++ b/plugin/plugin.go @@ -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 +}