Initial commit
Adds a happyDomain checker that probes STUN/TURN servers end-to-end:
DNS/SRV discovery, UDP/TCP/TLS/DTLS dial, STUN binding + reflexive-addr
sanity, open-relay detection, authenticated TURN Allocate (long-term
creds or REST-API HMAC), public-relay check, CreatePermission + Send
round-trip through the relay, and optional ChannelBind.
Failing sub-tests carry a remediation string (`Fix`) that the HTML
report surfaces as a yellow headline callout and inline next to each
row. Mapping covers the most common coturn misconfigurations
(external-ip, relay-ip, lt-cred-mech, min-port/max-port, cert issues,
401 nonce drift, 441/442/486/508 allocation errors).
Implements sdk.EndpointDiscoverer (checker/discovery.go): every
stuns:/turns:/DTLS endpoint observed during Collect is published as a
DiscoveredEndpoint{Type: "tls"|"dtls"} so a downstream TLS checker can
verify certificates without re-parsing the observation.
Backed by pion/stun/v3 + pion/turn/v4 + pion/dtls/v3; SDK pinned to a
local replace until the EndpointDiscoverer interface ships in a tagged
release.
This commit is contained in:
commit
7c7706fe3f
29 changed files with 2794 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-stun-turn
|
||||
*.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-stun-turn .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-stun-turn /checker-stun-turn
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-stun-turn", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-stun-turn"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-stun-turn
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker clean test
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test -tags standalone ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
checker-stun-turn
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
90
README.md
Normal file
90
README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# checker-stun-turn
|
||||
|
||||
happyDomain checker that probes **STUN** and **TURN** servers end-to-end:
|
||||
DNS / SRV discovery, TCP/UDP reachability, TLS / DTLS handshake, STUN binding,
|
||||
open-relay check, authenticated TURN Allocate (long-term creds *or*
|
||||
REST API shared secret), relay address sanity, and a `CreatePermission + Send`
|
||||
round-trip through the relay.
|
||||
|
||||
Backed by [`github.com/pion/stun`](https://github.com/pion/stun) +
|
||||
[`github.com/pion/turn`](https://github.com/pion/turn).
|
||||
|
||||
## Tests performed per endpoint
|
||||
|
||||
| Test | What it proves |
|
||||
|-----------------------------|----------------|
|
||||
| `dial:<transport>` | DNS resolves, port is reachable, TLS/DTLS handshake succeeds. |
|
||||
| `tls` / `dtls` | Records TLS version + cipher + peer cert CN. |
|
||||
| `stun_binding` | Server answers RFC 5389 Binding Request; measures RTT. |
|
||||
| `stun_reflexive_public` | Server returned a *public* XOR-MAPPED address (not RFC1918). |
|
||||
| `turn_open_relay_check` | Unauthenticated Allocate is rejected with 401 + REALM/NONCE. |
|
||||
| `turn_allocate_auth` | Authenticated Allocate succeeds; relay address returned. |
|
||||
| `turn_relay_public` | Relay address is publicly routable. |
|
||||
| `turn_relay_echo` | CreatePermission + Send to the probe peer succeed. |
|
||||
| `turn_channel_bind` | (optional) ChannelBind exercised via the relay conn. |
|
||||
|
||||
## Most common failures surfaced with a fix
|
||||
|
||||
Each failing sub-test carries a `Fix` field that is rendered prominently in the
|
||||
HTML report (yellow callout at the top of the card *and* inline with each row).
|
||||
Mapping:
|
||||
|
||||
- UDP/TCP dial timeouts → firewall/NAT guidance
|
||||
- TLS handshake errors → certificate reissue guidance (coturn `cert=`/`pkey=`)
|
||||
- STUN binding returns RFC1918 → `external-ip=` for coturn
|
||||
- Unauthenticated Allocate accepted → enable `lt-cred-mech`, close the open relay
|
||||
- Allocate 401 loop → check NTP (TURN nonces are time-sensitive)
|
||||
- Allocate 441 → wrong username/password or wrong REST shared secret
|
||||
- Allocate 442 → try different transport or enable it server-side
|
||||
- Allocate 486/508 → quota / port-range issues on the server
|
||||
- Relay address is private → set `relay-ip=` to a public IP
|
||||
- Relay echo fails → `min-port`/`max-port` range not publicly reachable
|
||||
|
||||
## Usage
|
||||
|
||||
Build and run:
|
||||
|
||||
```
|
||||
make # standalone binary
|
||||
make plugin # .so plugin for happyDomain
|
||||
./checker-stun-turn -listen :8080
|
||||
```
|
||||
|
||||
Trigger a check:
|
||||
|
||||
```
|
||||
curl -sX POST localhost:8080/collect -H 'content-type: application/json' -d '{
|
||||
"options": {
|
||||
"zone": "example.com",
|
||||
"serverURI": "turns:turn.example.com:5349?transport=tcp",
|
||||
"mode": "turn",
|
||||
"username": "alice",
|
||||
"credential": "s3cret",
|
||||
"transports": "udp,tcp,tls",
|
||||
"probePeer": "1.1.1.1:53",
|
||||
"timeout": 5
|
||||
}
|
||||
}' | jq .
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Scope | Option | Type | Default | Notes |
|
||||
|---------|-------------------|--------|---------------|-------|
|
||||
| run | `zone` | string | (auto-filled) | used for `_stun._udp` / `_turn._udp` / `_turns._tcp` SRV discovery |
|
||||
| run | `serverURI` | string | | explicit URI, RFC 7064/7065 |
|
||||
| run | `mode` | choice | `auto` | `stun`, `turn`, `auto` |
|
||||
| user | `username` | string | | long-term credentials |
|
||||
| user | `credential` | secret | | long-term credentials |
|
||||
| user | `sharedSecret` | secret | | REST-API auth (draft-uberti), takes precedence |
|
||||
| user | `realm` | string | | optional explicit realm |
|
||||
| user | `transports` | string | `udp,tcp,tls` | comma-separated among `udp,tcp,tls,dtls` |
|
||||
| user | `probePeer` | string | `1.1.1.1:53` | target for the relay echo test |
|
||||
| user | `testChannelBind` | bool | `false` | |
|
||||
| user | `warningRTT` | uint | `200` ms | |
|
||||
| user | `criticalRTT` | uint | `1000` ms | |
|
||||
| user | `timeout` | uint | `5` s | per-probe |
|
||||
|
||||
## License
|
||||
|
||||
MIT (see LICENSE).
|
||||
235
checker/collect.go
Normal file
235
checker/collect.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type probeConfig struct {
|
||||
mode string
|
||||
username string
|
||||
password string
|
||||
sharedSecret string
|
||||
realm string
|
||||
probePeer string
|
||||
testChannelBind bool
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
// Collect gathers raw STUN/TURN observations (SRV discovery, dial
|
||||
// outcome, STUN Binding result, TURN Allocate results, relay echo).
|
||||
// It performs NO judgement: severity, pass/fail, warning thresholds
|
||||
// and fix suggestions are left to the CheckRule layer.
|
||||
func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
zone, _ := opts["zone"].(string)
|
||||
uri, _ := opts["serverURI"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "auto"
|
||||
}
|
||||
username, _ := opts["username"].(string)
|
||||
password, _ := opts["credential"].(string)
|
||||
sharedSecret, _ := opts["sharedSecret"].(string)
|
||||
realm, _ := opts["realm"].(string)
|
||||
transportsRaw, _ := opts["transports"].(string)
|
||||
probePeer, _ := opts["probePeer"].(string)
|
||||
if probePeer == "" {
|
||||
probePeer = "1.1.1.1:53"
|
||||
}
|
||||
// Refuse to relay traffic toward private/loopback/link-local
|
||||
// destinations: a malicious caller could otherwise abuse the target
|
||||
// TURN server to port-scan the operator's internal network through us.
|
||||
if isPrivateAddr(probePeer) {
|
||||
return nil, fmt.Errorf("probePeer %q resolves to a private/loopback address", probePeer)
|
||||
}
|
||||
timeoutSec := sdk.GetIntOption(opts, "timeout", 5)
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 5
|
||||
}
|
||||
|
||||
cfg := probeConfig{
|
||||
mode: mode,
|
||||
username: username,
|
||||
password: password,
|
||||
sharedSecret: sharedSecret,
|
||||
realm: realm,
|
||||
probePeer: probePeer,
|
||||
testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false),
|
||||
timeout: time.Duration(timeoutSec) * time.Second,
|
||||
}
|
||||
|
||||
transports := parseTransports(transportsRaw)
|
||||
|
||||
warnRTT := int64(sdk.GetIntOption(opts, "warningRTT", 200))
|
||||
critRTT := int64(sdk.GetIntOption(opts, "criticalRTT", 1000))
|
||||
|
||||
data := &StunTurnData{
|
||||
Zone: zone,
|
||||
Mode: mode,
|
||||
RequestedURI: uri,
|
||||
ProbePeer: probePeer,
|
||||
WarningRTTMs: warnRTT,
|
||||
CriticalRTT: critRTT,
|
||||
HasCreds: sharedSecret != "" || (username != "" && password != ""),
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
|
||||
if err != nil {
|
||||
data.GlobalError = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
probe := EndpointProbe{Endpoint: ep}
|
||||
collectEndpoint(ctx, &probe, cfg)
|
||||
data.Endpoints = append(data.Endpoints, probe)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// collectEndpoint runs every network probe we know how to run against
|
||||
// a single endpoint and records raw results on probe. It never assigns
|
||||
// severity.
|
||||
func collectEndpoint(ctx context.Context, probe *EndpointProbe, cfg probeConfig) {
|
||||
ep := probe.Endpoint
|
||||
|
||||
// Best-effort DNS lookup for IPv6 coverage rule.
|
||||
if ips, err := net.DefaultResolver.LookupIPAddr(ctx, ep.Host); err == nil {
|
||||
for _, ip := range ips {
|
||||
probe.ResolvedIPs = append(probe.ResolvedIPs, ip.IP.String())
|
||||
}
|
||||
}
|
||||
|
||||
dialStart := time.Now()
|
||||
dc, err := dial(ctx, ep, cfg.timeout)
|
||||
dialDur := time.Since(dialStart)
|
||||
if err != nil {
|
||||
probe.Dial = DialResult{
|
||||
OK: false,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
Error: err.Error(),
|
||||
}
|
||||
return
|
||||
}
|
||||
defer dc.Close()
|
||||
probe.Dial = DialResult{
|
||||
OK: true,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
RemoteAddr: dc.remoteAddr.String(),
|
||||
}
|
||||
if dc.tlsState != nil {
|
||||
probe.Dial.TLSVersion = tlsVersionString(dc.tlsState.Version)
|
||||
probe.Dial.TLSCipher = tls.CipherSuiteName(dc.tlsState.CipherSuite)
|
||||
probe.Dial.TLSPeerCN = peerCertCN(dc.tlsState)
|
||||
}
|
||||
if dc.dtlsState != nil {
|
||||
probe.Dial.DTLSHandshake = true
|
||||
}
|
||||
|
||||
// STUN Binding probe, always attempted.
|
||||
bind := runSTUNBinding(dc, cfg.timeout)
|
||||
probe.STUNBinding.Attempted = true
|
||||
if bind.Err != nil {
|
||||
probe.STUNBinding.OK = false
|
||||
probe.STUNBinding.Error = bind.Err.Error()
|
||||
} else {
|
||||
probe.STUNBinding.OK = true
|
||||
probe.STUNBinding.RTTMs = bind.RTT.Milliseconds()
|
||||
if bind.ReflexiveAddr != nil {
|
||||
probe.STUNBinding.ReflexiveAddr = bind.ReflexiveAddr.String()
|
||||
}
|
||||
probe.STUNBinding.IsPrivateMapped = bind.IsPrivateMapped
|
||||
}
|
||||
|
||||
// TURN-only probes short-circuit when mode=stun or the scheme is
|
||||
// stun:/stuns:.
|
||||
if cfg.mode == "stun" || !ep.IsTURN {
|
||||
return
|
||||
}
|
||||
|
||||
// Unauthenticated TURN Allocate (open-relay probe).
|
||||
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
|
||||
probe.TURNNoAuth.Attempted = true
|
||||
probe.TURNNoAuth.DurationMs = noAuth.Duration.Milliseconds()
|
||||
probe.TURNNoAuth.ErrorCode = noAuth.AuthErrorCode
|
||||
probe.TURNNoAuth.ErrorReason = noAuth.AuthErrorReason
|
||||
probe.TURNNoAuth.UnauthChallenge = noAuth.UnauthChallenge
|
||||
if noAuth.RelayConn != nil {
|
||||
probe.TURNNoAuth.OK = true
|
||||
if noAuth.RelayAddr != nil {
|
||||
probe.TURNNoAuth.RelayAddr = noAuth.RelayAddr.String()
|
||||
}
|
||||
_ = noAuth.RelayConn.Close()
|
||||
if noAuth.Client != nil {
|
||||
noAuth.Client.Close()
|
||||
}
|
||||
} else if noAuth.Err != nil && !noAuth.UnauthChallenge {
|
||||
probe.TURNNoAuth.Error = noAuth.Err.Error()
|
||||
}
|
||||
|
||||
// Authenticated TURN Allocate, if credentials are provided.
|
||||
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
|
||||
if creds == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dc2, err := dial(ctx, ep, cfg.timeout)
|
||||
if err != nil {
|
||||
probe.TURNAuth.Attempted = true
|
||||
probe.TURNAuth.Error = fmt.Sprintf("redial failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer dc2.Close()
|
||||
|
||||
auth := runTURNAllocate(dc2, creds, cfg.timeout)
|
||||
probe.TURNAuth.Attempted = true
|
||||
probe.TURNAuth.DurationMs = auth.Duration.Milliseconds()
|
||||
probe.TURNAuth.ErrorCode = auth.AuthErrorCode
|
||||
probe.TURNAuth.ErrorReason = auth.AuthErrorReason
|
||||
if auth.Err != nil {
|
||||
probe.TURNAuth.OK = false
|
||||
probe.TURNAuth.Error = auth.Err.Error()
|
||||
return
|
||||
}
|
||||
probe.TURNAuth.OK = true
|
||||
if auth.RelayAddr != nil {
|
||||
probe.TURNAuth.RelayAddr = auth.RelayAddr.String()
|
||||
}
|
||||
probe.TURNAuth.IsPrivateRelay = auth.IsPrivateRelay
|
||||
defer func() {
|
||||
auth.RelayConn.Close()
|
||||
if auth.Client != nil {
|
||||
auth.Client.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// Relay echo.
|
||||
probe.RelayEcho.Attempted = true
|
||||
probe.RelayEcho.PeerAddr = cfg.probePeer
|
||||
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
|
||||
probe.RelayEcho.OK = false
|
||||
probe.RelayEcho.Error = err.Error()
|
||||
} else {
|
||||
probe.RelayEcho.OK = true
|
||||
}
|
||||
|
||||
if cfg.testChannelBind {
|
||||
probe.ChannelBindRun = true
|
||||
}
|
||||
}
|
||||
|
||||
func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials {
|
||||
if sharedSecret != "" {
|
||||
return restAPICredentials(sharedSecret, username, realm, time.Hour)
|
||||
}
|
||||
if username != "" && password != "" {
|
||||
return &turnCredentials{Username: username, Password: password, Realm: realm}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
94
checker/definition.go
Normal file
94
checker/definition.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the checker version reported in CheckerDefinition.Version.
|
||||
// Defaults to "built-in"; standalone binaries override it from main().
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the STUN/TURN checker.
|
||||
func (p *stunTurnProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "stunturn",
|
||||
Name: "STUN/TURN Server",
|
||||
Version: Version,
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyStunTurn},
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToZone: true,
|
||||
ApplyToService: true,
|
||||
},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "zone",
|
||||
Type: "string",
|
||||
Label: "Zone",
|
||||
Placeholder: "example.com",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Description: "Zone used for SRV-based STUN/TURN endpoint discovery when no explicit URI is provided.",
|
||||
},
|
||||
{
|
||||
Id: "serverURI",
|
||||
Type: "string",
|
||||
Label: "Server URI",
|
||||
Placeholder: "turns:turn.example.com:5349?transport=tcp",
|
||||
Description: "Explicit STUN/TURN URI (RFC 7064/7065). Overrides SRV-based discovery.",
|
||||
},
|
||||
{
|
||||
Id: "mode",
|
||||
Type: "string",
|
||||
Label: "Mode",
|
||||
Default: "auto",
|
||||
Choices: []string{"auto", "stun", "turn"},
|
||||
Description: "auto: probe both STUN and TURN; stun: skip TURN allocation tests; turn: require TURN allocation.",
|
||||
},
|
||||
},
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{Id: "username", Type: "string", Label: "TURN username"},
|
||||
{Id: "credential", Type: "string", Label: "TURN password", Secret: true},
|
||||
{
|
||||
Id: "sharedSecret",
|
||||
Type: "string",
|
||||
Label: "REST API shared secret",
|
||||
Secret: true,
|
||||
Description: "Shared secret used to derive ephemeral credentials (draft-uberti-rtcweb-turn-rest). Takes precedence over username/password.",
|
||||
},
|
||||
{Id: "realm", Type: "string", Label: "Realm"},
|
||||
{
|
||||
Id: "transports",
|
||||
Type: "string",
|
||||
Label: "Transports",
|
||||
Default: "udp,tcp,tls",
|
||||
Description: "Comma-separated list of transports to test among: udp, tcp, tls, dtls.",
|
||||
},
|
||||
{
|
||||
Id: "probePeer",
|
||||
Type: "string",
|
||||
Label: "Relay echo target",
|
||||
Default: "1.1.1.1:53",
|
||||
Description: "host:port used to validate the relay path (a CreatePermission + Send is issued, no payload data is exchanged).",
|
||||
},
|
||||
{
|
||||
Id: "testChannelBind",
|
||||
Type: "bool",
|
||||
Label: "Also test ChannelBind",
|
||||
Default: false,
|
||||
},
|
||||
{Id: "warningRTT", Type: "uint", Label: "RTT warning threshold (ms)", Default: 200},
|
||||
{Id: "criticalRTT", Type: "uint", Label: "RTT critical threshold (ms)", Default: 1000},
|
||||
{Id: "timeout", Type: "uint", Label: "Per-probe timeout (s)", Default: 5},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Default: 30 * time.Minute,
|
||||
Max: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
225
checker/discover.go
Normal file
225
checker/discover.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// parseURI parses a STUN/TURN URI per RFC 7064 / RFC 7065.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// stun:turn.example.com
|
||||
// stun:turn.example.com:3478
|
||||
// stuns:turn.example.com:5349
|
||||
// turn:turn.example.com:3478?transport=udp
|
||||
// turns:turn.example.com:5349?transport=tcp
|
||||
func parseURI(raw string) (Endpoint, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return Endpoint{}, fmt.Errorf("empty URI")
|
||||
}
|
||||
|
||||
colon := strings.IndexByte(raw, ':')
|
||||
if colon < 0 {
|
||||
return Endpoint{}, fmt.Errorf("missing scheme in %q", raw)
|
||||
}
|
||||
scheme := strings.ToLower(raw[:colon])
|
||||
rest := raw[colon+1:]
|
||||
|
||||
var ep Endpoint
|
||||
ep.URI = raw
|
||||
ep.Source = "uri"
|
||||
|
||||
switch scheme {
|
||||
case "stun":
|
||||
ep.IsTURN = false
|
||||
ep.Secure = false
|
||||
case "stuns":
|
||||
ep.IsTURN = false
|
||||
ep.Secure = true
|
||||
case "turn":
|
||||
ep.IsTURN = true
|
||||
ep.Secure = false
|
||||
case "turns":
|
||||
ep.IsTURN = true
|
||||
ep.Secure = true
|
||||
default:
|
||||
return Endpoint{}, fmt.Errorf("unknown scheme %q", scheme)
|
||||
}
|
||||
|
||||
hostport := rest
|
||||
query := ""
|
||||
if q := strings.IndexByte(rest, '?'); q >= 0 {
|
||||
hostport = rest[:q]
|
||||
query = rest[q+1:]
|
||||
}
|
||||
|
||||
host, portStr, err := net.SplitHostPort(hostport)
|
||||
if err != nil {
|
||||
// no port; pick the default per scheme
|
||||
host = hostport
|
||||
portStr = ""
|
||||
}
|
||||
if host == "" {
|
||||
return Endpoint{}, fmt.Errorf("missing host in %q", raw)
|
||||
}
|
||||
ep.Host = host
|
||||
|
||||
// Default transport: UDP for stun/turn, TCP for stuns/turns. Overridable via ?transport=
|
||||
if ep.Secure {
|
||||
ep.Transport = TransportTLS
|
||||
} else {
|
||||
ep.Transport = TransportUDP
|
||||
}
|
||||
if query != "" {
|
||||
values, err := url.ParseQuery(query)
|
||||
if err == nil {
|
||||
if t := strings.ToLower(values.Get("transport")); t != "" {
|
||||
switch t {
|
||||
case "udp":
|
||||
ep.Transport = TransportUDP
|
||||
case "tcp":
|
||||
if ep.Secure {
|
||||
ep.Transport = TransportTLS
|
||||
} else {
|
||||
ep.Transport = TransportTCP
|
||||
}
|
||||
case "tls":
|
||||
ep.Transport = TransportTLS
|
||||
ep.Secure = true
|
||||
case "dtls":
|
||||
ep.Transport = TransportDTLS
|
||||
ep.Secure = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if portStr == "" {
|
||||
if ep.Secure {
|
||||
ep.Port = 5349
|
||||
} else {
|
||||
ep.Port = 3478
|
||||
}
|
||||
} else {
|
||||
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return Endpoint{}, fmt.Errorf("invalid port %q: %w", portStr, err)
|
||||
}
|
||||
ep.Port = uint16(p)
|
||||
}
|
||||
|
||||
return ep, nil
|
||||
}
|
||||
|
||||
// discoverEndpoints returns the list of endpoints to probe.
|
||||
//
|
||||
// If serverURI is set, it is the only endpoint. Otherwise SRV records are
|
||||
// looked up for the zone. Returned endpoints are filtered to the requested
|
||||
// transports.
|
||||
func discoverEndpoints(ctx context.Context, zone, serverURI string, transports []Transport) ([]Endpoint, error) {
|
||||
if serverURI != "" {
|
||||
ep, err := parseURI(serverURI)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterByTransport([]Endpoint{ep}, transports), nil
|
||||
}
|
||||
|
||||
zone = strings.TrimSuffix(strings.TrimSpace(zone), ".")
|
||||
if zone == "" {
|
||||
return nil, fmt.Errorf("either serverURI or zone is required")
|
||||
}
|
||||
|
||||
resolver := net.DefaultResolver
|
||||
type srvSpec struct {
|
||||
service string // _stun, _turn, _stuns, _turns
|
||||
proto string // _udp / _tcp
|
||||
isTURN bool
|
||||
secure bool
|
||||
transport Transport
|
||||
}
|
||||
specs := []srvSpec{
|
||||
{"_stun", "_udp", false, false, TransportUDP},
|
||||
{"_stun", "_tcp", false, false, TransportTCP},
|
||||
{"_stuns", "_tcp", false, true, TransportTLS},
|
||||
{"_turn", "_udp", true, false, TransportUDP},
|
||||
{"_turn", "_tcp", true, false, TransportTCP},
|
||||
{"_turns", "_tcp", true, true, TransportTLS},
|
||||
}
|
||||
|
||||
var endpoints []Endpoint
|
||||
for _, s := range specs {
|
||||
_, addrs, err := resolver.LookupSRV(ctx, strings.TrimPrefix(s.service, "_"), strings.TrimPrefix(s.proto, "_"), zone)
|
||||
if err != nil || len(addrs) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, srv := range addrs {
|
||||
ep := Endpoint{
|
||||
Host: strings.TrimSuffix(srv.Target, "."),
|
||||
Port: srv.Port,
|
||||
Transport: s.transport,
|
||||
Secure: s.secure,
|
||||
IsTURN: s.isTURN,
|
||||
Source: fmt.Sprintf("srv:%s.%s.%s", s.service, s.proto, zone),
|
||||
}
|
||||
scheme := "stun"
|
||||
if s.isTURN {
|
||||
scheme = "turn"
|
||||
}
|
||||
if s.secure {
|
||||
scheme += "s"
|
||||
}
|
||||
ep.URI = fmt.Sprintf("%s:%s:%d", scheme, ep.Host, ep.Port)
|
||||
endpoints = append(endpoints, ep)
|
||||
}
|
||||
}
|
||||
|
||||
if len(endpoints) == 0 {
|
||||
return nil, fmt.Errorf("no STUN/TURN SRV records found under %s", zone)
|
||||
}
|
||||
return filterByTransport(endpoints, transports), nil
|
||||
}
|
||||
|
||||
func filterByTransport(eps []Endpoint, allowed []Transport) []Endpoint {
|
||||
if len(allowed) == 0 {
|
||||
return eps
|
||||
}
|
||||
allow := make(map[Transport]bool, len(allowed))
|
||||
for _, t := range allowed {
|
||||
allow[t] = true
|
||||
}
|
||||
out := make([]Endpoint, 0, len(eps))
|
||||
for _, ep := range eps {
|
||||
if allow[ep.Transport] {
|
||||
out = append(out, ep)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseTransports(raw string) []Transport {
|
||||
if raw == "" {
|
||||
return []Transport{TransportUDP, TransportTCP, TransportTLS}
|
||||
}
|
||||
var out []Transport
|
||||
for _, p := range strings.Split(raw, ",") {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
switch p {
|
||||
case "udp":
|
||||
out = append(out, TransportUDP)
|
||||
case "tcp":
|
||||
out = append(out, TransportTCP)
|
||||
case "tls":
|
||||
out = append(out, TransportTLS)
|
||||
case "dtls":
|
||||
out = append(out, TransportDTLS)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
66
checker/discover_test.go
Normal file
66
checker/discover_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package checker
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseURI(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
host string
|
||||
port uint16
|
||||
transport Transport
|
||||
secure bool
|
||||
isTURN bool
|
||||
wantErr bool
|
||||
}{
|
||||
{"stun:turn.example.com", "turn.example.com", 3478, TransportUDP, false, false, false},
|
||||
{"stun:turn.example.com:3478", "turn.example.com", 3478, TransportUDP, false, false, false},
|
||||
{"stuns:turn.example.com:5349", "turn.example.com", 5349, TransportTLS, true, false, false},
|
||||
{"turn:turn.example.com:3478?transport=udp", "turn.example.com", 3478, TransportUDP, false, true, false},
|
||||
{"turn:turn.example.com:3478?transport=tcp", "turn.example.com", 3478, TransportTCP, false, true, false},
|
||||
{"turns:turn.example.com:5349?transport=tcp", "turn.example.com", 5349, TransportTLS, true, true, false},
|
||||
{"turns:turn.example.com?transport=dtls", "turn.example.com", 5349, TransportDTLS, true, true, false},
|
||||
{"http://example.com", "", 0, "", false, false, true},
|
||||
{"stun:", "", 0, "", false, false, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
ep, err := parseURI(tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("%q: expected error, got nil", tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("%q: unexpected error: %v", tc.in, err)
|
||||
continue
|
||||
}
|
||||
if ep.Host != tc.host || ep.Port != tc.port || ep.Transport != tc.transport ||
|
||||
ep.Secure != tc.secure || ep.IsTURN != tc.isTURN {
|
||||
t.Errorf("%q: got %+v, want host=%s port=%d transport=%s secure=%v isTURN=%v",
|
||||
tc.in, ep, tc.host, tc.port, tc.transport, tc.secure, tc.isTURN)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTransports(t *testing.T) {
|
||||
got := parseTransports("udp, TLS ,dtls")
|
||||
want := []Transport{TransportUDP, TransportTLS, TransportDTLS}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("index %d: got %s want %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestAPICredentials(t *testing.T) {
|
||||
c := restAPICredentials("topsecret", "alice", "example.com", 0)
|
||||
if c.Username == "" || c.Password == "" {
|
||||
t.Fatalf("empty creds: %+v", c)
|
||||
}
|
||||
if c.Realm != "example.com" {
|
||||
t.Fatalf("realm mismatch: %s", c.Realm)
|
||||
}
|
||||
}
|
||||
51
checker/discovery.go
Normal file
51
checker/discovery.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
//
|
||||
// stuns:/turns: (RFC 7064/7065) speak TLS immediately after the TCP
|
||||
// handshake, so every secure TCP-based endpoint we observed is published
|
||||
// under the tls.endpoint.v1 contract for checker-tls to pick up.
|
||||
//
|
||||
// DTLS is intentionally omitted: the current checker-tls consumer uses
|
||||
// crypto/tls and would not probe a datagram-TLS endpoint correctly. Emitting
|
||||
// a DTLS entry today would only produce orphan lineage.
|
||||
//
|
||||
// SNI is left empty (= Host); no STARTTLS upgrade applies; the scheme
|
||||
// mandates direct TLS on the wire.
|
||||
func (p *stunTurnProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*StunTurnData)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected data type %T", data)
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var out []sdk.DiscoveryEntry
|
||||
for _, ep := range d.Endpoints {
|
||||
if !ep.Dial.OK {
|
||||
continue
|
||||
}
|
||||
if !ep.Endpoint.Secure || ep.Endpoint.Transport == TransportDTLS {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", ep.Endpoint.Host, ep.Endpoint.Port)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
|
||||
Host: ep.Endpoint.Host,
|
||||
Port: ep.Endpoint.Port,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
77
checker/interactive.go
Normal file
77
checker/interactive.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// RenderForm exposes the inputs needed to drive a STUN/TURN check from
|
||||
// the standalone HTML form. The fields mirror the canonical option
|
||||
// documentation in Definition() (RunOpts then UserOpts) so the form
|
||||
// stays in lock-step with the JSON schema served at /definition.
|
||||
func (p *stunTurnProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
def := p.Definition()
|
||||
fields := make([]sdk.CheckerOptionField, 0, len(def.Options.RunOpts)+len(def.Options.UserOpts))
|
||||
fields = append(fields, def.Options.RunOpts...)
|
||||
fields = append(fields, def.Options.UserOpts...)
|
||||
return fields
|
||||
}
|
||||
|
||||
// ParseForm turns the submitted form into a CheckerOptions. At least one
|
||||
// of zone or serverURI must be provided so discoverEndpoints has
|
||||
// something to work with.
|
||||
func (p *stunTurnProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
zone := strings.TrimSpace(r.FormValue("zone"))
|
||||
zone = strings.TrimSuffix(zone, ".")
|
||||
uri := strings.TrimSpace(r.FormValue("serverURI"))
|
||||
if zone == "" && uri == "" {
|
||||
return nil, errors.New("either zone or serverURI is required")
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{}
|
||||
if zone != "" {
|
||||
opts["zone"] = zone
|
||||
}
|
||||
if uri != "" {
|
||||
opts["serverURI"] = uri
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(r.FormValue("mode")); v != "" {
|
||||
switch v {
|
||||
case "auto", "stun", "turn":
|
||||
opts["mode"] = v
|
||||
default:
|
||||
return nil, errors.New("mode must be one of: auto, stun, turn")
|
||||
}
|
||||
}
|
||||
|
||||
for _, k := range []string{"username", "credential", "sharedSecret", "realm", "transports", "probePeer"} {
|
||||
if v := strings.TrimSpace(r.FormValue(k)); v != "" {
|
||||
opts[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if v := r.FormValue("testChannelBind"); v != "" {
|
||||
opts["testChannelBind"] = v == "true" || v == "on" || v == "1"
|
||||
}
|
||||
|
||||
for _, k := range []string{"warningRTT", "criticalRTT", "timeout"} {
|
||||
v := strings.TrimSpace(r.FormValue(k))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return nil, errors.New(k + " must be a non-negative integer")
|
||||
}
|
||||
opts[k] = int(n)
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
16
checker/provider.go
Normal file
16
checker/provider.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new STUN/TURN observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &stunTurnProvider{}
|
||||
}
|
||||
|
||||
type stunTurnProvider struct{}
|
||||
|
||||
func (p *stunTurnProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyStunTurn
|
||||
}
|
||||
400
checker/report.go
Normal file
400
checker/report.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type tmplHint struct {
|
||||
StatusCSS string
|
||||
StatusText string
|
||||
Message string
|
||||
Fix string
|
||||
}
|
||||
|
||||
type tmplObservation struct {
|
||||
Name string
|
||||
Status string // "ok" | "fail" | "info" (for neutral data badge)
|
||||
Detail string
|
||||
Error string
|
||||
}
|
||||
|
||||
type tmplEndpoint struct {
|
||||
URI string
|
||||
Transport string
|
||||
Source string
|
||||
Open bool
|
||||
BadgeText string
|
||||
BadgeCSS string
|
||||
ResolvedIPs []string
|
||||
Observations []tmplObservation
|
||||
Hints []tmplHint
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
Zone string
|
||||
Mode string
|
||||
OverallText string
|
||||
OverallCSS string
|
||||
HeadlineFix string
|
||||
HeadlineDetail string
|
||||
GlobalError string
|
||||
GlobalHints []tmplHint // hints with no endpoint subject
|
||||
Endpoints []tmplEndpoint
|
||||
}
|
||||
|
||||
var stunturnTemplate = template.Must(template.New("stunturn").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>STUN/TURN Report</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 1rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; color: #1f2937; background: #f3f4f6; line-height: 1.5; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; }
|
||||
h2 { margin: 0 0 .4rem; font-size: 1rem; }
|
||||
.hd { background: #fff; border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
.badge { display: inline-flex; align-items: center; padding: .2em .65em; border-radius: 9999px; font-size: .78rem; font-weight: 700; letter-spacing: .02em; }
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.info { background: #dbeafe; color: #1e3a8a; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.crit { background: #fee2e2; color: #991b1b; }
|
||||
.error { background: #fee2e2; color: #991b1b; }
|
||||
.skipped, .unknown { background: #e5e7eb; color: #374151; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.neutral { background: #e5e7eb; color: #374151; }
|
||||
.headline-fix { background: #fef3c7; border-left: 4px solid #f59e0b; padding: .75rem 1rem; border-radius: 6px; margin-top: .6rem; }
|
||||
.headline-fix strong { color: #78350f; }
|
||||
.global-err { background: #fee2e2; border-left: 4px solid #dc2626; padding: .75rem 1rem; border-radius: 6px; }
|
||||
details { background: #fff; border-radius: 8px; margin-bottom: .45rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden; }
|
||||
summary { display: flex; align-items: center; gap: .5rem; padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.ep-uri { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .9rem; }
|
||||
.body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: .4rem; }
|
||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
.fix { color: #92400e; font-size: .82rem; }
|
||||
.err { color: #b91c1c; font-size: .82rem; }
|
||||
.section-title { font-size: .78rem; color: #6b7280; text-transform: uppercase; letter-spacing: .04em; margin: .6rem 0 .2rem; }
|
||||
.hint { padding: .5rem .65rem; border-radius: 6px; margin-bottom: .35rem; background: #f9fafb; }
|
||||
.hint-msg { font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>STUN/TURN check</h1>
|
||||
<span class="badge {{.OverallCSS}}">{{.OverallText}}</span>
|
||||
<div class="meta">
|
||||
{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{end}}
|
||||
Mode: <code>{{.Mode}}</code> ·
|
||||
{{len .Endpoints}} endpoint(s) probed
|
||||
</div>
|
||||
{{if .HeadlineFix}}
|
||||
<div class="headline-fix">
|
||||
<strong>How to fix:</strong> {{.HeadlineFix}}
|
||||
{{if .HeadlineDetail}}<div class="err" style="margin-top:.3rem">{{.HeadlineDetail}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .GlobalError}}
|
||||
<div class="hd"><div class="global-err"><strong>Discovery failed:</strong> {{.GlobalError}}</div></div>
|
||||
{{end}}
|
||||
|
||||
{{if .GlobalHints}}
|
||||
<div class="hd">
|
||||
<h2>Global findings</h2>
|
||||
{{range .GlobalHints}}
|
||||
<div class="hint">
|
||||
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
|
||||
<span class="hint-msg">{{.Message}}</span>
|
||||
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{range .Endpoints}}
|
||||
<details{{if .Open}} open{{end}}>
|
||||
<summary>
|
||||
<span class="ep-uri">{{.URI}}</span>
|
||||
<span class="badge {{.BadgeCSS}}">{{.BadgeText}}</span>
|
||||
</summary>
|
||||
<div class="body">
|
||||
<p class="meta">Transport: <code>{{.Transport}}</code> · Source: <code>{{.Source}}</code>
|
||||
{{if .ResolvedIPs}} · Resolved: <code>{{range $i, $ip := .ResolvedIPs}}{{if $i}}, {{end}}{{$ip}}{{end}}</code>{{end}}
|
||||
</p>
|
||||
|
||||
{{if .Hints}}
|
||||
<div class="section-title">Findings</div>
|
||||
{{range .Hints}}
|
||||
<div class="hint">
|
||||
<span class="badge {{.StatusCSS}}">{{.StatusText}}</span>
|
||||
<span class="hint-msg">{{.Message}}</span>
|
||||
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .Observations}}
|
||||
<div class="section-title">Observations</div>
|
||||
<table>
|
||||
<tr><th>Probe</th><th>Result</th><th>Detail</th></tr>
|
||||
{{range .Observations}}
|
||||
<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td><span class="badge {{.Status}}">{{.Status}}</span></td>
|
||||
<td>
|
||||
{{if .Detail}}{{.Detail}}{{end}}
|
||||
{{if .Error}}<div class="err">⚠ {{.Error}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||||
func (p *stunTurnProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var d StunTurnData
|
||||
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal stun/turn report: %w", err)
|
||||
}
|
||||
|
||||
states := ctx.States()
|
||||
|
||||
// Group hints by endpoint subject.
|
||||
type group struct {
|
||||
worst sdk.Status
|
||||
hints []tmplHint
|
||||
first *tmplHint // first failing hint (for headline/open logic)
|
||||
}
|
||||
byEp := make(map[string]*group)
|
||||
global := &group{worst: sdk.StatusOK}
|
||||
|
||||
for _, st := range states {
|
||||
fix := ""
|
||||
if st.Meta != nil {
|
||||
if v, ok := st.Meta["fix"].(string); ok {
|
||||
fix = v
|
||||
}
|
||||
}
|
||||
css, txt := statusBadge(st.Status)
|
||||
h := tmplHint{
|
||||
StatusCSS: css,
|
||||
StatusText: txt,
|
||||
Message: st.Message,
|
||||
Fix: fix,
|
||||
}
|
||||
var g *group
|
||||
if st.Subject == "" {
|
||||
g = global
|
||||
} else {
|
||||
g = byEp[st.Subject]
|
||||
if g == nil {
|
||||
g = &group{worst: sdk.StatusOK}
|
||||
byEp[st.Subject] = g
|
||||
}
|
||||
}
|
||||
g.hints = append(g.hints, h)
|
||||
if statusSeverity(st.Status) > statusSeverity(g.worst) {
|
||||
g.worst = st.Status
|
||||
}
|
||||
if g.first == nil && isFailing(st.Status) {
|
||||
hh := h
|
||||
g.first = &hh
|
||||
}
|
||||
}
|
||||
|
||||
td := tmplData{
|
||||
Zone: d.Zone,
|
||||
Mode: d.Mode,
|
||||
GlobalError: d.GlobalError,
|
||||
GlobalHints: global.hints,
|
||||
}
|
||||
|
||||
worst := sdk.StatusOK
|
||||
if statusSeverity(global.worst) > statusSeverity(worst) {
|
||||
worst = global.worst
|
||||
}
|
||||
|
||||
var headlineFix, headlineDetail string
|
||||
if global.first != nil && global.first.Fix != "" {
|
||||
headlineFix = global.first.Fix
|
||||
headlineDetail = global.first.Message
|
||||
}
|
||||
|
||||
for _, ep := range d.Endpoints {
|
||||
subj := epSubject(ep.Endpoint)
|
||||
g := byEp[subj]
|
||||
te := tmplEndpoint{
|
||||
URI: ep.Endpoint.URI,
|
||||
Transport: string(ep.Endpoint.Transport),
|
||||
Source: ep.Endpoint.Source,
|
||||
ResolvedIPs: ep.ResolvedIPs,
|
||||
Observations: observationsFor(ep),
|
||||
}
|
||||
epWorst := sdk.StatusOK
|
||||
if g != nil {
|
||||
te.Hints = g.hints
|
||||
epWorst = g.worst
|
||||
if statusSeverity(g.worst) > statusSeverity(worst) {
|
||||
worst = g.worst
|
||||
}
|
||||
if headlineFix == "" && g.first != nil && g.first.Fix != "" {
|
||||
headlineFix = g.first.Fix
|
||||
headlineDetail = fmt.Sprintf("[%s] %s", ep.Endpoint.URI, g.first.Message)
|
||||
}
|
||||
}
|
||||
te.BadgeCSS, te.BadgeText = statusBadge(epWorst)
|
||||
te.Open = isFailing(epWorst)
|
||||
td.Endpoints = append(td.Endpoints, te)
|
||||
}
|
||||
|
||||
td.OverallCSS, td.OverallText = statusBadge(worst)
|
||||
td.HeadlineFix = headlineFix
|
||||
td.HeadlineDetail = headlineDetail
|
||||
|
||||
var buf strings.Builder
|
||||
if err := stunturnTemplate.Execute(&buf, td); err != nil {
|
||||
return "", fmt.Errorf("render stun/turn report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// observationsFor renders the raw probe observations for an endpoint,
|
||||
// without any severity or fix guidance. This is the neutral, data-only
|
||||
// view used both as the default and as a fallback when no CheckStates
|
||||
// are available.
|
||||
func observationsFor(ep EndpointProbe) []tmplObservation {
|
||||
var out []tmplObservation
|
||||
|
||||
// Dial
|
||||
dialName := fmt.Sprintf("dial:%s", ep.Endpoint.Transport)
|
||||
if ep.Dial.OK {
|
||||
detail := fmt.Sprintf("connected to %s in %d ms", ep.Dial.RemoteAddr, ep.Dial.DurationMs)
|
||||
if ep.Dial.TLSVersion != "" {
|
||||
detail += fmt.Sprintf("; TLS %s, %s, peer CN=%s", ep.Dial.TLSVersion, ep.Dial.TLSCipher, ep.Dial.TLSPeerCN)
|
||||
}
|
||||
if ep.Dial.DTLSHandshake {
|
||||
detail += "; DTLS handshake completed"
|
||||
}
|
||||
out = append(out, tmplObservation{Name: dialName, Status: "ok", Detail: detail})
|
||||
} else {
|
||||
out = append(out, tmplObservation{Name: dialName, Status: "fail", Error: ep.Dial.Error})
|
||||
return out
|
||||
}
|
||||
|
||||
// STUN binding
|
||||
if ep.STUNBinding.Attempted {
|
||||
if ep.STUNBinding.OK {
|
||||
detail := fmt.Sprintf("reflexive address: %s (RTT %d ms)", ep.STUNBinding.ReflexiveAddr, ep.STUNBinding.RTTMs)
|
||||
if ep.STUNBinding.IsPrivateMapped {
|
||||
detail += " [private]"
|
||||
}
|
||||
out = append(out, tmplObservation{Name: "stun_binding", Status: "ok", Detail: detail})
|
||||
} else {
|
||||
out = append(out, tmplObservation{Name: "stun_binding", Status: "fail", Error: ep.STUNBinding.Error})
|
||||
}
|
||||
}
|
||||
|
||||
// TURN no-auth probe
|
||||
if ep.TURNNoAuth.Attempted {
|
||||
switch {
|
||||
case ep.TURNNoAuth.OK:
|
||||
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "allocation accepted without authentication"})
|
||||
case ep.TURNNoAuth.UnauthChallenge:
|
||||
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "ok", Detail: "server challenged the unauthenticated allocate (401)"})
|
||||
default:
|
||||
out = append(out, tmplObservation{Name: "turn_allocate_noauth", Status: "info", Detail: fmt.Sprintf("code=%d %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason)})
|
||||
}
|
||||
}
|
||||
|
||||
// TURN authenticated allocate
|
||||
if ep.TURNAuth.Attempted {
|
||||
if ep.TURNAuth.OK {
|
||||
detail := fmt.Sprintf("relay address: %s (%d ms)", ep.TURNAuth.RelayAddr, ep.TURNAuth.DurationMs)
|
||||
if ep.TURNAuth.IsPrivateRelay {
|
||||
detail += " [private]"
|
||||
}
|
||||
out = append(out, tmplObservation{Name: "turn_allocate_auth", Status: "ok", Detail: detail})
|
||||
} else {
|
||||
out = append(out, tmplObservation{
|
||||
Name: "turn_allocate_auth",
|
||||
Status: "fail",
|
||||
Detail: fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode),
|
||||
Error: ep.TURNAuth.Error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Relay echo
|
||||
if ep.RelayEcho.Attempted {
|
||||
if ep.RelayEcho.OK {
|
||||
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "ok", Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", ep.RelayEcho.PeerAddr)})
|
||||
} else {
|
||||
out = append(out, tmplObservation{Name: "turn_relay_echo", Status: "fail", Error: ep.RelayEcho.Error})
|
||||
}
|
||||
}
|
||||
|
||||
if ep.ChannelBindRun {
|
||||
out = append(out, tmplObservation{Name: "turn_channel_bind", Status: "info", Detail: "ChannelBind exercised implicitly by relay traffic"})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func statusBadge(s sdk.Status) (cssClass, label string) {
|
||||
switch s {
|
||||
case sdk.StatusOK:
|
||||
return "ok", "OK"
|
||||
case sdk.StatusInfo:
|
||||
return "info", "INFO"
|
||||
case sdk.StatusWarn:
|
||||
return "warn", "WARN"
|
||||
case sdk.StatusCrit:
|
||||
return "crit", "CRIT"
|
||||
case sdk.StatusError:
|
||||
return "error", "ERROR"
|
||||
case sdk.StatusUnknown:
|
||||
return "unknown", "UNKNOWN"
|
||||
}
|
||||
return "info", s.String()
|
||||
}
|
||||
|
||||
func statusSeverity(s sdk.Status) int {
|
||||
switch s {
|
||||
case sdk.StatusOK:
|
||||
return 0
|
||||
case sdk.StatusUnknown:
|
||||
return 1
|
||||
case sdk.StatusInfo:
|
||||
return 2
|
||||
case sdk.StatusWarn:
|
||||
return 3
|
||||
case sdk.StatusCrit:
|
||||
return 4
|
||||
case sdk.StatusError:
|
||||
return 5
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func isFailing(s sdk.Status) bool {
|
||||
return s == sdk.StatusWarn || s == sdk.StatusCrit || s == sdk.StatusError
|
||||
}
|
||||
83
checker/rule.go
Normal file
83
checker/rule.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rules returns the list of CheckRules exposed by the STUN/TURN checker.
|
||||
// Each concern is its own rule (SRV for STUN, SRV for TURN, STUN
|
||||
// binding, TURN open-relay probe, TURN authenticated allocation, relay
|
||||
// echo, TLS transport, IPv6 coverage, …) so the UI can show a granular
|
||||
// status instead of a single aggregated one.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&discoveryRule{},
|
||||
&srvStunRule{},
|
||||
&srvTurnRule{},
|
||||
&dialRule{},
|
||||
&stunBindingRule{},
|
||||
&stunReflexivePublicRule{},
|
||||
&stunLatencyRule{},
|
||||
&turnOpenRelayRule{},
|
||||
&turnAuthRule{},
|
||||
&turnRelayPublicRule{},
|
||||
&turnRelayEchoRule{},
|
||||
&turnTLSTransportRule{},
|
||||
&ipv6CoverageRule{},
|
||||
}
|
||||
}
|
||||
|
||||
// loadData fetches the observation; on error returns a CheckState that
|
||||
// callers should emit directly.
|
||||
func loadData(ctx context.Context, obs sdk.ObservationGetter) (*StunTurnData, *sdk.CheckState) {
|
||||
var data StunTurnData
|
||||
if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil {
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err),
|
||||
Code: "stun_turn.observation_error",
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func passState(code, msg string) sdk.CheckState {
|
||||
return sdk.CheckState{Status: sdk.StatusOK, Message: msg, Code: code}
|
||||
}
|
||||
|
||||
func skippedState(code, msg string) sdk.CheckState {
|
||||
return sdk.CheckState{Status: sdk.StatusUnknown, Message: msg, Code: code}
|
||||
}
|
||||
|
||||
func epSubject(ep Endpoint) string {
|
||||
if ep.URI != "" {
|
||||
return ep.URI
|
||||
}
|
||||
return fmt.Sprintf("%s:%d/%s", ep.Host, ep.Port, ep.Transport)
|
||||
}
|
||||
|
||||
// hasTURNEndpoint reports whether the observation contains at least one
|
||||
// TURN endpoint (excluding STUN-only endpoints).
|
||||
func hasTURNEndpoint(data *StunTurnData) bool {
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Endpoint.IsTURN {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// joinMsg concatenates non-empty parts with ": " between them.
|
||||
func joinMsg(parts ...string) string {
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if strings.TrimSpace(p) != "" {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(out, ": ")
|
||||
}
|
||||
186
checker/rules_discovery.go
Normal file
186
checker/rules_discovery.go
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// discoveryRule reports the outcome of endpoint discovery (URI parse
|
||||
// or SRV lookup). If Collect recorded a GlobalError, this is where it
|
||||
// surfaces.
|
||||
type discoveryRule struct{}
|
||||
|
||||
func (r *discoveryRule) Name() string { return "stun_turn.discovery" }
|
||||
func (r *discoveryRule) Description() string {
|
||||
return "Verifies that at least one STUN/TURN endpoint could be discovered (explicit URI or SRV lookup)."
|
||||
}
|
||||
|
||||
func (r *discoveryRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.GlobalError != "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: data.GlobalError,
|
||||
Code: "stun_turn.discovery.error",
|
||||
}}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: "no endpoints to probe",
|
||||
Code: "stun_turn.discovery.empty",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{passState("stun_turn.discovery.ok",
|
||||
fmt.Sprintf("%d endpoint(s) discovered", len(data.Endpoints)))}
|
||||
}
|
||||
|
||||
// srvStunRule verifies that at least one STUN (non-TURN) SRV/URI endpoint
|
||||
// was obtained. Only meaningful in SRV-discovery mode.
|
||||
type srvStunRule struct{}
|
||||
|
||||
func (r *srvStunRule) Name() string { return "stun_turn.srv_stun" }
|
||||
func (r *srvStunRule) Description() string {
|
||||
return "Verifies that at least one STUN endpoint is reachable via SRV (_stun/_stuns) or an explicit URI."
|
||||
}
|
||||
|
||||
func (r *srvStunRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.GlobalError != "" {
|
||||
return []sdk.CheckState{skippedState("stun_turn.srv_stun.skipped",
|
||||
"Discovery failed, SRV coverage could not be evaluated.")}
|
||||
}
|
||||
// Count endpoints whose source indicates STUN SRV (or URI form).
|
||||
var stunCount, turnCount int
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Endpoint.IsTURN {
|
||||
turnCount++
|
||||
} else {
|
||||
stunCount++
|
||||
}
|
||||
}
|
||||
if stunCount == 0 && turnCount > 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "stun_turn.srv_stun.none",
|
||||
Message: "No STUN-only endpoint discovered (TURN endpoints also expose STUN).",
|
||||
}}
|
||||
}
|
||||
if stunCount == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.srv_stun.missing",
|
||||
Message: "No STUN endpoint discovered; clients may fail to obtain a reflexive address.",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{passState("stun_turn.srv_stun.ok",
|
||||
fmt.Sprintf("%d STUN endpoint(s) discovered", stunCount))}
|
||||
}
|
||||
|
||||
// srvTurnRule verifies TURN endpoint coverage.
|
||||
type srvTurnRule struct{}
|
||||
|
||||
func (r *srvTurnRule) Name() string { return "stun_turn.srv_turn" }
|
||||
func (r *srvTurnRule) Description() string {
|
||||
return "Verifies that at least one TURN endpoint is reachable via SRV (_turn/_turns) or an explicit URI."
|
||||
}
|
||||
|
||||
func (r *srvTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.GlobalError != "" {
|
||||
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
|
||||
"Discovery failed, TURN coverage could not be evaluated.")}
|
||||
}
|
||||
if data.Mode == "stun" {
|
||||
return []sdk.CheckState{skippedState("stun_turn.srv_turn.skipped",
|
||||
"TURN coverage not evaluated (mode=stun).")}
|
||||
}
|
||||
var turnCount int
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Endpoint.IsTURN {
|
||||
turnCount++
|
||||
}
|
||||
}
|
||||
if turnCount == 0 {
|
||||
sev := sdk.StatusWarn
|
||||
if data.Mode == "turn" {
|
||||
sev = sdk.StatusCrit
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: sev,
|
||||
Code: "stun_turn.srv_turn.missing",
|
||||
Message: "No TURN endpoint discovered; clients behind symmetric NAT will have no relay path.",
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{passState("stun_turn.srv_turn.ok",
|
||||
fmt.Sprintf("%d TURN endpoint(s) discovered", turnCount))}
|
||||
}
|
||||
|
||||
// dialRule reports connectivity/handshake failures per endpoint.
|
||||
type dialRule struct{}
|
||||
|
||||
func (r *dialRule) Name() string { return "stun_turn.dial" }
|
||||
func (r *dialRule) Description() string {
|
||||
return "Verifies that every discovered endpoint accepts a connection (TCP/TLS handshake or UDP socket)."
|
||||
}
|
||||
|
||||
func (r *dialRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.GlobalError != "" || len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{skippedState("stun_turn.dial.skipped", "No endpoint to evaluate.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Dial.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.dial.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: ep.Dial.Error,
|
||||
Meta: map[string]any{"fix": dialFix(ep.Endpoint, ep.Dial.Error)},
|
||||
})
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.dial.ok", "All discovered endpoints accepted a connection.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// dialFix mirrors the fix phrasing the old Collect emitted for dial
|
||||
// failures. Kept verbatim so users keep the same remediation guidance.
|
||||
func dialFix(ep Endpoint, errMsg string) string {
|
||||
msg := strings.ToLower(errMsg)
|
||||
switch {
|
||||
case strings.Contains(msg, "no such host"):
|
||||
return fmt.Sprintf("Hostname `%s` does not resolve. Add the matching A/AAAA record (or fix typos in the URI).", ep.Host)
|
||||
case strings.Contains(msg, "tls handshake"), strings.Contains(msg, "x509"):
|
||||
return fmt.Sprintf("TLS handshake failed for `%s`. Reissue the certificate covering this hostname (e.g. via Let's Encrypt) and reload the server (coturn: `cert=` and `pkey=`).", ep.Host)
|
||||
case strings.Contains(msg, "connection refused"):
|
||||
return fmt.Sprintf("Nothing is listening on %s/%d. Start the server with the appropriate listening port (coturn: `listening-port=`/`tls-listening-port=`).", ep.Host, ep.Port)
|
||||
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline"):
|
||||
switch ep.Transport {
|
||||
case TransportUDP:
|
||||
return "No reply on UDP. Open the UDP port inbound and verify your network does not block UDP egress."
|
||||
default:
|
||||
return "Connection timed out. A firewall or NAT is likely blocking this port."
|
||||
}
|
||||
}
|
||||
return "Could not establish a connection to the server."
|
||||
}
|
||||
154
checker/rules_stun.go
Normal file
154
checker/rules_stun.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// stunBindingRule verifies that the STUN Binding request succeeds on every
|
||||
// reachable endpoint (returns a reflexive address).
|
||||
type stunBindingRule struct{}
|
||||
|
||||
func (r *stunBindingRule) Name() string { return "stun_turn.stun_binding" }
|
||||
func (r *stunBindingRule) Description() string {
|
||||
return "Verifies that the STUN Binding request receives a XOR-MAPPED-ADDRESS reply."
|
||||
}
|
||||
|
||||
func (r *stunBindingRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.Dial.OK || !ep.STUNBinding.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if ep.STUNBinding.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.stun_binding.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: ep.STUNBinding.Error,
|
||||
Meta: map[string]any{
|
||||
"fix": "Server did not answer the STUN Binding Request. Check that the STUN service is actually listening on this transport, and that no middlebox is filtering RFC 5389 traffic.",
|
||||
},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.stun_binding.skipped", "No endpoint completed a dial, STUN binding not evaluated.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.stun_binding.ok", "STUN Binding succeeded on every reachable endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// stunReflexivePublicRule flags servers that return a private/loopback
|
||||
// reflexive address (typically a TURN server behind NAT with missing
|
||||
// external-ip configuration).
|
||||
type stunReflexivePublicRule struct{}
|
||||
|
||||
func (r *stunReflexivePublicRule) Name() string { return "stun_turn.reflexive_public" }
|
||||
func (r *stunReflexivePublicRule) Description() string {
|
||||
return "Flags endpoints that return a private/loopback reflexive address (server unaware of its public IP)."
|
||||
}
|
||||
|
||||
func (r *stunReflexivePublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.STUNBinding.OK {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if !ep.STUNBinding.IsPrivateMapped {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.reflexive_public.private",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("server returned a private/loopback IP: %s", ep.STUNBinding.ReflexiveAddr),
|
||||
Meta: map[string]any{
|
||||
"fix": "Server appears to be behind NAT and unaware of its public IP. Set `external-ip=<public>` (coturn) or the equivalent on your TURN server.",
|
||||
},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.reflexive_public.skipped", "No successful STUN Binding to evaluate.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.reflexive_public.ok", "Every reflexive address is public.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// stunLatencyRule folds the warningRTT / criticalRTT thresholds the old
|
||||
// Collect hard-coded into a dedicated rule.
|
||||
type stunLatencyRule struct{}
|
||||
|
||||
func (r *stunLatencyRule) Name() string { return "stun_turn.stun_latency" }
|
||||
func (r *stunLatencyRule) Description() string {
|
||||
return "Compares the STUN Binding RTT against the configured warning/critical thresholds."
|
||||
}
|
||||
|
||||
func (r *stunLatencyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
warn := time.Duration(sdk.GetIntOption(opts, "warningRTT", int(data.WarningRTTMs))) * time.Millisecond
|
||||
crit := time.Duration(sdk.GetIntOption(opts, "criticalRTT", int(data.CriticalRTT))) * time.Millisecond
|
||||
if warn <= 0 {
|
||||
warn = 200 * time.Millisecond
|
||||
}
|
||||
if crit <= 0 {
|
||||
crit = 1000 * time.Millisecond
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.STUNBinding.OK {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
rtt := time.Duration(ep.STUNBinding.RTTMs) * time.Millisecond
|
||||
switch {
|
||||
case rtt > crit:
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.stun_latency.critical",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("STUN RTT %dms exceeds critical threshold %dms", ep.STUNBinding.RTTMs, crit.Milliseconds()),
|
||||
Meta: map[string]any{"fix": "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."},
|
||||
})
|
||||
case rtt > warn:
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.stun_latency.high",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("STUN RTT %dms exceeds warning threshold %dms", ep.STUNBinding.RTTMs, warn.Milliseconds()),
|
||||
Meta: map[string]any{"fix": "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."},
|
||||
})
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.stun_latency.skipped", "No successful STUN Binding to evaluate.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.stun_latency.ok", "STUN RTT within acceptable thresholds on every endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
108
checker/rules_transport.go
Normal file
108
checker/rules_transport.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// turnTLSTransportRule evaluates whether a TLS-capable transport is
|
||||
// available and reports its version/cipher metadata. Kept separate from
|
||||
// the generic dial rule because TURN-TLS deployments often need dedicated
|
||||
// attention (port 5349, certificate covering the TURN hostname, …).
|
||||
type turnTLSTransportRule struct{}
|
||||
|
||||
func (r *turnTLSTransportRule) Name() string { return "stun_turn.tls_transport" }
|
||||
func (r *turnTLSTransportRule) Description() string {
|
||||
return "Verifies that at least one TLS/DTLS transport (stuns/turns) succeeds when present in the endpoint set."
|
||||
}
|
||||
|
||||
func (r *turnTLSTransportRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var (
|
||||
secureCount, secureOK int
|
||||
states []sdk.CheckState
|
||||
)
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.Endpoint.Secure {
|
||||
continue
|
||||
}
|
||||
secureCount++
|
||||
if ep.Dial.OK {
|
||||
secureOK++
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.tls_transport.handshake_failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: ep.Dial.Error,
|
||||
Meta: map[string]any{
|
||||
"fix": "TLS/DTLS handshake failed. Reissue a certificate covering the TURN hostname and reload the server (coturn: `cert=`/`pkey=`).",
|
||||
},
|
||||
})
|
||||
}
|
||||
if secureCount == 0 {
|
||||
return []sdk.CheckState{skippedState("stun_turn.tls_transport.skipped", "No secure (stuns/turns) endpoint discovered.")}
|
||||
}
|
||||
if secureOK == 0 {
|
||||
// All secure endpoints failed, already emitted per-endpoint
|
||||
// crit states; return them as-is.
|
||||
return states
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.tls_transport.ok", "TLS/DTLS handshake succeeded on every secure endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// ipv6CoverageRule verifies at least one endpoint resolves to an IPv6
|
||||
// address. It never marks an endpoint failed: missing AAAA records are a
|
||||
// coverage concern, not an error.
|
||||
type ipv6CoverageRule struct{}
|
||||
|
||||
func (r *ipv6CoverageRule) Name() string { return "stun_turn.ipv6_coverage" }
|
||||
func (r *ipv6CoverageRule) Description() string {
|
||||
return "Verifies at least one STUN/TURN hostname resolves to an IPv6 address."
|
||||
}
|
||||
|
||||
func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "No endpoint discovered.")}
|
||||
}
|
||||
var anyResolved, anyV6 bool
|
||||
for _, ep := range data.Endpoints {
|
||||
for _, ipStr := range ep.ResolvedIPs {
|
||||
anyResolved = true
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip != nil && ip.To4() == nil && strings.Contains(ipStr, ":") {
|
||||
anyV6 = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if anyV6 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !anyResolved {
|
||||
return []sdk.CheckState{skippedState("stun_turn.ipv6_coverage.skipped", "Hostname resolution data unavailable.")}
|
||||
}
|
||||
if !anyV6 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.ipv6_coverage.missing",
|
||||
Message: "No STUN/TURN endpoint resolves to an IPv6 address; IPv6-only clients will have no reachable server.",
|
||||
Meta: map[string]any{"fix": "Publish AAAA records for your STUN/TURN hostnames and ensure the server listens on IPv6."},
|
||||
}}
|
||||
}
|
||||
return []sdk.CheckState{passState("stun_turn.ipv6_coverage.ok", "At least one endpoint resolves to an IPv6 address.")}
|
||||
}
|
||||
210
checker/rules_turn.go
Normal file
210
checker/rules_turn.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// turnOpenRelayRule flags servers that accept an unauthenticated TURN
|
||||
// Allocate (open relay, abuse vector) and warns on non-standard replies.
|
||||
type turnOpenRelayRule struct{}
|
||||
|
||||
func (r *turnOpenRelayRule) Name() string { return "stun_turn.turn_open_relay" }
|
||||
func (r *turnOpenRelayRule) Description() string {
|
||||
return "Verifies the TURN server requires authentication (challenges unauthenticated Allocate with 401)."
|
||||
}
|
||||
|
||||
func (r *turnOpenRelayRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Mode == "stun" || !hasTURNEndpoint(data) {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN endpoint to evaluate.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNNoAuth.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
switch {
|
||||
case ep.TURNNoAuth.OK:
|
||||
// Allocate accepted without credentials => open relay.
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.turn_open_relay.open",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: "TURN allocation accepted without authentication",
|
||||
Meta: map[string]any{"fix": "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification."},
|
||||
})
|
||||
case ep.TURNNoAuth.UnauthChallenge:
|
||||
// Expected 401 + REALM/NONCE, nothing to emit, pass below.
|
||||
default:
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.turn_open_relay.unexpected",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("unexpected response (code=%d): %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason),
|
||||
Meta: map[string]any{"fix": "Server did not behave like a standard TURN. Verify it actually implements RFC 5766."},
|
||||
})
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN Allocate probe attempted.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.turn_open_relay.ok", "Server correctly challenged unauthenticated Allocate requests.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnAuthRule evaluates the outcome of the authenticated TURN Allocate.
|
||||
type turnAuthRule struct{}
|
||||
|
||||
func (r *turnAuthRule) Name() string { return "stun_turn.turn_auth" }
|
||||
func (r *turnAuthRule) Description() string {
|
||||
return "Verifies the supplied TURN credentials (or REST shared secret) yield a successful Allocate."
|
||||
}
|
||||
|
||||
func (r *turnAuthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if data.Mode == "stun" || !hasTURNEndpoint(data) {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN endpoint to evaluate.")}
|
||||
}
|
||||
if !data.HasCreds {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN credentials supplied; authenticated Allocate not attempted.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNAuth.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if ep.TURNAuth.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.turn_auth.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: joinMsg(ep.TURNAuth.Error, fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode)),
|
||||
Meta: map[string]any{"fix": allocateFix(ep.TURNAuth.ErrorCode)},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "Authenticated Allocate not attempted on any endpoint.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.turn_auth.ok", "Authenticated TURN Allocate succeeded on every endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnRelayPublicRule flags private relay addresses (missing relay-ip).
|
||||
type turnRelayPublicRule struct{}
|
||||
|
||||
func (r *turnRelayPublicRule) Name() string { return "stun_turn.relay_public" }
|
||||
func (r *turnRelayPublicRule) Description() string {
|
||||
return "Flags TURN servers whose allocated relay address is private/loopback (missing public relay-ip)."
|
||||
}
|
||||
|
||||
func (r *turnRelayPublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TURNAuth.OK {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if !ep.TURNAuth.IsPrivateRelay {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "stun_turn.relay_public.private",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: fmt.Sprintf("relay address is private: %s", ep.TURNAuth.RelayAddr),
|
||||
Meta: map[string]any{"fix": "Set `relay-ip=<public>` (coturn). The relay range must be publicly reachable for clients to use TURN."},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.relay_public.skipped", "No successful TURN allocation to evaluate.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.relay_public.ok", "Every relay address is public.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// turnRelayEchoRule reports relay-path breakage.
|
||||
type turnRelayEchoRule struct{}
|
||||
|
||||
func (r *turnRelayEchoRule) Name() string { return "stun_turn.relay_echo" }
|
||||
func (r *turnRelayEchoRule) Description() string {
|
||||
return "Verifies the TURN relay path can carry traffic to the configured probe peer (CreatePermission + Send)."
|
||||
}
|
||||
|
||||
func (r *turnRelayEchoRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
seen := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.RelayEcho.Attempted {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if ep.RelayEcho.OK {
|
||||
continue
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "stun_turn.relay_echo.failed",
|
||||
Subject: epSubject(ep.Endpoint),
|
||||
Message: ep.RelayEcho.Error,
|
||||
Meta: map[string]any{"fix": "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn)."},
|
||||
})
|
||||
}
|
||||
if !seen {
|
||||
return []sdk.CheckState{skippedState("stun_turn.relay_echo.skipped", "No relay allocation available to exercise.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("stun_turn.relay_echo.ok", "Relay echo succeeded on every tested endpoint.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// allocateFix mirrors the coturn/RFC 5766 guidance the old Collect emitted.
|
||||
func allocateFix(code int) string {
|
||||
switch code {
|
||||
case 401:
|
||||
return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive."
|
||||
case 403:
|
||||
return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect."
|
||||
case 437:
|
||||
return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server."
|
||||
case 441:
|
||||
return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`."
|
||||
case 442:
|
||||
return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side."
|
||||
case 486:
|
||||
return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`."
|
||||
case 508:
|
||||
return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range."
|
||||
}
|
||||
return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport."
|
||||
}
|
||||
61
checker/stun.go
Normal file
61
checker/stun.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// stunBindingResult holds the outcome of a STUN Binding test.
|
||||
type stunBindingResult struct {
|
||||
RTT time.Duration
|
||||
ReflexiveAddr net.Addr
|
||||
IsPrivateMapped bool
|
||||
Err error
|
||||
}
|
||||
|
||||
// runSTUNBinding sends a STUN Binding Request to the remote and returns the
|
||||
// reflexive (XOR-MAPPED) address along with the RTT. We construct a tiny
|
||||
// turn.Client with no credentials; its SendBindingRequestTo path drives a
|
||||
// vanilla STUN exchange (RFC 5389) and works on UDP/TCP/TLS/DTLS through
|
||||
// the dialed PacketConn we hand it.
|
||||
func runSTUNBinding(d *dialedConn, timeout time.Duration) stunBindingResult {
|
||||
cfg := &turn.ClientConfig{
|
||||
Conn: d.pc,
|
||||
STUNServerAddr: d.remoteAddr.String(),
|
||||
RTO: timeout,
|
||||
Software: "happyDomain-checker-stun-turn",
|
||||
}
|
||||
client, err := turn.NewClient(cfg)
|
||||
if err != nil {
|
||||
return stunBindingResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
|
||||
}
|
||||
defer client.Close()
|
||||
if err := client.Listen(); err != nil {
|
||||
return stunBindingResult{Err: fmt.Errorf("client.Listen: %w", err)}
|
||||
}
|
||||
start := time.Now()
|
||||
addr, err := client.SendBindingRequestTo(d.remoteAddr)
|
||||
if err != nil {
|
||||
return stunBindingResult{Err: err}
|
||||
}
|
||||
res := stunBindingResult{
|
||||
RTT: time.Since(start),
|
||||
ReflexiveAddr: addr,
|
||||
}
|
||||
if udpAddr, ok := addr.(*net.UDPAddr); ok {
|
||||
res.IsPrivateMapped = isPrivate(udpAddr.IP)
|
||||
} else if tcpAddr, ok := addr.(*net.TCPAddr); ok {
|
||||
res.IsPrivateMapped = isPrivate(tcpAddr.IP)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func isPrivate(ip net.IP) bool {
|
||||
if ip == nil {
|
||||
return false
|
||||
}
|
||||
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsUnspecified()
|
||||
}
|
||||
24
checker/tlsmeta.go
Normal file
24
checker/tlsmeta.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package checker
|
||||
|
||||
import "crypto/tls"
|
||||
|
||||
func tlsVersionString(v uint16) string {
|
||||
switch v {
|
||||
case tls.VersionTLS10:
|
||||
return "TLS 1.0"
|
||||
case tls.VersionTLS11:
|
||||
return "TLS 1.1"
|
||||
case tls.VersionTLS12:
|
||||
return "TLS 1.2"
|
||||
case tls.VersionTLS13:
|
||||
return "TLS 1.3"
|
||||
}
|
||||
return "TLS ?"
|
||||
}
|
||||
|
||||
func peerCertCN(s *tls.ConnectionState) string {
|
||||
if s == nil || len(s.PeerCertificates) == 0 {
|
||||
return ""
|
||||
}
|
||||
return s.PeerCertificates[0].Subject.CommonName
|
||||
}
|
||||
148
checker/transport.go
Normal file
148
checker/transport.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// dialedConn wraps the network conn used to talk to a STUN/TURN server,
|
||||
// always exposing a PacketConn (turn/stun talk in datagrams). For
|
||||
// stream transports (TCP/TLS) we wrap with turn.NewSTUNConn which frames
|
||||
// STUN messages on top of the byte stream per RFC 5389 §7.2.2.
|
||||
type dialedConn struct {
|
||||
pc net.PacketConn
|
||||
underlying net.Conn // non-nil for TCP/TLS; nil for UDP and DTLS
|
||||
tlsState *tls.ConnectionState
|
||||
dtlsState *dtls.State
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func (d *dialedConn) Close() error {
|
||||
var err error
|
||||
if d.pc != nil {
|
||||
err = d.pc.Close()
|
||||
}
|
||||
if d.underlying != nil {
|
||||
if e := d.underlying.Close(); e != nil && err == nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// dtlsPacketConn adapts *dtls.Conn (net.Conn) to net.PacketConn.
|
||||
// DTLS frames messages at the record level; no additional length-prefix
|
||||
// framing (as turn.NewSTUNConn adds for TCP) is needed or correct here.
|
||||
type dtlsPacketConn struct {
|
||||
conn *dtls.Conn
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
n, err := d.conn.Read(b)
|
||||
return n, d.raddr, err
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
return d.conn.Write(b)
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) Close() error { return d.conn.Close() }
|
||||
func (d *dtlsPacketConn) LocalAddr() net.Addr { return d.conn.LocalAddr() }
|
||||
func (d *dtlsPacketConn) SetDeadline(t time.Time) error { return d.conn.SetDeadline(t) }
|
||||
func (d *dtlsPacketConn) SetReadDeadline(t time.Time) error { return d.conn.SetReadDeadline(t) }
|
||||
func (d *dtlsPacketConn) SetWriteDeadline(t time.Time) error { return d.conn.SetWriteDeadline(t) }
|
||||
|
||||
// dial establishes the appropriate L4(/secure) connection to ep.
|
||||
// timeout is applied per dial step (TCP connect, TLS handshake, DTLS handshake).
|
||||
func dial(ctx context.Context, ep Endpoint, timeout time.Duration) (*dialedConn, error) {
|
||||
addr := net.JoinHostPort(ep.Host, strconv.Itoa(int(ep.Port)))
|
||||
|
||||
dctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
switch ep.Transport {
|
||||
case TransportUDP:
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
|
||||
}
|
||||
// Use the dual-stack wildcard ("") so the kernel can pick an IPv6
|
||||
// source when the resolved server address is IPv6.
|
||||
conn, err := net.ListenPacket("udp", ":0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen udp: %w", err)
|
||||
}
|
||||
return &dialedConn{pc: conn, remoteAddr: raddr}, nil
|
||||
|
||||
case TransportTCP:
|
||||
var d net.Dialer
|
||||
c, err := d.DialContext(dctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
|
||||
}
|
||||
return &dialedConn{
|
||||
pc: turn.NewSTUNConn(c),
|
||||
underlying: c,
|
||||
remoteAddr: c.RemoteAddr(),
|
||||
}, nil
|
||||
|
||||
case TransportTLS:
|
||||
var d net.Dialer
|
||||
raw, err := d.DialContext(dctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
|
||||
}
|
||||
tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, MinVersion: tls.VersionTLS12})
|
||||
if err := tlsConn.HandshakeContext(dctx); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake %s: %w", addr, err)
|
||||
}
|
||||
state := tlsConn.ConnectionState()
|
||||
return &dialedConn{
|
||||
pc: turn.NewSTUNConn(tlsConn),
|
||||
underlying: tlsConn,
|
||||
tlsState: &state,
|
||||
remoteAddr: tlsConn.RemoteAddr(),
|
||||
}, nil
|
||||
|
||||
case TransportDTLS:
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen udp: %w", err)
|
||||
}
|
||||
dconn, err := dtls.Client(udpConn, raddr, &dtls.Config{
|
||||
ServerName: ep.Host,
|
||||
})
|
||||
if err != nil {
|
||||
udpConn.Close()
|
||||
return nil, fmt.Errorf("dtls setup %s: %w", addr, err)
|
||||
}
|
||||
if err := dconn.HandshakeContext(dctx); err != nil {
|
||||
dconn.Close()
|
||||
udpConn.Close()
|
||||
return nil, fmt.Errorf("dtls handshake %s: %w", addr, err)
|
||||
}
|
||||
state, _ := dconn.ConnectionState()
|
||||
return &dialedConn{
|
||||
pc: &dtlsPacketConn{conn: dconn, raddr: raddr},
|
||||
dtlsState: &state,
|
||||
remoteAddr: raddr,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown transport")
|
||||
}
|
||||
}
|
||||
189
checker/turn.go
Normal file
189
checker/turn.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// turnAllocateResult holds the outcome of a TURN Allocate exchange.
|
||||
type turnAllocateResult struct {
|
||||
Client *turn.Client // non-nil iff RelayConn is non-nil; caller must Close after RelayConn.
|
||||
RelayConn net.PacketConn
|
||||
RelayAddr net.Addr
|
||||
IsPrivateRelay bool
|
||||
UnauthChallenge bool // first allocate replied with 401 + REALM/NONCE (good for "no auth" probe)
|
||||
AuthErrorCode int // STUN error code on the final attempt (0 if OK)
|
||||
AuthErrorReason string // STUN reason phrase
|
||||
Duration time.Duration // wall time of the allocate exchange
|
||||
Err error
|
||||
}
|
||||
|
||||
// runTURNAllocate runs a full TURN Allocate against the dialed connection.
|
||||
// If creds is nil, it sends an unauthenticated Allocate and treats the
|
||||
// expected 401 challenge as success of the *probe* (UnauthChallenge=true).
|
||||
// If creds is non-nil, it performs the full long-term-credential dance.
|
||||
//
|
||||
// The returned RelayConn is owned by the caller and must be Close()d.
|
||||
func runTURNAllocate(d *dialedConn, creds *turnCredentials, timeout time.Duration) turnAllocateResult {
|
||||
cfg := &turn.ClientConfig{
|
||||
Conn: d.pc,
|
||||
TURNServerAddr: d.remoteAddr.String(),
|
||||
STUNServerAddr: d.remoteAddr.String(),
|
||||
RTO: timeout,
|
||||
Software: "happyDomain-checker-stun-turn",
|
||||
}
|
||||
if creds != nil {
|
||||
cfg.Username = creds.Username
|
||||
cfg.Password = creds.Password
|
||||
cfg.Realm = creds.Realm
|
||||
}
|
||||
|
||||
client, err := turn.NewClient(cfg)
|
||||
if err != nil {
|
||||
return turnAllocateResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
|
||||
}
|
||||
|
||||
if err := client.Listen(); err != nil {
|
||||
client.Close()
|
||||
return turnAllocateResult{Err: fmt.Errorf("client.Listen: %w", err)}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
relay, err := client.Allocate()
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
// Inspect the STUN error code to give the user a precise diagnostic.
|
||||
code, reason := stunErrorOf(err)
|
||||
// 401 with REALM/NONCE is the *expected* answer when probing without
|
||||
// credentials; surface that as a positive UnauthChallenge signal,
|
||||
// not as a failure, so the rule layer can flag "open relay" if we
|
||||
// got a 200 instead.
|
||||
if creds == nil && code == 401 {
|
||||
client.Close()
|
||||
return turnAllocateResult{
|
||||
UnauthChallenge: true,
|
||||
Duration: dur,
|
||||
AuthErrorCode: 401,
|
||||
AuthErrorReason: reason,
|
||||
}
|
||||
}
|
||||
client.Close()
|
||||
return turnAllocateResult{
|
||||
AuthErrorCode: code,
|
||||
AuthErrorReason: reason,
|
||||
Duration: dur,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
res := turnAllocateResult{
|
||||
Client: client,
|
||||
RelayConn: relay,
|
||||
RelayAddr: relay.LocalAddr(),
|
||||
Duration: dur,
|
||||
}
|
||||
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
|
||||
res.IsPrivateRelay = isPrivate(udpAddr.IP)
|
||||
}
|
||||
// Client is intentionally not Close()d here: closing it before the
|
||||
// caller is done with RelayConn would tear down the underlying PacketConn.
|
||||
// The caller is responsible for Close()ing RelayConn first, then Client.
|
||||
return res
|
||||
}
|
||||
|
||||
// runRelayEcho asks the TURN server to relay a single short datagram to the
|
||||
// configured probe peer. This proves that:
|
||||
// - CreatePermission succeeds (server acknowledges the Send indication),
|
||||
// - the TURN data path accepts traffic.
|
||||
//
|
||||
// A reply from the peer is not required or awaited.
|
||||
func runRelayEcho(relay net.PacketConn, peer string, timeout time.Duration) error {
|
||||
host, _, err := net.SplitHostPort(peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid probePeer %q: %w", peer, err)
|
||||
}
|
||||
if host == "" {
|
||||
return errors.New("empty probe peer host")
|
||||
}
|
||||
addr, err := net.ResolveUDPAddr("udp", peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve probe peer: %w", err)
|
||||
}
|
||||
// Defence in depth against SSRF: even if the literal probePeer passed
|
||||
// the upfront check, its DNS resolution might land on private space.
|
||||
if isPrivate(addr.IP) {
|
||||
return fmt.Errorf("probePeer %q resolves to private address %s", peer, addr.IP)
|
||||
}
|
||||
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
// One-byte payload: enough to trigger CreatePermission + Send on the
|
||||
// TURN data path. We do not expect or wait for a peer reply.
|
||||
payload := []byte{0x00}
|
||||
if _, err := relay.WriteTo(payload, addr); err != nil {
|
||||
return fmt.Errorf("relay WriteTo: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// turnCredentials carries either explicit long-term credentials or values
|
||||
// derived from a REST-API shared secret.
|
||||
type turnCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
Realm string
|
||||
}
|
||||
|
||||
// restAPICredentials derives ephemeral credentials per the
|
||||
// draft-uberti-rtcweb-turn-rest scheme:
|
||||
//
|
||||
// username = "<unix_ts>:<optional_user>"
|
||||
// password = base64(hmac_sha1(secret, username))
|
||||
//
|
||||
// ttl is the validity window from now.
|
||||
func restAPICredentials(secret, user, realm string, ttl time.Duration) *turnCredentials {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
expiry := time.Now().Add(ttl).Unix()
|
||||
username := strconv.FormatInt(expiry, 10)
|
||||
if user != "" {
|
||||
username += ":" + user
|
||||
}
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(username))
|
||||
password := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return &turnCredentials{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Realm: realm,
|
||||
}
|
||||
}
|
||||
|
||||
// stunErrorOf parses a STUN error returned by pion/turn into (code, reason).
|
||||
// pion/turn does not wrap errors with %w, so we parse the formatted message.
|
||||
// pion formats error responses as: "... (error <code>: <reason>)"
|
||||
func stunErrorOf(err error) (int, string) {
|
||||
if err == nil {
|
||||
return 0, ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if i := strings.LastIndex(msg, "(error "); i >= 0 {
|
||||
inner := strings.TrimSuffix(msg[i+7:], ")")
|
||||
if sep := strings.IndexByte(inner, ':'); sep > 0 {
|
||||
if code, err := strconv.Atoi(strings.TrimSpace(inner[:sep])); err == nil {
|
||||
return code, strings.TrimSpace(inner[sep+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, msg
|
||||
}
|
||||
53
checker/turn_test.go
Normal file
53
checker/turn_test.go
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/stun/v3"
|
||||
)
|
||||
|
||||
// TestStunErrorOf_PinPionFormat builds an error using pion's own
|
||||
// ErrorCodeAttribute.String() so that any future change to the format
|
||||
// pion/turn uses ("... (error <code>: <reason>)") makes this test fail
|
||||
// loudly instead of silently breaking our diagnostic parsing.
|
||||
func TestStunErrorOf_PinPionFormat(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
code stun.ErrorCode
|
||||
reason string
|
||||
wantCode int
|
||||
wantReason string
|
||||
}{
|
||||
{"unauthorized", stun.CodeUnauthorized, "Unauthorized", 401, "Unauthorized"},
|
||||
{"stale nonce", stun.CodeStaleNonce, "Stale Nonce", 438, "Stale Nonce"},
|
||||
{"server error", stun.CodeServerError, "Server Error", 500, "Server Error"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
attr := stun.ErrorCodeAttribute{Code: tc.code, Reason: []byte(tc.reason)}
|
||||
// Mirror pion/turn/v4@v4.0.0 client.go:296:
|
||||
// fmt.Errorf("%s (error %s)", res.Type, code)
|
||||
err := fmt.Errorf("error response (error %s)", attr)
|
||||
gotCode, gotReason := stunErrorOf(err)
|
||||
if gotCode != tc.wantCode || gotReason != tc.wantReason {
|
||||
t.Errorf("stunErrorOf(%q) = (%d, %q); want (%d, %q): pion error format may have changed",
|
||||
err.Error(), gotCode, gotReason, tc.wantCode, tc.wantReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStunErrorOf_NoMatch(t *testing.T) {
|
||||
code, reason := stunErrorOf(errors.New("plain error"))
|
||||
if code != 0 {
|
||||
t.Errorf("expected code 0 for unparseable error, got %d", code)
|
||||
}
|
||||
if reason != "plain error" {
|
||||
t.Errorf("expected reason to fall back to message, got %q", reason)
|
||||
}
|
||||
if c, r := stunErrorOf(nil); c != 0 || r != "" {
|
||||
t.Errorf("nil error: got (%d, %q), want (0, \"\")", c, r)
|
||||
}
|
||||
}
|
||||
133
checker/types.go
Normal file
133
checker/types.go
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
// Package checker implements the STUN/TURN checker for happyDomain.
|
||||
//
|
||||
// The checker drives a target server through the STUN binding and TURN
|
||||
// allocation/relay protocols (RFC 5389, RFC 5766) using the Pion libraries,
|
||||
// then exposes a structured observation and a rich HTML report including
|
||||
// remediation guidance for the most common deployment mistakes.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"net"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// isPrivateAddr returns whether s parses as a private/loopback/link-local
|
||||
// address. Accepts either a bare IP or a host:port form. Hostnames return
|
||||
// false; the caller should resolve first if needed.
|
||||
func isPrivateAddr(s string) bool {
|
||||
ip := net.ParseIP(s)
|
||||
if ip == nil {
|
||||
host, _, err := net.SplitHostPort(s)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ip = net.ParseIP(host)
|
||||
}
|
||||
return isPrivate(ip)
|
||||
}
|
||||
|
||||
// ObservationKeyStunTurn is the observation key for STUN/TURN test data.
|
||||
const ObservationKeyStunTurn sdk.ObservationKey = "stun_turn"
|
||||
|
||||
// Transport identifies the L4/L4-secure transport used to reach an endpoint.
|
||||
type Transport string
|
||||
|
||||
const (
|
||||
TransportUDP Transport = "udp"
|
||||
TransportTCP Transport = "tcp"
|
||||
TransportTLS Transport = "tls"
|
||||
TransportDTLS Transport = "dtls"
|
||||
)
|
||||
|
||||
// Endpoint is a single resolved server target to probe.
|
||||
type Endpoint struct {
|
||||
URI string `json:"uri"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `json:"port"`
|
||||
Transport Transport `json:"transport"`
|
||||
Secure bool `json:"secure"`
|
||||
IsTURN bool `json:"is_turn"` // false: STUN-only scheme, true: TURN scheme
|
||||
Source string `json:"source"` // "uri" or "srv:_turn._udp.example.com"
|
||||
}
|
||||
|
||||
// DialResult holds the raw outcome of establishing the L4(/secure)
|
||||
// connection to an endpoint. Collected without judgement.
|
||||
type DialResult struct {
|
||||
OK bool `json:"ok"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
RemoteAddr string `json:"remote_addr,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// TLS metadata populated when the underlying transport used TLS.
|
||||
TLSVersion string `json:"tls_version,omitempty"`
|
||||
TLSCipher string `json:"tls_cipher,omitempty"`
|
||||
TLSPeerCN string `json:"tls_peer_cn,omitempty"`
|
||||
|
||||
// DTLSHandshake records whether a DTLS handshake was performed.
|
||||
DTLSHandshake bool `json:"dtls_handshake,omitempty"`
|
||||
}
|
||||
|
||||
// STUNBindingObservation holds the raw outcome of a STUN Binding probe.
|
||||
type STUNBindingObservation struct {
|
||||
Attempted bool `json:"attempted"`
|
||||
OK bool `json:"ok"`
|
||||
RTTMs int64 `json:"rtt_ms"`
|
||||
ReflexiveAddr string `json:"reflexive_addr,omitempty"`
|
||||
IsPrivateMapped bool `json:"is_private_mapped,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TURNAllocateObservation holds the raw outcome of a TURN Allocate
|
||||
// attempt (either unauthenticated probe or authenticated attempt).
|
||||
type TURNAllocateObservation struct {
|
||||
Attempted bool `json:"attempted"`
|
||||
OK bool `json:"ok"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
RelayAddr string `json:"relay_addr,omitempty"`
|
||||
IsPrivateRelay bool `json:"is_private_relay,omitempty"`
|
||||
UnauthChallenge bool `json:"unauth_challenge,omitempty"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
ErrorReason string `json:"error_reason,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RelayEchoObservation holds the raw outcome of the relay echo probe.
|
||||
type RelayEchoObservation struct {
|
||||
Attempted bool `json:"attempted"`
|
||||
OK bool `json:"ok"`
|
||||
PeerAddr string `json:"peer_addr,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointProbe holds the raw, unjudged observation for a single endpoint.
|
||||
type EndpointProbe struct {
|
||||
Endpoint Endpoint `json:"endpoint"`
|
||||
|
||||
// ResolvedIPs lists the A/AAAA addresses we observed for this host
|
||||
// (populated when the endpoint was reached). Used for IPv6 coverage.
|
||||
ResolvedIPs []string `json:"resolved_ips,omitempty"`
|
||||
|
||||
Dial DialResult `json:"dial"`
|
||||
STUNBinding STUNBindingObservation `json:"stun_binding"`
|
||||
TURNNoAuth TURNAllocateObservation `json:"turn_noauth"`
|
||||
TURNAuth TURNAllocateObservation `json:"turn_auth"`
|
||||
RelayEcho RelayEchoObservation `json:"relay_echo"`
|
||||
ChannelBindRun bool `json:"channel_bind_run,omitempty"`
|
||||
}
|
||||
|
||||
// StunTurnData is the JSON-serializable observation payload. It now
|
||||
// carries only raw per-endpoint probe outcomes; rules do the judging.
|
||||
type StunTurnData struct {
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
RequestedURI string `json:"requested_uri,omitempty"`
|
||||
HasCreds bool `json:"has_creds,omitempty"`
|
||||
ProbePeer string `json:"probe_peer,omitempty"`
|
||||
WarningRTTMs int64 `json:"warning_rtt_ms,omitempty"`
|
||||
CriticalRTT int64 `json:"critical_rtt_ms,omitempty"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Endpoints []EndpointProbe `json:"endpoints"`
|
||||
GlobalError string `json:"global_error,omitempty"`
|
||||
}
|
||||
20
go.mod
Normal file
20
go.mod
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
module git.happydns.org/checker-stun-turn
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.5.0
|
||||
git.happydns.org/checker-tls v0.6.2
|
||||
github.com/pion/dtls/v3 v3.0.4
|
||||
github.com/pion/stun/v3 v3.0.0
|
||||
github.com/pion/turn/v4 v4.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/pion/logging v0.2.2 // indirect
|
||||
github.com/pion/randutil v0.1.0 // indirect
|
||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||
github.com/wlynxg/anet v0.0.3 // indirect
|
||||
golang.org/x/crypto v0.28.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
)
|
||||
32
go.sum
Normal file
32
go.sum
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/pion/dtls/v3 v3.0.4 h1:44CZekewMzfrn9pmGrj5BNnTMDCFwr+6sLH+cCuLM7U=
|
||||
github.com/pion/dtls/v3 v3.0.4/go.mod h1:R373CsjxWqNPf6MEkfdy3aSe9niZvL/JaKlGeFphtMg=
|
||||
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
|
||||
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
|
||||
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
|
||||
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
|
||||
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
|
||||
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
|
||||
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
|
||||
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
|
||||
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/wlynxg/anet v0.0.3 h1:PvR53psxFXstc12jelG6f1Lv4MWqE0tI76/hHGjh9rg=
|
||||
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
26
main.go
Normal file
26
main.go
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
stunturn "git.happydns.org/checker-stun-turn/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version, overridden via
|
||||
// `go build -ldflags "-X main.Version=..."`.
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
stunturn.Version = Version
|
||||
|
||||
srv := server.New(stunturn.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
19
plugin/plugin.go
Normal file
19
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// Command plugin is the happyDomain plugin entrypoint for the STUN/TURN checker.
|
||||
//
|
||||
// It is built as a Go plugin (`go build -buildmode=plugin`) and loaded at
|
||||
// runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
stunturn "git.happydns.org/checker-stun-turn/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading the .so.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
stunturn.Version = Version
|
||||
prvd := stunturn.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue