commit 7c7706fe3fc18a409a91b4acfc75a412b882443d 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. 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..1057796 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder + +ARG CHECKER_VERSION=custom-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-stun-turn . + +FROM scratch +COPY --from=builder /checker-stun-turn /checker-stun-turn +USER 65534:65534 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/checker-stun-turn", "-healthcheck"] +ENTRYPOINT ["/checker-stun-turn"] 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..c3db3aa --- /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 -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ . + +plugin: $(CHECKER_NAME).so + +$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go) + go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/ + +docker: + docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) . + +test: + go test -tags standalone ./... + +clean: + rm -f $(CHECKER_NAME) $(CHECKER_NAME).so diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..b115512 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +checker-stun-turn +Copyright (c) 2026 The happyDomain Authors + +This product is licensed under the MIT License (see LICENSE). + +------------------------------------------------------------------------------- +Third-party notices +------------------------------------------------------------------------------- + +This product includes software developed as part of the checker-sdk-go +project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed +under the Apache License, Version 2.0: + + checker-sdk-go + Copyright 2020-2026 The happyDomain Authors + + This product includes software developed as part of the happyDomain + project (https://happydomain.org). + + Portions of this code were originally written for the happyDomain + server (licensed under AGPL-3.0 and a commercial license) and are + made available there under the Apache License, Version 2.0 to enable + a permissively licensed ecosystem of checker plugins. + +You may obtain a copy of the Apache License 2.0 at: + http://www.apache.org/licenses/LICENSE-2.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f0f15af --- /dev/null +++ b/README.md @@ -0,0 +1,90 @@ +# checker-stun-turn + +happyDomain checker that probes **STUN** and **TURN** servers end-to-end: +DNS / SRV discovery, TCP/UDP reachability, TLS / DTLS handshake, STUN binding, +open-relay check, authenticated TURN Allocate (long-term creds *or* +REST API shared secret), relay address sanity, and a `CreatePermission + Send` +round-trip through the relay. + +Backed by [`github.com/pion/stun`](https://github.com/pion/stun) + +[`github.com/pion/turn`](https://github.com/pion/turn). + +## Tests performed per endpoint + +| Test | What it proves | +|-----------------------------|----------------| +| `dial:` | DNS resolves, port is reachable, TLS/DTLS handshake succeeds. | +| `tls` / `dtls` | Records TLS version + cipher + peer cert CN. | +| `stun_binding` | Server answers RFC 5389 Binding Request; measures RTT. | +| `stun_reflexive_public` | Server returned a *public* XOR-MAPPED address (not RFC1918). | +| `turn_open_relay_check` | Unauthenticated Allocate is rejected with 401 + REALM/NONCE. | +| `turn_allocate_auth` | Authenticated Allocate succeeds; relay address returned. | +| `turn_relay_public` | Relay address is publicly routable. | +| `turn_relay_echo` | CreatePermission + Send to the probe peer succeed. | +| `turn_channel_bind` | (optional) ChannelBind exercised via the relay conn. | + +## Most common failures surfaced with a fix + +Each failing sub-test carries a `Fix` field that is rendered prominently in the +HTML report (yellow callout at the top of the card *and* inline with each row). +Mapping: + +- UDP/TCP dial timeouts → firewall/NAT guidance +- TLS handshake errors → certificate reissue guidance (coturn `cert=`/`pkey=`) +- STUN binding returns RFC1918 → `external-ip=` for coturn +- Unauthenticated Allocate accepted → enable `lt-cred-mech`, close the open relay +- Allocate 401 loop → check NTP (TURN nonces are time-sensitive) +- Allocate 441 → wrong username/password or wrong REST shared secret +- Allocate 442 → try different transport or enable it server-side +- Allocate 486/508 → quota / port-range issues on the server +- Relay address is private → set `relay-ip=` to a public IP +- Relay echo fails → `min-port`/`max-port` range not publicly reachable + +## Usage + +Build and run: + +``` +make # standalone binary +make plugin # .so plugin for happyDomain +./checker-stun-turn -listen :8080 +``` + +Trigger a check: + +``` +curl -sX POST localhost:8080/collect -H 'content-type: application/json' -d '{ + "options": { + "zone": "example.com", + "serverURI": "turns:turn.example.com:5349?transport=tcp", + "mode": "turn", + "username": "alice", + "credential": "s3cret", + "transports": "udp,tcp,tls", + "probePeer": "1.1.1.1:53", + "timeout": 5 + } +}' | jq . +``` + +## Options + +| Scope | Option | Type | Default | Notes | +|---------|-------------------|--------|---------------|-------| +| run | `zone` | string | (auto-filled) | used for `_stun._udp` / `_turn._udp` / `_turns._tcp` SRV discovery | +| run | `serverURI` | string | | explicit URI, RFC 7064/7065 | +| run | `mode` | choice | `auto` | `stun`, `turn`, `auto` | +| user | `username` | string | | long-term credentials | +| user | `credential` | secret | | long-term credentials | +| user | `sharedSecret` | secret | | REST-API auth (draft-uberti), takes precedence | +| user | `realm` | string | | optional explicit realm | +| user | `transports` | string | `udp,tcp,tls` | comma-separated among `udp,tcp,tls,dtls` | +| user | `probePeer` | string | `1.1.1.1:53` | target for the relay echo test | +| user | `testChannelBind` | bool | `false` | | +| user | `warningRTT` | uint | `200` ms | | +| user | `criticalRTT` | uint | `1000` ms | | +| user | `timeout` | uint | `5` s | per-probe | + +## License + +MIT (see LICENSE). diff --git a/checker/collect.go b/checker/collect.go new file mode 100644 index 0000000..31b919b --- /dev/null +++ b/checker/collect.go @@ -0,0 +1,235 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type probeConfig struct { + mode string + username string + password string + sharedSecret string + realm string + probePeer string + testChannelBind bool + timeout time.Duration +} + +// Collect gathers raw STUN/TURN observations (SRV discovery, dial +// outcome, STUN Binding result, TURN Allocate results, relay echo). +// It performs NO judgement: severity, pass/fail, warning thresholds +// and fix suggestions are left to the CheckRule layer. +func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) { + zone, _ := opts["zone"].(string) + uri, _ := opts["serverURI"].(string) + mode, _ := opts["mode"].(string) + if mode == "" { + mode = "auto" + } + username, _ := opts["username"].(string) + password, _ := opts["credential"].(string) + sharedSecret, _ := opts["sharedSecret"].(string) + realm, _ := opts["realm"].(string) + transportsRaw, _ := opts["transports"].(string) + probePeer, _ := opts["probePeer"].(string) + if probePeer == "" { + probePeer = "1.1.1.1:53" + } + // Refuse to relay traffic toward private/loopback/link-local + // destinations: a malicious caller could otherwise abuse the target + // TURN server to port-scan the operator's internal network through us. + if isPrivateAddr(probePeer) { + return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer) + } + timeoutSec := sdk.GetIntOption(opts, "timeout", 5) + if timeoutSec <= 0 { + timeoutSec = 5 + } + + cfg := probeConfig{ + mode: mode, + username: username, + password: password, + sharedSecret: sharedSecret, + realm: realm, + probePeer: probePeer, + testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false), + timeout: time.Duration(timeoutSec) * time.Second, + } + + transports := parseTransports(transportsRaw) + + warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200)) + critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000)) + + data := &StunTurnData{ + Zone: zone, + Mode: mode, + RequestedURI: uri, + ProbePeer: probePeer, + WarningRTTMs: warnRTT, + CriticalRTT: critRTT, + HasCreds: sharedSecret != "" || (username != "" && password != ""), + CollectedAt: time.Now().UTC(), + } + + endpoints, err := discoverEndpoints(ctx, zone, uri, transports) + if err != nil { + data.GlobalError = err.Error() + return data, nil + } + + for _, ep := range endpoints { + probe := EndpointProbe{Endpoint: ep} + collectEndpoint(ctx, &probe, cfg) + data.Endpoints = append(data.Endpoints, probe) + } + return data, nil +} + +// collectEndpoint runs every network probe we know how to run against +// a single endpoint and records raw results on probe. It never assigns +// severity. +func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) { + ep := probe.Endpoint + + // Best-effort DNS lookup for IPv6 coverage rule. + if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil { + for _, ip := range ips { + probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String()) + } + } + + dialStart := time.Now() + dc, err := dial(ctx, ep, cfg.timeout) + dialDur := time.Since(dialStart) + if err != nil { + probe.Dial = DialResult{ + OK: false, + DurationMs: dialDur.Milliseconds(), + Error: err.Error(), + } + return + } + defer dc.Close() + probe.Dial = DialResult{ + OK: true, + DurationMs: dialDur.Milliseconds(), + RemoteAddr: dc.remoteAddr.String(), + } + if dc.tlsState != nil { + probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version) + probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite) + probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState) + } + if dc.dtlsState != nil { + probe.Dial.DTLSHandshake = true + } + + // STUN Binding probe, always attempted. + bind := runSTUNBinding(dc, cfg.timeout) + probe.STUNBinding.Attempted = true + if bind.Err != nil { + probe.STUNBinding.OK = false + probe.STUNBinding.Error = bind.Err.Error() + } else { + probe.STUNBinding.OK = true + probe.STUNBinding.RTTMs = bind.RTT.Milliseconds() + if bind.ReflexiveAddr != nil { + probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String() + } + probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped + } + + // TURN-only probes short-circuit when mode=stun or the scheme is + // stun:/stuns:. + if cfg.mode == "stun" || !ep.IsTURN { + return + } + + // Unauthenticated TURN Allocate (open-relay probe). + noAuth := runTURNAllocate(dc, nil, cfg.timeout) + probe.TURNNoAuth.Attempted = true + probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds() + probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode + probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason + probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge + if noAuth.RelayConn != nil { + probe.TURNNoAuth.OK = true + if noAuth.RelayAddr != nil { + probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String() + } + _ = noAuth.RelayConn.Close() + if noAuth.Client != nil { + noAuth.Client.Close() + } + } else if noAuth.Err != nil && !noAuth.UnauthChallenge { + probe.TURNNoAuth.Error = noAuth.Err.Error() + } + + // Authenticated TURN Allocate, if credentials are provided. + creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm) + if creds == nil { + return + } + + dc2, err := dial(ctx, ep, cfg.timeout) + if err != nil { + probe.TURNAuth.Attempted = true + probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err) + return + } + defer dc2.Close() + + auth := runTURNAllocate(dc2, creds, cfg.timeout) + probe.TURNAuth.Attempted = true + probe.TURNAuth.DurationMs = auth.Duration.Milliseconds() + probe.TURNAuth.ErrorCode = auth.AuthErrorCode + probe.TURNAuth.ErrorReason = auth.AuthErrorReason + if auth.Err != nil { + probe.TURNAuth.OK = false + probe.TURNAuth.Error = auth.Err.Error() + return + } + probe.TURNAuth.OK = true + if auth.RelayAddr != nil { + probe.TURNAuth.RelayAddr = auth.RelayAddr.String() + } + probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay + defer func() { + auth.RelayConn.Close() + if auth.Client != nil { + auth.Client.Close() + } + }() + + // Relay echo. + probe.RelayEcho.Attempted = true + probe.RelayEcho.PeerAddr = cfg.probePeer + if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil { + probe.RelayEcho.OK = false + probe.RelayEcho.Error = err.Error() + } else { + probe.RelayEcho.OK = true + } + + if cfg.testChannelBind { + probe.ChannelBindRun = true + } +} + +func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials { + if sharedSecret != "" { + return restAPICredentials(sharedSecret, username, realm, time.Hour) + } + if username != "" && password != "" { + return &turnCredentials{Username: username, Password: password, Realm: realm} + } + return nil +} diff --git a/checker/definition.go b/checker/definition.go new file mode 100644 index 0000000..fa6b6e1 --- /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 (p *stunTurnProvider) Definition() *sdk.CheckerDefinition { + return &sdk.CheckerDefinition{ + ID: "stunturn", + Name: "STUN/TURN Server", + Version: Version, + HasHTMLReport: true, + ObservationKeys: []sdk.ObservationKey{ObservationKeyStunTurn}, + Availability: sdk.CheckerAvailability{ + ApplyToZone: true, + ApplyToService: true, + }, + Options: sdk.CheckerOptionsDocumentation{ + RunOpts: []sdk.CheckerOptionDocumentation{ + { + Id: "zone", + Type: "string", + Label: "Zone", + Placeholder: "example.com", + AutoFill: sdk.AutoFillDomainName, + Description: "Zone used for SRV-based STUN/TURN endpoint discovery when no explicit URI is provided.", + }, + { + Id: "serverURI", + Type: "string", + Label: "Server URI", + Placeholder: "turns:turn.example.com:5349?transport=tcp", + Description: "Explicit STUN/TURN URI (RFC 7064/7065). Overrides SRV-based discovery.", + }, + { + Id: "mode", + Type: "string", + Label: "Mode", + Default: "auto", + Choices: []string{"auto", "stun", "turn"}, + Description: "auto: probe both STUN and TURN; stun: skip TURN allocation tests; turn: require TURN allocation.", + }, + }, + UserOpts: []sdk.CheckerOptionDocumentation{ + {Id: "username", Type: "string", Label: "TURN username"}, + {Id: "credential", Type: "string", Label: "TURN password", Secret: true}, + { + Id: "sharedSecret", + Type: "string", + Label: "REST API shared secret", + Secret: true, + Description: "Shared secret used to derive ephemeral credentials (draft-uberti-rtcweb-turn-rest). Takes precedence over username/password.", + }, + {Id: "realm", Type: "string", Label: "Realm"}, + { + Id: "transports", + Type: "string", + Label: "Transports", + Default: "udp,tcp,tls", + Description: "Comma-separated list of transports to test among: udp, tcp, tls, dtls.", + }, + { + Id: "probePeer", + Type: "string", + Label: "Relay echo target", + Default: "1.1.1.1:53", + Description: "host:port used to validate the relay path (a CreatePermission + Send is issued, no payload data is exchanged).", + }, + { + Id: "testChannelBind", + Type: "bool", + Label: "Also test ChannelBind", + Default: false, + }, + {Id: "warningRTT", Type: "uint", Label: "RTT warning threshold (ms)", Default: 200}, + {Id: "criticalRTT", Type: "uint", Label: "RTT critical threshold (ms)", Default: 1000}, + {Id: "timeout", Type: "uint", Label: "Per-probe timeout (s)", Default: 5}, + }, + }, + Rules: Rules(), + Interval: &sdk.CheckIntervalSpec{ + Min: 5 * time.Minute, + Default: 30 * time.Minute, + Max: 24 * time.Hour, + }, + } +} 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..a7d690d --- /dev/null +++ b/checker/discovery.go @@ -0,0 +1,51 @@ +package checker + +import ( + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" + tlsct "git.happydns.org/checker-tls/contract" +) + +// DiscoverEntries implements sdk.DiscoveryPublisher. +// +// stuns:/turns: (RFC 7064/7065) speak TLS immediately after the TCP +// handshake, so every secure TCP-based endpoint we observed is published +// under the tls.endpoint.v1 contract for checker-tls to pick up. +// +// DTLS is intentionally omitted: the current checker-tls consumer uses +// crypto/tls and would not probe a datagram-TLS endpoint correctly. Emitting +// a DTLS entry today would only produce orphan lineage. +// +// SNI is left empty (= Host); no STARTTLS upgrade applies; the scheme +// mandates direct TLS on the wire. +func (p *stunTurnProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) { + d, ok := data.(*StunTurnData) + if !ok { + return nil, fmt.Errorf("unexpected data type %T", data) + } + seen := make(map[string]struct{}) + var out []sdk.DiscoveryEntry + for _, ep := range d.Endpoints { + if !ep.Dial.OK { + continue + } + if !ep.Endpoint.Secure || ep.Endpoint.Transport == TransportDTLS { + continue + } + key := fmt.Sprintf("%s|%d", ep.Endpoint.Host, ep.Endpoint.Port) + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{ + Host: ep.Endpoint.Host, + Port: ep.Endpoint.Port, + }) + if err != nil { + return nil, err + } + out = append(out, entry) + } + return out, nil +} diff --git a/checker/interactive.go b/checker/interactive.go new file mode 100644 index 0000000..f4f324c --- /dev/null +++ b/checker/interactive.go @@ -0,0 +1,77 @@ +//go:build standalone + +package checker + +import ( + "errors" + "net/http" + "strconv" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// RenderForm exposes the inputs needed to drive a STUN/TURN check from +// the standalone HTML form. The fields mirror the canonical option +// documentation in Definition() (RunOpts then UserOpts) so the form +// stays in lock-step with the JSON schema served at /definition. +func (p *stunTurnProvider) RenderForm() []sdk.CheckerOptionField { + def := p.Definition() + fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.UserOpts)) + fields = append(fields, def.Options.RunOpts...) + fields = append(fields, def.Options.UserOpts...) + return fields +} + +// ParseForm turns the submitted form into a CheckerOptions. At least one +// of zone or serverURI must be provided so discoverEndpoints has +// something to work with. +func (p *stunTurnProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) { + zone := strings.TrimSpace(r.FormValue("zone")) + zone = strings.TrimSuffix(zone, ".") + uri := strings.TrimSpace(r.FormValue("serverURI")) + if zone == "" && uri == "" { + return nil, errors.New("either zone or serverURI is required") + } + + opts := sdk.CheckerOptions{} + if zone != "" { + opts["zone"] = zone + } + if uri != "" { + opts["serverURI"] = uri + } + + if v := strings.TrimSpace(r.FormValue("mode")); v != "" { + switch v { + case "auto", "stun", "turn": + opts["mode"] = v + default: + return nil, errors.New("mode must be one of: auto, stun, turn") + } + } + + for _, k := range []string{"username", "credential", "sharedSecret", "realm", "transports", "probePeer"} { + if v := strings.TrimSpace(r.FormValue(k)); v != "" { + opts[k] = v + } + } + + if v := r.FormValue("testChannelBind"); v != "" { + opts["testChannelBind"] = v == "true" || v == "on" || v == "1" + } + + for _, k := range []string{"warningRTT", "criticalRTT", "timeout"} { + v := strings.TrimSpace(r.FormValue(k)) + if v == "" { + continue + } + n, err := strconv.ParseUint(v, 10, 32) + if err != nil { + return nil, errors.New(k + " must be a non-negative integer") + } + opts[k] = int(n) + } + + return opts, nil +} diff --git a/checker/provider.go b/checker/provider.go new file mode 100644 index 0000000..68595c2 --- /dev/null +++ b/checker/provider.go @@ -0,0 +1,16 @@ +package checker + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Provider returns a new STUN/TURN observation provider. +func Provider() sdk.ObservationProvider { + return &stunTurnProvider{} +} + +type stunTurnProvider struct{} + +func (p *stunTurnProvider) Key() sdk.ObservationKey { + return ObservationKeyStunTurn +} diff --git a/checker/report.go b/checker/report.go new file mode 100644 index 0000000..546c487 --- /dev/null +++ b/checker/report.go @@ -0,0 +1,400 @@ +package checker + +import ( + "encoding/json" + "fmt" + "html/template" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +type tmplHint struct { + StatusCSS string + StatusText string + Message string + Fix string +} + +type tmplObservation struct { + Name string + Status string // "ok" | "fail" | "info" (for neutral data badge) + Detail string + Error string +} + +type tmplEndpoint struct { + URI string + Transport string + Source string + Open bool + BadgeText string + BadgeCSS string + ResolvedIPs []string + Observations []tmplObservation + Hints []tmplHint +} + +type tmplData struct { + Zone string + Mode string + OverallText string + OverallCSS string + HeadlineFix string + HeadlineDetail string + GlobalError string + GlobalHints []tmplHint // hints with no endpoint subject + Endpoints []tmplEndpoint +} + +var stunturnTemplate = template.Must(template.New("stunturn").Parse(` + + + +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}} + +{{if .GlobalHints}} +
+

