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
6ad7d3f593
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