Global findings

+ {{range .GlobalHints}} +
+ {{.StatusText}} + {{.Message}} + {{if .Fix}}
Fix: {{.Fix}}
{{end}} +
+ {{end}} +
+{{end}} + +{{range .Endpoints}} + + + {{.URI}} + {{.BadgeText}} + +
+

Transport: {{.Transport}} · Source: {{.Source}} + {{if .ResolvedIPs}} · Resolved: {{range $i, $ip := .ResolvedIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}{{end}} +

+ + {{if .Hints}} +
Findings
+ {{range .Hints}} +
+ {{.StatusText}} + {{.Message}} + {{if .Fix}}
Fix: {{.Fix}}
{{end}} +
+ {{end}} + {{end}} + + {{if .Observations}} +
Observations
+ + + {{range .Observations}} + + + + + + {{end}} +
ProbeResultDetail
{{.Name}}{{.Status}} + {{if .Detail}}{{.Detail}}{{end}} + {{if .Error}}
⚠ {{.Error}}
{{end}} +
+ {{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) + } + + states := ctx.States() + + // Group hints by endpoint subject. + type group struct { + worst sdk.Status + hints []tmplHint + first *tmplHint // first failing hint (for headline/open logic) + } + byEp := make(map[string]*group) + global := &group{worst: sdk.StatusOK} + + for _, st := range states { + fix := "" + if st.Meta != nil { + if v, ok := st.Meta["fix"].(string); ok { + fix = v + } + } + css, txt := statusBadge(st.Status) + h := tmplHint{ + StatusCSS: css, + StatusText: txt, + Message: st.Message, + Fix: fix, + } + var g *group + if st.Subject == "" { + g = global + } else { + g = byEp[st.Subject] + if g == nil { + g = &group{worst: sdk.StatusOK} + byEp[st.Subject] = g + } + } + g.hints = append(g.hints, h) + if statusSeverity(st.Status) > statusSeverity(g.worst) { + g.worst = st.Status + } + if g.first == nil && isFailing(st.Status) { + hh := h + g.first = &hh + } + } + + td := tmplData{ + Zone: d.Zone, + Mode: d.Mode, + GlobalError: d.GlobalError, + GlobalHints: global.hints, + } + + worst := sdk.StatusOK + if statusSeverity(global.worst) > statusSeverity(worst) { + worst = global.worst + } + + var headlineFix, headlineDetail string + if global.first != nil && global.first.Fix != "" { + headlineFix = global.first.Fix + headlineDetail = global.first.Message + } + + for _, ep := range d.Endpoints { + subj := epSubject(ep.Endpoint) + g := byEp[subj] + te := tmplEndpoint{ + URI: ep.Endpoint.URI, + Transport: string(ep.Endpoint.Transport), + Source: ep.Endpoint.Source, + ResolvedIPs: ep.ResolvedIPs, + Observations: observationsFor(ep), + } + epWorst := sdk.StatusOK + if g != nil { + te.Hints = g.hints + epWorst = g.worst + if statusSeverity(g.worst) > statusSeverity(worst) { + worst = g.worst + } + if headlineFix == "" && g.first != nil && g.first.Fix != "" { + headlineFix = g.first.Fix + headlineDetail = fmt.Sprintf("[%s] %s", ep.Endpoint.URI, g.first.Message) + } + } + te.BadgeCSS, te.BadgeText = statusBadge(epWorst) + te.Open = isFailing(epWorst) + td.Endpoints = append(td.Endpoints, te) + } + + td.OverallCSS, td.OverallText = statusBadge(worst) + td.HeadlineFix = headlineFix + td.HeadlineDetail = headlineDetail + + var buf strings.Builder + if err := stunturnTemplate.Execute(&buf, td); err != nil { + return "", fmt.Errorf("render stun/turn report: %w", err) + } + return buf.String(), nil +} + +// observationsFor renders the raw probe observations for an endpoint, +// without any severity or fix guidance. This is the neutral, data-only +// view used both as the default and as a fallback when no CheckStates +// are available. +func observationsFor(ep EndpointProbe) []tmplObservation { + var out []tmplObservation + + // Dial + dialName := fmt.Sprintf("dial:%s", ep.Endpoint.Transport) + if ep.Dial.OK { + detail := fmt.Sprintf("connected to %s in %d ms", ep.Dial.RemoteAddr, ep.Dial.DurationMs) + if ep.Dial.TLSVersion != "" { + detail += fmt.Sprintf("; TLS %s, %s, peer CN=%s", ep.Dial.TLSVersion, ep.Dial.TLSCipher, ep.Dial.TLSPeerCN) + } + if ep.Dial.DTLSHandshake { + detail += "; DTLS handshake completed" + } + out = append(out, tmplObservation{Name: dialName, Status: "ok", Detail: detail}) + } else { + out = append(out, tmplObservation{Name: dialName, Status: "fail", Error: ep.Dial.Error}) + return out + } + + // STUN binding + if ep.STUNBinding.Attempted { + if ep.STUNBinding.OK { + detail := fmt.Sprintf("reflexive address: %s (RTT %d ms)", ep.STUNBinding.ReflexiveAddr, ep.STUNBinding.RTTMs) + if ep.STUNBinding.IsPrivateMapped { + detail += " [private]" + } + out = append(out, tmplObservation{Name: "stun_binding", Status: "ok", Detail: detail}) + } else { + out = append(out, tmplObservation{Name: "stun_binding", Status: "fail", Error: ep.STUNBinding.Error}) + } + } + + // TURN no-auth probe + if ep.TURNNoAuth.Attempted { + switch { + case ep.TURNNoAuth.OK: + out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "allocation accepted without authentication"}) + case ep.TURNNoAuth.UnauthChallenge: + out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "server challenged the unauthenticated allocate (401)"}) + default: + out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "info", Detail: fmt.Sprintf("code=%d %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason)}) + } + } + + // TURN authenticated allocate + if ep.TURNAuth.Attempted { + if ep.TURNAuth.OK { + detail := fmt.Sprintf("relay address: %s (%d ms)", ep.TURNAuth.RelayAddr, ep.TURNAuth.DurationMs) + if ep.TURNAuth.IsPrivateRelay { + detail += " [private]" + } + out = append(out, tmplObservation{Name: "turn_allocate_auth", Status: "ok", Detail: detail}) + } else { + out = append(out, tmplObservation{ + Name: "turn_allocate_auth", + Status: "fail", + Detail: fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode), + Error: ep.TURNAuth.Error, + }) + } + } + + // Relay echo + if ep.RelayEcho.Attempted { + if ep.RelayEcho.OK { + out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "ok", Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", ep.RelayEcho.PeerAddr)}) + } else { + out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "fail", Error: ep.RelayEcho.Error}) + } + } + + if ep.ChannelBindRun { + out = append(out, tmplObservation{Name: "turn_channel_bind", Status: "info", Detail: "ChannelBind exercised implicitly by relay traffic"}) + } + + return out +} + +func statusBadge(s sdk.Status) (cssClass, label string) { + switch s { + case sdk.StatusOK: + return "ok", "OK" + case sdk.StatusInfo: + return "info", "INFO" + case sdk.StatusWarn: + return "warn", "WARN" + case sdk.StatusCrit: + return "crit", "CRIT" + case sdk.StatusError: + return "error", "ERROR" + case sdk.StatusUnknown: + return "unknown", "UNKNOWN" + } + return "info", s.String() +} + +func statusSeverity(s sdk.Status) int { + switch s { + case sdk.StatusOK: + return 0 + case sdk.StatusUnknown: + return 1 + case sdk.StatusInfo: + return 2 + case sdk.StatusWarn: + return 3 + case sdk.StatusCrit: + return 4 + case sdk.StatusError: + return 5 + } + return -1 +} + +func isFailing(s sdk.Status) bool { + return s == sdk.StatusWarn || s == sdk.StatusCrit || s == sdk.StatusError +} diff --git a/checker/rule.go b/checker/rule.go new file mode 100644 index 0000000..ec28b0e --- /dev/null +++ b/checker/rule.go @@ -0,0 +1,83 @@ +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// Rules returns the list of CheckRules exposed by the STUN/TURN checker. +// Each concern is its own rule (SRV for STUN, SRV for TURN, STUN +// binding, TURN open-relay probe, TURN authenticated allocation, relay +// echo, TLS transport, IPv6 coverage, …) so the UI can show a granular +// status instead of a single aggregated one. +func Rules() []sdk.CheckRule { + return []sdk.CheckRule{ + &discoveryRule{}, + &srvStunRule{}, + &srvTurnRule{}, + &dialRule{}, + &stunBindingRule{}, + &stunReflexivePublicRule{}, + &stunLatencyRule{}, + &turnOpenRelayRule{}, + &turnAuthRule{}, + &turnRelayPublicRule{}, + &turnRelayEchoRule{}, + &turnTLSTransportRule{}, + &ipv6CoverageRule{}, + } +} + +// loadData fetches the observation; on error returns a CheckState that +// callers should emit directly. +func loadData(ctx context.Context, obs sdk.ObservationGetter) (*StunTurnData, *sdk.CheckState) { + var data StunTurnData + if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil { + return nil, &sdk.CheckState{ + Status: sdk.StatusError, + Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err), + Code: "stun_turn.observation_error", + } + } + return &data, nil +} + +func passState(code, msg string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusOK, Message: msg, Code: code} +} + +func skippedState(code, msg string) sdk.CheckState { + return sdk.CheckState{Status: sdk.StatusUnknown, Message: msg, Code: code} +} + +func epSubject(ep Endpoint) string { + if ep.URI != "" { + return ep.URI + } + return fmt.Sprintf("%s:%d/%s", ep.Host, ep.Port, ep.Transport) +} + +// hasTURNEndpoint reports whether the observation contains at least one +// TURN endpoint (excluding STUN-only endpoints). +func hasTURNEndpoint(data *StunTurnData) bool { + for _, ep := range data.Endpoints { + if ep.Endpoint.IsTURN { + return true + } + } + return false +} + +// joinMsg concatenates non-empty parts with ": " between them. +func joinMsg(parts ...string) string { + out := make([]string, 0, len(parts)) + for _, p := range parts { + if strings.TrimSpace(p) != "" { + out = append(out, p) + } + } + return strings.Join(out, ": ") +} diff --git a/checker/rules_discovery.go b/checker/rules_discovery.go new file mode 100644 index 0000000..bf6e6e4 --- /dev/null +++ b/checker/rules_discovery.go @@ -0,0 +1,186 @@ +package checker + +import ( + "context" + "fmt" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// discoveryRule reports the outcome of endpoint discovery (URI parse +// or SRV lookup). If Collect recorded a GlobalError, this is where it +// surfaces. +type discoveryRule struct{} + +func (r *discoveryRule) Name() string { return "stun_turn.discovery" } +func (r *discoveryRule) Description() string { + return "Verifies that at least one STUN/TURN endpoint could be discovered (explicit URI or SRV lookup)." +} + +func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.GlobalError != "" { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: data.GlobalError, + Code: "stun_turn.discovery.error", + }} + } + if len(data.Endpoints) == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusError, + Message: "no endpoints to probe", + Code: "stun_turn.discovery.empty", + }} + } + return []sdk.CheckState{passState("stun_turn.discovery.ok", + fmt.Sprintf("%d endpoint(s) discovered", len(data.Endpoints)))} +} + +// srvStunRule verifies that at least one STUN (non-TURN) SRV/URI endpoint +// was obtained. Only meaningful in SRV-discovery mode. +type srvStunRule struct{} + +func (r *srvStunRule) Name() string { return "stun_turn.srv_stun" } +func (r *srvStunRule) Description() string { + return "Verifies that at least one STUN endpoint is reachable via SRV (_stun/_stuns) or an explicit URI." +} + +func (r *srvStunRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.GlobalError != "" { + return []sdk.CheckState{skippedState("stun_turn.srv_stun.skipped", + "Discovery failed, SRV coverage could not be evaluated.")} + } + // Count endpoints whose source indicates STUN SRV (or URI form). + var stunCount, turnCount int + for _, ep := range data.Endpoints { + if ep.Endpoint.IsTURN { + turnCount++ + } else { + stunCount++ + } + } + if stunCount == 0 && turnCount > 0 { + return []sdk.CheckState{{ + Status: sdk.StatusInfo, + Code: "stun_turn.srv_stun.none", + Message: "No STUN-only endpoint discovered (TURN endpoints also expose STUN).", + }} + } + if stunCount == 0 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "stun_turn.srv_stun.missing", + Message: "No STUN endpoint discovered; clients may fail to obtain a reflexive address.", + }} + } + return []sdk.CheckState{passState("stun_turn.srv_stun.ok", + fmt.Sprintf("%d STUN endpoint(s) discovered", stunCount))} +} + +// srvTurnRule verifies TURN endpoint coverage. +type srvTurnRule struct{} + +func (r *srvTurnRule) Name() string { return "stun_turn.srv_turn" } +func (r *srvTurnRule) Description() string { + return "Verifies that at least one TURN endpoint is reachable via SRV (_turn/_turns) or an explicit URI." +} + +func (r *srvTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.GlobalError != "" { + return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped", + "Discovery failed, TURN coverage could not be evaluated.")} + } + if data.Mode == "stun" { + return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped", + "TURN coverage not evaluated (mode=stun).")} + } + var turnCount int + for _, ep := range data.Endpoints { + if ep.Endpoint.IsTURN { + turnCount++ + } + } + if turnCount == 0 { + sev := sdk.StatusWarn + if data.Mode == "turn" { + sev = sdk.StatusCrit + } + return []sdk.CheckState{{ + Status: sev, + Code: "stun_turn.srv_turn.missing", + Message: "No TURN endpoint discovered; clients behind symmetric NAT will have no relay path.", + }} + } + return []sdk.CheckState{passState("stun_turn.srv_turn.ok", + fmt.Sprintf("%d TURN endpoint(s) discovered", turnCount))} +} + +// dialRule reports connectivity/handshake failures per endpoint. +type dialRule struct{} + +func (r *dialRule) Name() string { return "stun_turn.dial" } +func (r *dialRule) Description() string { + return "Verifies that every discovered endpoint accepts a connection (TCP/TLS handshake or UDP socket)." +} + +func (r *dialRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.GlobalError != "" || len(data.Endpoints) == 0 { + return []sdk.CheckState{skippedState("stun_turn.dial.skipped", "No endpoint to evaluate.")} + } + var states []sdk.CheckState + for _, ep := range data.Endpoints { + if ep.Dial.OK { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.dial.failed", + Subject: epSubject(ep.Endpoint), + Message: ep.Dial.Error, + Meta: map[string]any{"fix": dialFix(ep.Endpoint, ep.Dial.Error)}, + }) + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.dial.ok", "All discovered endpoints accepted a connection.")} + } + return states +} + +// dialFix mirrors the fix phrasing the old Collect emitted for dial +// failures. Kept verbatim so users keep the same remediation guidance. +func dialFix(ep Endpoint, errMsg string) string { + msg := strings.ToLower(errMsg) + switch { + case strings.Contains(msg, "no such host"): + return fmt.Sprintf("Hostname `%s` does not resolve. Add the matching A/AAAA record (or fix typos in the URI).", ep.Host) + case strings.Contains(msg, "tls handshake"), strings.Contains(msg, "x509"): + return fmt.Sprintf("TLS handshake failed for `%s`. Reissue the certificate covering this hostname (e.g. via Let's Encrypt) and reload the server (coturn: `cert=` and `pkey=`).", ep.Host) + case strings.Contains(msg, "connection refused"): + return fmt.Sprintf("Nothing is listening on %s/%d. Start the server with the appropriate listening port (coturn: `listening-port=`/`tls-listening-port=`).", ep.Host, ep.Port) + case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline"): + switch ep.Transport { + case TransportUDP: + return "No reply on UDP. Open the UDP port inbound and verify your network does not block UDP egress." + default: + return "Connection timed out. A firewall or NAT is likely blocking this port." + } + } + return "Could not establish a connection to the server." +} diff --git a/checker/rules_stun.go b/checker/rules_stun.go new file mode 100644 index 0000000..7357922 --- /dev/null +++ b/checker/rules_stun.go @@ -0,0 +1,154 @@ +package checker + +import ( + "context" + "fmt" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// stunBindingRule verifies that the STUN Binding request succeeds on every +// reachable endpoint (returns a reflexive address). +type stunBindingRule struct{} + +func (r *stunBindingRule) Name() string { return "stun_turn.stun_binding" } +func (r *stunBindingRule) Description() string { + return "Verifies that the STUN Binding request receives a XOR-MAPPED-ADDRESS reply." +} + +func (r *stunBindingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.Dial.OK || !ep.STUNBinding.Attempted { + continue + } + seen = true + if ep.STUNBinding.OK { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.stun_binding.failed", + Subject: epSubject(ep.Endpoint), + Message: ep.STUNBinding.Error, + Meta: map[string]any{ + "fix": "Server did not answer the STUN Binding Request. Check that the STUN service is actually listening on this transport, and that no middlebox is filtering RFC 5389 traffic.", + }, + }) + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.stun_binding.skipped", "No endpoint completed a dial, STUN binding not evaluated.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.stun_binding.ok", "STUN Binding succeeded on every reachable endpoint.")} + } + return states +} + +// stunReflexivePublicRule flags servers that return a private/loopback +// reflexive address (typically a TURN server behind NAT with missing +// external-ip configuration). +type stunReflexivePublicRule struct{} + +func (r *stunReflexivePublicRule) Name() string { return "stun_turn.reflexive_public" } +func (r *stunReflexivePublicRule) Description() string { + return "Flags endpoints that return a private/loopback reflexive address (server unaware of its public IP)." +} + +func (r *stunReflexivePublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.STUNBinding.OK { + continue + } + seen = true + if !ep.STUNBinding.IsPrivateMapped { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.reflexive_public.private", + Subject: epSubject(ep.Endpoint), + Message: fmt.Sprintf("server returned a private/loopback IP: %s", ep.STUNBinding.ReflexiveAddr), + Meta: map[string]any{ + "fix": "Server appears to be behind NAT and unaware of its public IP. Set `external-ip=` (coturn) or the equivalent on your TURN server.", + }, + }) + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.reflexive_public.skipped", "No successful STUN Binding to evaluate.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.reflexive_public.ok", "Every reflexive address is public.")} + } + return states +} + +// stunLatencyRule folds the warningRTT / criticalRTT thresholds the old +// Collect hard-coded into a dedicated rule. +type stunLatencyRule struct{} + +func (r *stunLatencyRule) Name() string { return "stun_turn.stun_latency" } +func (r *stunLatencyRule) Description() string { + return "Compares the STUN Binding RTT against the configured warning/critical thresholds." +} + +func (r *stunLatencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + warn := time.Duration(sdk.GetIntOption(opts, "warningRTT", int(data.WarningRTTMs))) * time.Millisecond + crit := time.Duration(sdk.GetIntOption(opts, "criticalRTT", int(data.CriticalRTT))) * time.Millisecond + if warn <= 0 { + warn = 200 * time.Millisecond + } + if crit <= 0 { + crit = 1000 * time.Millisecond + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.STUNBinding.OK { + continue + } + seen = true + rtt := time.Duration(ep.STUNBinding.RTTMs) * time.Millisecond + switch { + case rtt > crit: + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.stun_latency.critical", + Subject: epSubject(ep.Endpoint), + Message: fmt.Sprintf("STUN RTT %dms exceeds critical threshold %dms", ep.STUNBinding.RTTMs, crit.Milliseconds()), + Meta: map[string]any{"fix": "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."}, + }) + case rtt > warn: + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "stun_turn.stun_latency.high", + Subject: epSubject(ep.Endpoint), + Message: fmt.Sprintf("STUN RTT %dms exceeds warning threshold %dms", ep.STUNBinding.RTTMs, warn.Milliseconds()), + Meta: map[string]any{"fix": "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."}, + }) + } + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.stun_latency.skipped", "No successful STUN Binding to evaluate.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.stun_latency.ok", "STUN RTT within acceptable thresholds on every endpoint.")} + } + return states +} diff --git a/checker/rules_transport.go b/checker/rules_transport.go new file mode 100644 index 0000000..5fac7ff --- /dev/null +++ b/checker/rules_transport.go @@ -0,0 +1,108 @@ +package checker + +import ( + "context" + "net" + "strings" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// turnTLSTransportRule evaluates whether a TLS-capable transport is +// available and reports its version/cipher metadata. Kept separate from +// the generic dial rule because TURN-TLS deployments often need dedicated +// attention (port 5349, certificate covering the TURN hostname, …). +type turnTLSTransportRule struct{} + +func (r *turnTLSTransportRule) Name() string { return "stun_turn.tls_transport" } +func (r *turnTLSTransportRule) Description() string { + return "Verifies that at least one TLS/DTLS transport (stuns/turns) succeeds when present in the endpoint set." +} + +func (r *turnTLSTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var ( + secureCount, secureOK int + states []sdk.CheckState + ) + for _, ep := range data.Endpoints { + if !ep.Endpoint.Secure { + continue + } + secureCount++ + if ep.Dial.OK { + secureOK++ + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.tls_transport.handshake_failed", + Subject: epSubject(ep.Endpoint), + Message: ep.Dial.Error, + Meta: map[string]any{ + "fix": "TLS/DTLS handshake failed. Reissue a certificate covering the TURN hostname and reload the server (coturn: `cert=`/`pkey=`).", + }, + }) + } + if secureCount == 0 { + return []sdk.CheckState{skippedState("stun_turn.tls_transport.skipped", "No secure (stuns/turns) endpoint discovered.")} + } + if secureOK == 0 { + // All secure endpoints failed, already emitted per-endpoint + // crit states; return them as-is. + return states + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.tls_transport.ok", "TLS/DTLS handshake succeeded on every secure endpoint.")} + } + return states +} + +// ipv6CoverageRule verifies at least one endpoint resolves to an IPv6 +// address. It never marks an endpoint failed: missing AAAA records are a +// coverage concern, not an error. +type ipv6CoverageRule struct{} + +func (r *ipv6CoverageRule) Name() string { return "stun_turn.ipv6_coverage" } +func (r *ipv6CoverageRule) Description() string { + return "Verifies at least one STUN/TURN hostname resolves to an IPv6 address." +} + +func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if len(data.Endpoints) == 0 { + return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "No endpoint discovered.")} + } + var anyResolved, anyV6 bool + for _, ep := range data.Endpoints { + for _, ipStr := range ep.ResolvedIPs { + anyResolved = true + ip := net.ParseIP(ipStr) + if ip != nil && ip.To4() == nil && strings.Contains(ipStr, ":") { + anyV6 = true + break + } + } + if anyV6 { + break + } + } + if !anyResolved { + return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "Hostname resolution data unavailable.")} + } + if !anyV6 { + return []sdk.CheckState{{ + Status: sdk.StatusWarn, + Code: "stun_turn.ipv6_coverage.missing", + Message: "No STUN/TURN endpoint resolves to an IPv6 address; IPv6-only clients will have no reachable server.", + Meta: map[string]any{"fix": "Publish AAAA records for your STUN/TURN hostnames and ensure the server listens on IPv6."}, + }} + } + return []sdk.CheckState{passState("stun_turn.ipv6_coverage.ok", "At least one endpoint resolves to an IPv6 address.")} +} diff --git a/checker/rules_turn.go b/checker/rules_turn.go new file mode 100644 index 0000000..4a0d743 --- /dev/null +++ b/checker/rules_turn.go @@ -0,0 +1,210 @@ +package checker + +import ( + "context" + "fmt" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// turnOpenRelayRule flags servers that accept an unauthenticated TURN +// Allocate (open relay, abuse vector) and warns on non-standard replies. +type turnOpenRelayRule struct{} + +func (r *turnOpenRelayRule) Name() string { return "stun_turn.turn_open_relay" } +func (r *turnOpenRelayRule) Description() string { + return "Verifies the TURN server requires authentication (challenges unauthenticated Allocate with 401)." +} + +func (r *turnOpenRelayRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.Mode == "stun" || !hasTURNEndpoint(data) { + return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN endpoint to evaluate.")} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.TURNNoAuth.Attempted { + continue + } + seen = true + switch { + case ep.TURNNoAuth.OK: + // Allocate accepted without credentials => open relay. + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.turn_open_relay.open", + Subject: epSubject(ep.Endpoint), + Message: "TURN allocation accepted without authentication", + Meta: map[string]any{"fix": "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification."}, + }) + case ep.TURNNoAuth.UnauthChallenge: + // Expected 401 + REALM/NONCE, nothing to emit, pass below. + default: + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "stun_turn.turn_open_relay.unexpected", + Subject: epSubject(ep.Endpoint), + Message: fmt.Sprintf("unexpected response (code=%d): %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason), + Meta: map[string]any{"fix": "Server did not behave like a standard TURN. Verify it actually implements RFC 5766."}, + }) + } + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN Allocate probe attempted.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.turn_open_relay.ok", "Server correctly challenged unauthenticated Allocate requests.")} + } + return states +} + +// turnAuthRule evaluates the outcome of the authenticated TURN Allocate. +type turnAuthRule struct{} + +func (r *turnAuthRule) Name() string { return "stun_turn.turn_auth" } +func (r *turnAuthRule) Description() string { + return "Verifies the supplied TURN credentials (or REST shared secret) yield a successful Allocate." +} + +func (r *turnAuthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + if data.Mode == "stun" || !hasTURNEndpoint(data) { + return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN endpoint to evaluate.")} + } + if !data.HasCreds { + return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN credentials supplied; authenticated Allocate not attempted.")} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.TURNAuth.Attempted { + continue + } + seen = true + if ep.TURNAuth.OK { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.turn_auth.failed", + Subject: epSubject(ep.Endpoint), + Message: joinMsg(ep.TURNAuth.Error, fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode)), + Meta: map[string]any{"fix": allocateFix(ep.TURNAuth.ErrorCode)}, + }) + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "Authenticated Allocate not attempted on any endpoint.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.turn_auth.ok", "Authenticated TURN Allocate succeeded on every endpoint.")} + } + return states +} + +// turnRelayPublicRule flags private relay addresses (missing relay-ip). +type turnRelayPublicRule struct{} + +func (r *turnRelayPublicRule) Name() string { return "stun_turn.relay_public" } +func (r *turnRelayPublicRule) Description() string { + return "Flags TURN servers whose allocated relay address is private/loopback (missing public relay-ip)." +} + +func (r *turnRelayPublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.TURNAuth.OK { + continue + } + seen = true + if !ep.TURNAuth.IsPrivateRelay { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusCrit, + Code: "stun_turn.relay_public.private", + Subject: epSubject(ep.Endpoint), + Message: fmt.Sprintf("relay address is private: %s", ep.TURNAuth.RelayAddr), + Meta: map[string]any{"fix": "Set `relay-ip=` (coturn). The relay range must be publicly reachable for clients to use TURN."}, + }) + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.relay_public.skipped", "No successful TURN allocation to evaluate.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.relay_public.ok", "Every relay address is public.")} + } + return states +} + +// turnRelayEchoRule reports relay-path breakage. +type turnRelayEchoRule struct{} + +func (r *turnRelayEchoRule) Name() string { return "stun_turn.relay_echo" } +func (r *turnRelayEchoRule) Description() string { + return "Verifies the TURN relay path can carry traffic to the configured probe peer (CreatePermission + Send)." +} + +func (r *turnRelayEchoRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { + data, errSt := loadData(ctx, obs) + if errSt != nil { + return []sdk.CheckState{*errSt} + } + var states []sdk.CheckState + seen := false + for _, ep := range data.Endpoints { + if !ep.RelayEcho.Attempted { + continue + } + seen = true + if ep.RelayEcho.OK { + continue + } + states = append(states, sdk.CheckState{ + Status: sdk.StatusWarn, + Code: "stun_turn.relay_echo.failed", + Subject: epSubject(ep.Endpoint), + Message: ep.RelayEcho.Error, + Meta: map[string]any{"fix": "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn)."}, + }) + } + if !seen { + return []sdk.CheckState{skippedState("stun_turn.relay_echo.skipped", "No relay allocation available to exercise.")} + } + if len(states) == 0 { + return []sdk.CheckState{passState("stun_turn.relay_echo.ok", "Relay echo succeeded on every tested endpoint.")} + } + return states +} + +// allocateFix mirrors the coturn/RFC 5766 guidance the old Collect emitted. +func allocateFix(code int) string { + switch code { + case 401: + return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive." + case 403: + return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect." + case 437: + return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server." + case 441: + return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`." + case 442: + return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side." + case 486: + return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`." + case 508: + return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range." + } + return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport." +} 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..136b22a --- /dev/null +++ b/checker/transport.go @@ -0,0 +1,148 @@ +package checker + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "net" + "strconv" + "time" + + "github.com/pion/dtls/v3" + "github.com/pion/turn/v4" +) + +// dialedConn wraps the network conn used to talk to a STUN/TURN server, +// always exposing a PacketConn (turn/stun talk in datagrams). For +// stream transports (TCP/TLS) we wrap with turn.NewSTUNConn which frames +// STUN messages on top of the byte stream per RFC 5389 §7.2.2. +type dialedConn struct { + pc net.PacketConn + underlying net.Conn // non-nil for TCP/TLS; nil for UDP and DTLS + tlsState *tls.ConnectionState + dtlsState *dtls.State + remoteAddr net.Addr +} + +func (d *dialedConn) Close() error { + var err error + if d.pc != nil { + err = d.pc.Close() + } + if d.underlying != nil { + if e := d.underlying.Close(); e != nil && err == nil { + err = e + } + } + return err +} + +// dtlsPacketConn adapts *dtls.Conn (net.Conn) to net.PacketConn. +// DTLS frames messages at the record level; no additional length-prefix +// framing (as turn.NewSTUNConn adds for TCP) is needed or correct here. +type dtlsPacketConn struct { + conn *dtls.Conn + raddr net.Addr +} + +func (d *dtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) { + n, err := d.conn.Read(b) + return n, d.raddr, err +} + +func (d *dtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) { + return d.conn.Write(b) +} + +func (d *dtlsPacketConn) Close() error { return d.conn.Close() } +func (d *dtlsPacketConn) LocalAddr() net.Addr { return d.conn.LocalAddr() } +func (d *dtlsPacketConn) SetDeadline(t time.Time) error { return d.conn.SetDeadline(t) } +func (d *dtlsPacketConn) SetReadDeadline(t time.Time) error { return d.conn.SetReadDeadline(t) } +func (d *dtlsPacketConn) SetWriteDeadline(t time.Time) error { return d.conn.SetWriteDeadline(t) } + +// dial establishes the appropriate L4(/secure) connection to ep. +// timeout is applied per dial step (TCP connect, TLS handshake, DTLS handshake). +func dial(ctx context.Context, ep Endpoint, timeout time.Duration) (*dialedConn, error) { + addr := net.JoinHostPort(ep.Host, strconv.Itoa(int(ep.Port))) + + dctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + switch ep.Transport { + case TransportUDP: + raddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return nil, fmt.Errorf("resolve udp %s: %w", addr, err) + } + // Use the dual-stack wildcard ("") so the kernel can pick an IPv6 + // source when the resolved server address is IPv6. + conn, err := net.ListenPacket("udp", ":0") + if err != nil { + return nil, fmt.Errorf("listen udp: %w", err) + } + return &dialedConn{pc: conn, remoteAddr: raddr}, nil + + case TransportTCP: + var d net.Dialer + c, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("dial tcp %s: %w", addr, err) + } + return &dialedConn{ + pc: turn.NewSTUNConn(c), + underlying: c, + remoteAddr: c.RemoteAddr(), + }, nil + + case TransportTLS: + var d net.Dialer + raw, err := d.DialContext(dctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("dial tcp %s: %w", addr, err) + } + tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, MinVersion: tls.VersionTLS12}) + if err := tlsConn.HandshakeContext(dctx); err != nil { + raw.Close() + return nil, fmt.Errorf("tls handshake %s: %w", addr, err) + } + state := tlsConn.ConnectionState() + return &dialedConn{ + pc: turn.NewSTUNConn(tlsConn), + underlying: tlsConn, + tlsState: &state, + remoteAddr: tlsConn.RemoteAddr(), + }, nil + + case TransportDTLS: + raddr, err := net.ResolveUDPAddr("udp", addr) + if err != nil { + return nil, fmt.Errorf("resolve udp %s: %w", addr, err) + } + udpConn, err := net.ListenUDP("udp", nil) + if err != nil { + return nil, fmt.Errorf("listen udp: %w", err) + } + dconn, err := dtls.Client(udpConn, raddr, &dtls.Config{ + ServerName: ep.Host, + }) + if err != nil { + udpConn.Close() + return nil, fmt.Errorf("dtls setup %s: %w", addr, err) + } + if err := dconn.HandshakeContext(dctx); err != nil { + dconn.Close() + udpConn.Close() + return nil, fmt.Errorf("dtls handshake %s: %w", addr, err) + } + state, _ := dconn.ConnectionState() + return &dialedConn{ + pc: &dtlsPacketConn{conn: dconn, raddr: raddr}, + dtlsState: &state, + remoteAddr: raddr, + }, nil + + default: + return nil, errors.New("unknown transport") + } +} diff --git a/checker/turn.go b/checker/turn.go new file mode 100644 index 0000000..4c85d40 --- /dev/null +++ b/checker/turn.go @@ -0,0 +1,189 @@ +package checker + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "github.com/pion/turn/v4" +) + +// turnAllocateResult holds the outcome of a TURN Allocate exchange. +type turnAllocateResult struct { + Client *turn.Client // non-nil iff RelayConn is non-nil; caller must Close after RelayConn. + RelayConn net.PacketConn + RelayAddr net.Addr + IsPrivateRelay bool + UnauthChallenge bool // first allocate replied with 401 + REALM/NONCE (good for "no auth" probe) + AuthErrorCode int // STUN error code on the final attempt (0 if OK) + AuthErrorReason string // STUN reason phrase + Duration time.Duration // wall time of the allocate exchange + Err error +} + +// runTURNAllocate runs a full TURN Allocate against the dialed connection. +// If creds is nil, it sends an unauthenticated Allocate and treats the +// expected 401 challenge as success of the *probe* (UnauthChallenge=true). +// If creds is non-nil, it performs the full long-term-credential dance. +// +// The returned RelayConn is owned by the caller and must be Close()d. +func runTURNAllocate(d *dialedConn, creds *turnCredentials, timeout time.Duration) turnAllocateResult { + cfg := &turn.ClientConfig{ + Conn: d.pc, + TURNServerAddr: d.remoteAddr.String(), + STUNServerAddr: d.remoteAddr.String(), + RTO: timeout, + Software: "happyDomain-checker-stun-turn", + } + if creds != nil { + cfg.Username = creds.Username + cfg.Password = creds.Password + cfg.Realm = creds.Realm + } + + client, err := turn.NewClient(cfg) + if err != nil { + return turnAllocateResult{Err: fmt.Errorf("turn.NewClient: %w", err)} + } + + if err := client.Listen(); err != nil { + client.Close() + return turnAllocateResult{Err: fmt.Errorf("client.Listen: %w", err)} + } + + start := time.Now() + relay, err := client.Allocate() + dur := time.Since(start) + if err != nil { + // Inspect the STUN error code to give the user a precise diagnostic. + code, reason := stunErrorOf(err) + // 401 with REALM/NONCE is the *expected* answer when probing without + // credentials; surface that as a positive UnauthChallenge signal, + // not as a failure, so the rule layer can flag "open relay" if we + // got a 200 instead. + if creds == nil && code == 401 { + client.Close() + return turnAllocateResult{ + UnauthChallenge: true, + Duration: dur, + AuthErrorCode: 401, + AuthErrorReason: reason, + } + } + client.Close() + return turnAllocateResult{ + AuthErrorCode: code, + AuthErrorReason: reason, + Duration: dur, + Err: err, + } + } + + res := turnAllocateResult{ + Client: client, + RelayConn: relay, + RelayAddr: relay.LocalAddr(), + Duration: dur, + } + if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok { + res.IsPrivateRelay = isPrivate(udpAddr.IP) + } + // Client is intentionally not Close()d here: closing it before the + // caller is done with RelayConn would tear down the underlying PacketConn. + // The caller is responsible for Close()ing RelayConn first, then Client. + return res +} + +// runRelayEcho asks the TURN server to relay a single short datagram to the +// configured probe peer. This proves that: +// - CreatePermission succeeds (server acknowledges the Send indication), +// - the TURN data path accepts traffic. +// +// A reply from the peer is not required or awaited. +func runRelayEcho(relay net.PacketConn, peer string, timeout time.Duration) error { + host, _, err := net.SplitHostPort(peer) + if err != nil { + return fmt.Errorf("invalid probePeer %q: %w", peer, err) + } + if host == "" { + return errors.New("empty probe peer host") + } + addr, err := net.ResolveUDPAddr("udp", peer) + if err != nil { + return fmt.Errorf("resolve probe peer: %w", err) + } + // Defence in depth against SSRF: even if the literal probePeer passed + // the upfront check, its DNS resolution might land on private space. + if isPrivate(addr.IP) { + return fmt.Errorf("probePeer %q resolves to private address %s", peer, addr.IP) + } + if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil { + return err + } + // One-byte payload: enough to trigger CreatePermission + Send on the + // TURN data path. We do not expect or wait for a peer reply. + payload := []byte{0x00} + if _, err := relay.WriteTo(payload, addr); err != nil { + return fmt.Errorf("relay WriteTo: %w", err) + } + return nil +} + +// turnCredentials carries either explicit long-term credentials or values +// derived from a REST-API shared secret. +type turnCredentials struct { + Username string + Password string + Realm string +} + +// restAPICredentials derives ephemeral credentials per the +// draft-uberti-rtcweb-turn-rest scheme: +// +// username = ":" +// 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/turn_test.go b/checker/turn_test.go new file mode 100644 index 0000000..ca5f986 --- /dev/null +++ b/checker/turn_test.go @@ -0,0 +1,53 @@ +package checker + +import ( + "errors" + "fmt" + "testing" + + "github.com/pion/stun/v3" +) + +// TestStunErrorOf_PinPionFormat builds an error using pion's own +// ErrorCodeAttribute.String() so that any future change to the format +// pion/turn uses ("... (error : )") makes this test fail +// loudly instead of silently breaking our diagnostic parsing. +func TestStunErrorOf_PinPionFormat(t *testing.T) { + cases := []struct { + name string + code stun.ErrorCode + reason string + wantCode int + wantReason string + }{ + {"unauthorized", stun.CodeUnauthorized, "Unauthorized", 401, "Unauthorized"}, + {"stale nonce", stun.CodeStaleNonce, "Stale Nonce", 438, "Stale Nonce"}, + {"server error", stun.CodeServerError, "Server Error", 500, "Server Error"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + attr := stun.ErrorCodeAttribute{Code: tc.code, Reason: []byte(tc.reason)} + // Mirror pion/turn/v4@v4.0.0 client.go:296: + // fmt.Errorf("%s (error %s)", res.Type, code) + err := fmt.Errorf("error response (error %s)", attr) + gotCode, gotReason := stunErrorOf(err) + if gotCode != tc.wantCode || gotReason != tc.wantReason { + t.Errorf("stunErrorOf(%q) = (%d, %q); want (%d, %q): pion error format may have changed", + err.Error(), gotCode, gotReason, tc.wantCode, tc.wantReason) + } + }) + } +} + +func TestStunErrorOf_NoMatch(t *testing.T) { + code, reason := stunErrorOf(errors.New("plain error")) + if code != 0 { + t.Errorf("expected code 0 for unparseable error, got %d", code) + } + if reason != "plain error" { + t.Errorf("expected reason to fall back to message, got %q", reason) + } + if c, r := stunErrorOf(nil); c != 0 || r != "" { + t.Errorf("nil error: got (%d, %q), want (0, \"\")", c, r) + } +} diff --git a/checker/types.go b/checker/types.go new file mode 100644 index 0000000..3daddb5 --- /dev/null +++ b/checker/types.go @@ -0,0 +1,133 @@ +// Package checker implements the STUN/TURN checker for happyDomain. +// +// The checker drives a target server through the STUN binding and TURN +// allocation/relay protocols (RFC 5389, RFC 5766) using the Pion libraries, +// then exposes a structured observation and a rich HTML report including +// remediation guidance for the most common deployment mistakes. +package checker + +import ( + "net" + "time" + + sdk "git.happydns.org/checker-sdk-go/checker" +) + +// isPrivateAddr returns whether s parses as a private/loopback/link-local +// address. Accepts either a bare IP or a host:port form. Hostnames return +// false; the caller should resolve first if needed. +func isPrivateAddr(s string) bool { + ip := net.ParseIP(s) + if ip == nil { + host, _, err := net.SplitHostPort(s) + if err != nil { + return false + } + ip = net.ParseIP(host) + } + return isPrivate(ip) +} + +// ObservationKeyStunTurn is the observation key for STUN/TURN test data. +const ObservationKeyStunTurn sdk.ObservationKey = "stun_turn" + +// Transport identifies the L4/L4-secure transport used to reach an endpoint. +type Transport string + +const ( + TransportUDP Transport = "udp" + TransportTCP Transport = "tcp" + TransportTLS Transport = "tls" + TransportDTLS Transport = "dtls" +) + +// Endpoint is a single resolved server target to probe. +type Endpoint struct { + URI string `json:"uri"` + Host string `json:"host"` + Port uint16 `json:"port"` + Transport Transport `json:"transport"` + Secure bool `json:"secure"` + IsTURN bool `json:"is_turn"` // false: STUN-only scheme, true: TURN scheme + Source string `json:"source"` // "uri" or "srv:_turn._udp.example.com" +} + +// DialResult holds the raw outcome of establishing the L4(/secure) +// connection to an endpoint. Collected without judgement. +type DialResult struct { + OK bool `json:"ok"` + DurationMs int64 `json:"duration_ms"` + RemoteAddr string `json:"remote_addr,omitempty"` + Error string `json:"error,omitempty"` + + // TLS metadata populated when the underlying transport used TLS. + TLSVersion string `json:"tls_version,omitempty"` + TLSCipher string `json:"tls_cipher,omitempty"` + TLSPeerCN string `json:"tls_peer_cn,omitempty"` + + // DTLSHandshake records whether a DTLS handshake was performed. + DTLSHandshake bool `json:"dtls_handshake,omitempty"` +} + +// STUNBindingObservation holds the raw outcome of a STUN Binding probe. +type STUNBindingObservation struct { + Attempted bool `json:"attempted"` + OK bool `json:"ok"` + RTTMs int64 `json:"rtt_ms"` + ReflexiveAddr string `json:"reflexive_addr,omitempty"` + IsPrivateMapped bool `json:"is_private_mapped,omitempty"` + Error string `json:"error,omitempty"` +} + +// TURNAllocateObservation holds the raw outcome of a TURN Allocate +// attempt (either unauthenticated probe or authenticated attempt). +type TURNAllocateObservation struct { + Attempted bool `json:"attempted"` + OK bool `json:"ok"` + DurationMs int64 `json:"duration_ms"` + RelayAddr string `json:"relay_addr,omitempty"` + IsPrivateRelay bool `json:"is_private_relay,omitempty"` + UnauthChallenge bool `json:"unauth_challenge,omitempty"` + ErrorCode int `json:"error_code,omitempty"` + ErrorReason string `json:"error_reason,omitempty"` + Error string `json:"error,omitempty"` +} + +// RelayEchoObservation holds the raw outcome of the relay echo probe. +type RelayEchoObservation struct { + Attempted bool `json:"attempted"` + OK bool `json:"ok"` + PeerAddr string `json:"peer_addr,omitempty"` + Error string `json:"error,omitempty"` +} + +// EndpointProbe holds the raw, unjudged observation for a single endpoint. +type EndpointProbe struct { + Endpoint Endpoint `json:"endpoint"` + + // ResolvedIPs lists the A/AAAA addresses we observed for this host + // (populated when the endpoint was reached). Used for IPv6 coverage. + ResolvedIPs []string `json:"resolved_ips,omitempty"` + + Dial DialResult `json:"dial"` + STUNBinding STUNBindingObservation `json:"stun_binding"` + TURNNoAuth TURNAllocateObservation `json:"turn_noauth"` + TURNAuth TURNAllocateObservation `json:"turn_auth"` + RelayEcho RelayEchoObservation `json:"relay_echo"` + ChannelBindRun bool `json:"channel_bind_run,omitempty"` +} + +// StunTurnData is the JSON-serializable observation payload. It now +// carries only raw per-endpoint probe outcomes; rules do the judging. +type StunTurnData struct { + Zone string `json:"zone,omitempty"` + Mode string `json:"mode,omitempty"` + RequestedURI string `json:"requested_uri,omitempty"` + HasCreds bool `json:"has_creds,omitempty"` + ProbePeer string `json:"probe_peer,omitempty"` + WarningRTTMs int64 `json:"warning_rtt_ms,omitempty"` + CriticalRTT int64 `json:"critical_rtt_ms,omitempty"` + CollectedAt time.Time `json:"collected_at"` + Endpoints []EndpointProbe `json:"endpoints"` + GlobalError string `json:"global_error,omitempty"` +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..804f6de --- /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.5.0 + git.happydns.org/checker-tls v0.6.2 + github.com/pion/dtls/v3 v3.0.4 + github.com/pion/stun/v3 v3.0.0 + github.com/pion/turn/v4 v4.0.0 +) + +require ( + github.com/pion/logging v0.2.2 // indirect + github.com/pion/randutil v0.1.0 // indirect + github.com/pion/transport/v3 v3.0.7 // indirect + github.com/wlynxg/anet v0.0.3 // indirect + golang.org/x/crypto v0.28.0 // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f7d14df --- /dev/null +++ b/go.sum @@ -0,0 +1,32 @@ +git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY= +git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI= +git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E= +git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U= +github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= +github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= +github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= +github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= +github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= +github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= +github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= +github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg= +github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= +golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= +golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..8ab8e50 --- /dev/null +++ b/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "flag" + "log" + + "git.happydns.org/checker-sdk-go/checker/server" + stunturn "git.happydns.org/checker-stun-turn/checker" +) + +// Version is the standalone binary's version, overridden via +// `go build -ldflags "-X main.Version=..."`. +var Version = "custom-build" + +var listenAddr = flag.String("listen", ":8080", "HTTP listen address") + +func main() { + flag.Parse() + + stunturn.Version = Version + + srv := server.New(stunturn.Provider()) + if err := srv.ListenAndServe(*listenAddr); err != nil { + log.Fatalf("server error: %v", err) + } +} diff --git a/plugin/plugin.go b/plugin/plugin.go new file mode 100644 index 0000000..486398e --- /dev/null +++ b/plugin/plugin.go @@ -0,0 +1,19 @@ +// Command plugin is the happyDomain plugin entrypoint for the STUN/TURN checker. +// +// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at +// runtime by happyDomain. +package main + +import ( + sdk "git.happydns.org/checker-sdk-go/checker" + stunturn "git.happydns.org/checker-stun-turn/checker" +) + +var Version = "custom-build" + +// NewCheckerPlugin is the symbol resolved by happyDomain when loading the .so. +func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) { + stunturn.Version = Version + prvd := stunturn.Provider() + return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil +}