Initial commit
Adds a happyDomain checker that probes STUN/TURN servers end-to-end:
DNS/SRV discovery, UDP/TCP/TLS/DTLS dial, STUN binding + reflexive-addr
sanity, open-relay detection, authenticated TURN Allocate (long-term
creds or REST-API HMAC), public-relay check, CreatePermission + Send
round-trip through the relay, and optional ChannelBind.
Failing sub-tests carry a remediation string (`Fix`) that the HTML
report surfaces as a yellow headline callout and inline next to each
row. Mapping covers the most common coturn misconfigurations
(external-ip, relay-ip, lt-cred-mech, min-port/max-port, cert issues,
401 nonce drift, 441/442/486/508 allocation errors).
Implements sdk.EndpointDiscoverer (checker/discovery.go): every
stuns:/turns:/DTLS endpoint observed during Collect is published as a
DiscoveredEndpoint{Type: "tls"|"dtls"} so a downstream TLS checker can
verify certificates without re-parsing the observation.
Backed by pion/stun/v3 + pion/turn/v4 + pion/dtls/v3; SDK pinned to a
local replace until the EndpointDiscoverer interface ships in a tagged
release.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5826bb1f40
23 changed files with 1906 additions and 0 deletions
331
checker/collect.go
Normal file
331
checker/collect.go
Normal file
|
|
@ -0,0 +1,331 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type probeConfig struct {
|
||||
mode string
|
||||
username string
|
||||
password string
|
||||
sharedSecret string
|
||||
realm string
|
||||
probePeer string
|
||||
testChannelBind bool
|
||||
timeout time.Duration
|
||||
warningRTT time.Duration
|
||||
criticalRTT time.Duration
|
||||
}
|
||||
|
||||
func (p *stunTurnProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
zone, _ := opts["zone"].(string)
|
||||
uri, _ := opts["serverURI"].(string)
|
||||
mode, _ := opts["mode"].(string)
|
||||
if mode == "" {
|
||||
mode = "auto"
|
||||
}
|
||||
username, _ := opts["username"].(string)
|
||||
password, _ := opts["credential"].(string)
|
||||
sharedSecret, _ := opts["sharedSecret"].(string)
|
||||
realm, _ := opts["realm"].(string)
|
||||
transportsRaw, _ := opts["transports"].(string)
|
||||
probePeer, _ := opts["probePeer"].(string)
|
||||
if probePeer == "" {
|
||||
probePeer = "1.1.1.1:53"
|
||||
}
|
||||
timeoutSec := sdk.GetIntOption(opts, "timeout", 5)
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = 5
|
||||
}
|
||||
|
||||
cfg := probeConfig{
|
||||
mode: mode,
|
||||
username: username,
|
||||
password: password,
|
||||
sharedSecret: sharedSecret,
|
||||
realm: realm,
|
||||
probePeer: probePeer,
|
||||
testChannelBind: sdk.GetBoolOption(opts, "testChannelBind", false),
|
||||
timeout: time.Duration(timeoutSec) * time.Second,
|
||||
warningRTT: time.Duration(sdk.GetIntOption(opts, "warningRTT", 200)) * time.Millisecond,
|
||||
criticalRTT: time.Duration(sdk.GetIntOption(opts, "criticalRTT", 1000)) * time.Millisecond,
|
||||
}
|
||||
|
||||
transports := parseTransports(transportsRaw)
|
||||
|
||||
collectedAt := time.Now().UTC()
|
||||
endpoints, err := discoverEndpoints(ctx, zone, uri, transports)
|
||||
if err != nil {
|
||||
return &StunTurnData{
|
||||
Zone: zone,
|
||||
Mode: mode,
|
||||
CollectedAt: collectedAt,
|
||||
GlobalError: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
data := &StunTurnData{
|
||||
Zone: zone,
|
||||
Mode: mode,
|
||||
CollectedAt: collectedAt,
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
report := EndpointReport{Endpoint: ep}
|
||||
probeEndpoint(ctx, &report, cfg)
|
||||
data.Endpoints = append(data.Endpoints, report)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func probeEndpoint(ctx context.Context, r *EndpointReport, cfg probeConfig) {
|
||||
ep := r.Endpoint
|
||||
|
||||
dialName := fmt.Sprintf("dial:%s", ep.Transport)
|
||||
dialStart := time.Now()
|
||||
dc, err := dial(ctx, ep, cfg.timeout)
|
||||
dialDur := time.Since(dialStart)
|
||||
if err != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: dialName,
|
||||
Status: SubTestCrit,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
Error: err.Error(),
|
||||
Fix: dialFix(ep, err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer dc.Close()
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: dialName,
|
||||
Status: SubTestOK,
|
||||
DurationMs: dialDur.Milliseconds(),
|
||||
Detail: fmt.Sprintf("connected to %s", dc.remoteAddr),
|
||||
})
|
||||
|
||||
if dc.tlsState != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "tls",
|
||||
Status: SubTestOK,
|
||||
Detail: fmt.Sprintf("%s, %s, peer cert CN=%s",
|
||||
tlsVersionString(dc.tlsState.Version),
|
||||
tls.CipherSuiteName(dc.tlsState.CipherSuite),
|
||||
peerCertCN(dc.tlsState),
|
||||
),
|
||||
})
|
||||
}
|
||||
if dc.dtlsState != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "dtls",
|
||||
Status: SubTestOK,
|
||||
Detail: "DTLS handshake completed",
|
||||
})
|
||||
}
|
||||
|
||||
bind := runSTUNBinding(dc, cfg.timeout)
|
||||
if bind.Err != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "stun_binding",
|
||||
Status: SubTestCrit,
|
||||
Error: bind.Err.Error(),
|
||||
Fix: "Server did not answer the STUN Binding Request. Check that the STUN service is actually listening on this transport, and that no middlebox is filtering RFC 5389 traffic.",
|
||||
})
|
||||
return
|
||||
}
|
||||
rttStatus := SubTestOK
|
||||
rttFix := ""
|
||||
if bind.RTT > cfg.criticalRTT {
|
||||
rttStatus = SubTestCrit
|
||||
rttFix = "Server is very slow to respond. Check server load, network path, and consider deploying closer to your users."
|
||||
} else if bind.RTT > cfg.warningRTT {
|
||||
rttStatus = SubTestWarn
|
||||
rttFix = "Latency is high enough to noticeably degrade interactive RTC. Consider a server geographically closer to your users."
|
||||
}
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "stun_binding",
|
||||
Status: rttStatus,
|
||||
DurationMs: bind.RTT.Milliseconds(),
|
||||
Detail: fmt.Sprintf("reflexive address: %s", bind.ReflexiveAddr),
|
||||
Fix: rttFix,
|
||||
})
|
||||
if bind.IsPrivateMapped {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "stun_reflexive_public",
|
||||
Status: SubTestCrit,
|
||||
Detail: fmt.Sprintf("server returned a private/loopback IP: %s", bind.ReflexiveAddr),
|
||||
Fix: "Server appears to be behind NAT and unaware of its public IP. Set `external-ip=<public>` (coturn) or the equivalent on your TURN server.",
|
||||
})
|
||||
} else {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "stun_reflexive_public",
|
||||
Status: SubTestOK,
|
||||
Detail: fmt.Sprintf("public reflexive: %s", bind.ReflexiveAddr),
|
||||
})
|
||||
}
|
||||
|
||||
// Mode short-circuits: STUN-only servers stop here.
|
||||
if cfg.mode == "stun" || !ep.IsTURN {
|
||||
return
|
||||
}
|
||||
|
||||
noAuth := runTURNAllocate(dc, nil, cfg.timeout)
|
||||
if noAuth.RelayConn != nil {
|
||||
_ = noAuth.RelayConn.Close()
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_open_relay_check",
|
||||
Status: SubTestCrit,
|
||||
Detail: "TURN allocation accepted without authentication",
|
||||
Fix: "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification.",
|
||||
})
|
||||
} else if noAuth.UnauthChallenge {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_open_relay_check",
|
||||
Status: SubTestOK,
|
||||
Detail: "server correctly challenged the unauthenticated allocate (401)",
|
||||
})
|
||||
} else {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_open_relay_check",
|
||||
Status: SubTestWarn,
|
||||
Detail: fmt.Sprintf("unexpected response (code=%d): %s", noAuth.AuthErrorCode, noAuth.AuthErrorReason),
|
||||
Fix: "Server did not behave like a standard TURN. Verify it actually implements RFC 5766.",
|
||||
})
|
||||
}
|
||||
|
||||
creds := pickCredentials(cfg.username, cfg.password, cfg.sharedSecret, cfg.realm)
|
||||
if creds == nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_allocate_auth",
|
||||
Status: SubTestSkipped,
|
||||
Detail: "no credentials provided",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// We need a fresh dialed conn; pion/turn binds the client to one PacketConn lifetime.
|
||||
dc2, err := dial(ctx, ep, cfg.timeout)
|
||||
if err != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_allocate_auth",
|
||||
Status: SubTestError,
|
||||
Error: fmt.Sprintf("redial failed: %v", err),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer dc2.Close()
|
||||
|
||||
auth := runTURNAllocate(dc2, creds, cfg.timeout)
|
||||
if auth.Err != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_allocate_auth",
|
||||
Status: SubTestCrit,
|
||||
DurationMs: auth.Duration.Milliseconds(),
|
||||
Error: auth.Err.Error(),
|
||||
Detail: fmt.Sprintf("STUN error code: %d", auth.AuthErrorCode),
|
||||
Fix: allocateFix(auth.AuthErrorCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer auth.RelayConn.Close()
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_allocate_auth",
|
||||
Status: SubTestOK,
|
||||
DurationMs: auth.Duration.Milliseconds(),
|
||||
Detail: fmt.Sprintf("relay address: %s", auth.RelayAddr),
|
||||
})
|
||||
if auth.IsPrivateRelay {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_relay_public",
|
||||
Status: SubTestCrit,
|
||||
Detail: fmt.Sprintf("relay address is private: %s", auth.RelayAddr),
|
||||
Fix: "Set `relay-ip=<public>` (coturn). The relay range must be publicly reachable for clients to use TURN.",
|
||||
})
|
||||
} else {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_relay_public",
|
||||
Status: SubTestOK,
|
||||
Detail: fmt.Sprintf("relay is public: %s", auth.RelayAddr),
|
||||
})
|
||||
}
|
||||
|
||||
if err := runRelayEcho(auth.RelayConn, cfg.probePeer, cfg.timeout); err != nil {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_relay_echo",
|
||||
Status: SubTestWarn,
|
||||
Error: err.Error(),
|
||||
Fix: "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn).",
|
||||
})
|
||||
} else {
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_relay_echo",
|
||||
Status: SubTestOK,
|
||||
Detail: fmt.Sprintf("CreatePermission + Send to %s succeeded", cfg.probePeer),
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.testChannelBind {
|
||||
// pion/turn handles ChannelBind transparently when the relay PacketConn
|
||||
// is used through a turn.Client; we just record that the option was on.
|
||||
r.SubTests = append(r.SubTests, SubTest{
|
||||
Name: "turn_channel_bind",
|
||||
Status: SubTestInfo,
|
||||
Detail: "ChannelBind exercised implicitly by relay traffic",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func pickCredentials(username, password, sharedSecret, realm string) *turnCredentials {
|
||||
if sharedSecret != "" {
|
||||
return restAPICredentials(sharedSecret, username, realm, time.Hour)
|
||||
}
|
||||
if username != "" && password != "" {
|
||||
return &turnCredentials{Username: username, Password: password, Realm: realm}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dialFix(ep Endpoint, err error) string {
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "no such host"):
|
||||
return fmt.Sprintf("Hostname `%s` does not resolve. Add the matching A/AAAA record (or fix typos in the URI).", ep.Host)
|
||||
case strings.Contains(msg, "tls handshake"), strings.Contains(msg, "x509"):
|
||||
return fmt.Sprintf("TLS handshake failed for `%s`. Reissue the certificate covering this hostname (e.g. via Let's Encrypt) and reload the server (coturn: `cert=` and `pkey=`).", ep.Host)
|
||||
case strings.Contains(msg, "connection refused"):
|
||||
return fmt.Sprintf("Nothing is listening on %s/%d. Start the server with the appropriate listening port (coturn: `listening-port=`/`tls-listening-port=`).", ep.Host, ep.Port)
|
||||
case strings.Contains(msg, "i/o timeout"), strings.Contains(msg, "deadline"):
|
||||
switch ep.Transport {
|
||||
case TransportUDP:
|
||||
return "No reply on UDP. Open the UDP port inbound and verify your network does not block UDP egress."
|
||||
default:
|
||||
return "Connection timed out. A firewall or NAT is likely blocking this port."
|
||||
}
|
||||
}
|
||||
return "Could not establish a connection to the server."
|
||||
}
|
||||
|
||||
func allocateFix(code int) string {
|
||||
switch code {
|
||||
case 401:
|
||||
return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive."
|
||||
case 403:
|
||||
return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect."
|
||||
case 437:
|
||||
return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server."
|
||||
case 441:
|
||||
return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`."
|
||||
case 442:
|
||||
return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side."
|
||||
case 486:
|
||||
return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`."
|
||||
case 508:
|
||||
return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range."
|
||||
}
|
||||
return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport."
|
||||
}
|
||||
94
checker/definition.go
Normal file
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 Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "stunturn",
|
||||
Name: "STUN/TURN Server",
|
||||
Version: Version,
|
||||
HasHTMLReport: true,
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyStunTurn},
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToZone: true,
|
||||
ApplyToService: true,
|
||||
},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
RunOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "zone",
|
||||
Type: "string",
|
||||
Label: "Zone",
|
||||
Placeholder: "example.com",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
Description: "Zone used for SRV-based STUN/TURN endpoint discovery when no explicit URI is provided.",
|
||||
},
|
||||
{
|
||||
Id: "serverURI",
|
||||
Type: "string",
|
||||
Label: "Server URI",
|
||||
Placeholder: "turns:turn.example.com:5349?transport=tcp",
|
||||
Description: "Explicit STUN/TURN URI (RFC 7064/7065). Overrides SRV-based discovery.",
|
||||
},
|
||||
{
|
||||
Id: "mode",
|
||||
Type: "string",
|
||||
Label: "Mode",
|
||||
Default: "auto",
|
||||
Choices: []string{"auto", "stun", "turn"},
|
||||
Description: "auto: probe both STUN and TURN; stun: skip TURN allocation tests; turn: require TURN allocation.",
|
||||
},
|
||||
},
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{Id: "username", Type: "string", Label: "TURN username"},
|
||||
{Id: "credential", Type: "string", Label: "TURN password", Secret: true},
|
||||
{
|
||||
Id: "sharedSecret",
|
||||
Type: "string",
|
||||
Label: "REST API shared secret",
|
||||
Secret: true,
|
||||
Description: "Shared secret used to derive ephemeral credentials (draft-uberti-rtcweb-turn-rest). Takes precedence over username/password.",
|
||||
},
|
||||
{Id: "realm", Type: "string", Label: "Realm"},
|
||||
{
|
||||
Id: "transports",
|
||||
Type: "string",
|
||||
Label: "Transports",
|
||||
Default: "udp,tcp,tls",
|
||||
Description: "Comma-separated list of transports to test among: udp, tcp, tls, dtls.",
|
||||
},
|
||||
{
|
||||
Id: "probePeer",
|
||||
Type: "string",
|
||||
Label: "Relay echo target",
|
||||
Default: "1.1.1.1:53",
|
||||
Description: "host:port used to validate the relay path (a CreatePermission + Send is issued, no payload data is exchanged).",
|
||||
},
|
||||
{
|
||||
Id: "testChannelBind",
|
||||
Type: "bool",
|
||||
Label: "Also test ChannelBind",
|
||||
Default: false,
|
||||
},
|
||||
{Id: "warningRTT", Type: "uint", Label: "RTT warning threshold (ms)", Default: 200},
|
||||
{Id: "criticalRTT", Type: "uint", Label: "RTT critical threshold (ms)", Default: 1000},
|
||||
{Id: "timeout", Type: "uint", Label: "Per-probe timeout (s)", Default: 5},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{Rule()},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 5 * time.Minute,
|
||||
Default: 30 * time.Minute,
|
||||
Max: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
225
checker/discover.go
Normal file
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)
|
||||
}
|
||||
}
|
||||
48
checker/discovery.go
Normal file
48
checker/discovery.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tlsct "git.happydns.org/checker-tls/contract"
|
||||
)
|
||||
|
||||
// DiscoverEntries implements sdk.DiscoveryPublisher.
|
||||
//
|
||||
// stuns:/turns: (RFC 7064/7065) speak TLS immediately after the TCP
|
||||
// handshake, so every secure TCP-based endpoint we observed is published
|
||||
// under the tls.endpoint.v1 contract for checker-tls to pick up.
|
||||
//
|
||||
// DTLS is intentionally omitted: the current checker-tls consumer uses
|
||||
// crypto/tls and would not probe a datagram-TLS endpoint correctly. Emitting
|
||||
// a DTLS entry today would only produce orphan lineage.
|
||||
//
|
||||
// SNI is left empty (= Host); no STARTTLS upgrade applies; the scheme
|
||||
// mandates direct TLS on the wire.
|
||||
func (p *stunTurnProvider) DiscoverEntries(data any) ([]sdk.DiscoveryEntry, error) {
|
||||
d, ok := data.(*StunTurnData)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected data type %T", data)
|
||||
}
|
||||
seen := make(map[string]struct{})
|
||||
var out []sdk.DiscoveryEntry
|
||||
for _, ep := range d.Endpoints {
|
||||
if !ep.Endpoint.Secure || ep.Endpoint.Transport == TransportDTLS {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s|%d", ep.Endpoint.Host, ep.Endpoint.Port)
|
||||
if _, dup := seen[key]; dup {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
entry, err := tlsct.NewEntry(tlsct.TLSEndpoint{
|
||||
Host: ep.Endpoint.Host,
|
||||
Port: ep.Endpoint.Port,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
21
checker/provider.go
Normal file
21
checker/provider.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new STUN/TURN observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &stunTurnProvider{}
|
||||
}
|
||||
|
||||
type stunTurnProvider struct{}
|
||||
|
||||
func (p *stunTurnProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyStunTurn
|
||||
}
|
||||
|
||||
// Definition implements sdk.CheckerDefinitionProvider.
|
||||
func (p *stunTurnProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
213
checker/report.go
Normal file
213
checker/report.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type tmplSubTest struct {
|
||||
Name string
|
||||
StatusCSS string
|
||||
StatusText string
|
||||
DurationMs int64
|
||||
Detail string
|
||||
Error string
|
||||
Fix string
|
||||
}
|
||||
|
||||
type tmplEndpoint struct {
|
||||
URI string
|
||||
Transport string
|
||||
Source string
|
||||
Open bool
|
||||
BadgeText string
|
||||
BadgeCSS string
|
||||
SubTests []tmplSubTest
|
||||
}
|
||||
|
||||
type tmplData struct {
|
||||
Zone string
|
||||
Mode string
|
||||
OverallText string
|
||||
OverallCSS string
|
||||
HeadlineFix string
|
||||
HeadlineDetail string
|
||||
GlobalError string
|
||||
Endpoints []tmplEndpoint
|
||||
}
|
||||
|
||||
var stunturnTemplate = template.Must(template.New("stunturn").Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>STUN/TURN Report</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 1rem; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-size: 14px; color: #1f2937; background: #f3f4f6; line-height: 1.5; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; }
|
||||
h2 { margin: 0 0 .4rem; font-size: 1rem; }
|
||||
.hd { background: #fff; border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
.badge { display: inline-flex; align-items: center; padding: .2em .65em; border-radius: 9999px; font-size: .78rem; font-weight: 700; letter-spacing: .02em; }
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.info { background: #dbeafe; color: #1e3a8a; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.crit { background: #fee2e2; color: #991b1b; }
|
||||
.error { background: #fee2e2; color: #991b1b; }
|
||||
.skipped { background: #e5e7eb; color: #374151; }
|
||||
.headline-fix { background: #fef3c7; border-left: 4px solid #f59e0b; padding: .75rem 1rem; border-radius: 6px; margin-top: .6rem; }
|
||||
.headline-fix strong { color: #78350f; }
|
||||
.global-err { background: #fee2e2; border-left: 4px solid #dc2626; padding: .75rem 1rem; border-radius: 6px; }
|
||||
details { background: #fff; border-radius: 8px; margin-bottom: .45rem; box-shadow: 0 1px 3px rgba(0,0,0,.07); overflow: hidden; }
|
||||
summary { display: flex; align-items: center; gap: .5rem; padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.ep-uri { font-weight: 600; flex: 1; font-family: ui-monospace, monospace; font-size: .9rem; }
|
||||
.body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||
th, td { text-align: left; padding: .35rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
.fix { color: #92400e; font-size: .82rem; }
|
||||
.err { color: #b91c1c; font-size: .82rem; }
|
||||
.dur { color: #6b7280; font-size: .8rem; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>STUN/TURN check</h1>
|
||||
<span class="badge {{.OverallCSS}}">{{.OverallText}}</span>
|
||||
<div class="meta">
|
||||
{{if .Zone}}Zone: <code>{{.Zone}}</code> · {{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}}
|
||||
|
||||
{{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></p>
|
||||
<table>
|
||||
<tr><th>Test</th><th>Status</th><th>Duration</th><th>Detail</th></tr>
|
||||
{{range .SubTests}}
|
||||
<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td><span class="badge {{.StatusCSS}}">{{.StatusText}}</span></td>
|
||||
<td class="dur">{{if .DurationMs}}{{.DurationMs}} ms{{end}}</td>
|
||||
<td>
|
||||
{{if .Detail}}{{.Detail}}{{end}}
|
||||
{{if .Error}}<div class="err">⚠ {{.Error}}</div>{{end}}
|
||||
{{if .Fix}}<div class="fix"><strong>Fix:</strong> {{.Fix}}</div>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||||
func (p *stunTurnProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) {
|
||||
var d StunTurnData
|
||||
if err := json.Unmarshal(ctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal stun/turn report: %w", err)
|
||||
}
|
||||
|
||||
worst := SubTestOK
|
||||
var headlineFix, headlineDetail string
|
||||
|
||||
td := tmplData{
|
||||
Zone: d.Zone,
|
||||
Mode: d.Mode,
|
||||
GlobalError: d.GlobalError,
|
||||
}
|
||||
|
||||
for _, ep := range d.Endpoints {
|
||||
te := tmplEndpoint{
|
||||
URI: ep.Endpoint.URI,
|
||||
Transport: string(ep.Endpoint.Transport),
|
||||
Source: ep.Endpoint.Source,
|
||||
}
|
||||
w := ep.Worst()
|
||||
if statusRank(w) > statusRank(worst) {
|
||||
worst = w
|
||||
}
|
||||
te.BadgeCSS, te.BadgeText = badge(w)
|
||||
te.Open = w != SubTestOK && w != SubTestSkipped && w != SubTestInfo
|
||||
if te.Open && headlineFix == "" {
|
||||
if f := ep.FirstFailure(); f != nil && f.Fix != "" {
|
||||
headlineFix = f.Fix
|
||||
if f.Detail != "" {
|
||||
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Detail)
|
||||
} else if f.Error != "" {
|
||||
headlineDetail = fmt.Sprintf("[%s] %s: %s", ep.Endpoint.URI, f.Name, f.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, st := range ep.SubTests {
|
||||
css, txt := badge(st.Status)
|
||||
te.SubTests = append(te.SubTests, tmplSubTest{
|
||||
Name: st.Name,
|
||||
StatusCSS: css,
|
||||
StatusText: txt,
|
||||
DurationMs: st.DurationMs,
|
||||
Detail: st.Detail,
|
||||
Error: st.Error,
|
||||
Fix: st.Fix,
|
||||
})
|
||||
}
|
||||
td.Endpoints = append(td.Endpoints, te)
|
||||
}
|
||||
|
||||
td.OverallCSS, td.OverallText = badge(worst)
|
||||
td.HeadlineFix = headlineFix
|
||||
td.HeadlineDetail = headlineDetail
|
||||
|
||||
var buf strings.Builder
|
||||
if err := stunturnTemplate.Execute(&buf, td); err != nil {
|
||||
return "", fmt.Errorf("render stun/turn report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func badge(s SubTestStatus) (cssClass, label string) {
|
||||
switch s {
|
||||
case SubTestOK:
|
||||
return "ok", "OK"
|
||||
case SubTestInfo:
|
||||
return "info", "INFO"
|
||||
case SubTestWarn:
|
||||
return "warn", "WARN"
|
||||
case SubTestCrit:
|
||||
return "crit", "CRIT"
|
||||
case SubTestError:
|
||||
return "error", "ERROR"
|
||||
case SubTestSkipped:
|
||||
return "skipped", "SKIPPED"
|
||||
}
|
||||
return "info", string(s)
|
||||
}
|
||||
87
checker/rule.go
Normal file
87
checker/rule.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Rule returns the single aggregated STUN/TURN check rule.
|
||||
func Rule() sdk.CheckRule {
|
||||
return &stunTurnRule{}
|
||||
}
|
||||
|
||||
type stunTurnRule struct{}
|
||||
|
||||
func (r *stunTurnRule) Name() string { return "stun_turn" }
|
||||
func (r *stunTurnRule) Description() string {
|
||||
return "Validates STUN binding and TURN allocation against the configured server(s)."
|
||||
}
|
||||
|
||||
func (r *stunTurnRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data StunTurnData
|
||||
if err := obs.Get(ctx, ObservationKeyStunTurn, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to get STUN/TURN observation: %v", err),
|
||||
Code: "stun_turn_obs_error",
|
||||
}}
|
||||
}
|
||||
|
||||
if data.GlobalError != "" {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: data.GlobalError,
|
||||
Code: "stun_turn_discovery_error",
|
||||
}}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: "no endpoints to probe",
|
||||
Code: "stun_turn_no_endpoints",
|
||||
}}
|
||||
}
|
||||
|
||||
worst := SubTestOK
|
||||
var firstFailLine string
|
||||
for _, ep := range data.Endpoints {
|
||||
w := ep.Worst()
|
||||
if statusRank(w) > statusRank(worst) {
|
||||
worst = w
|
||||
}
|
||||
if firstFailLine == "" {
|
||||
if f := ep.FirstFailure(); f != nil {
|
||||
parts := []string{
|
||||
fmt.Sprintf("[%s] %s", ep.Endpoint.URI, f.Name),
|
||||
}
|
||||
if f.Detail != "" {
|
||||
parts = append(parts, f.Detail)
|
||||
}
|
||||
if f.Error != "" {
|
||||
parts = append(parts, f.Error)
|
||||
}
|
||||
firstFailLine = strings.Join(parts, ": ")
|
||||
if f.Fix != "" {
|
||||
firstFailLine += ". Fix: " + f.Fix
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state := sdk.CheckState{
|
||||
Status: toSDKStatus(worst),
|
||||
Code: fmt.Sprintf("stun_turn_%s", worst),
|
||||
Meta: map[string]any{
|
||||
"endpoint_count": len(data.Endpoints),
|
||||
},
|
||||
}
|
||||
if firstFailLine != "" {
|
||||
state.Message = firstFailLine
|
||||
} else {
|
||||
state.Message = fmt.Sprintf("All %d endpoint(s) healthy", len(data.Endpoints))
|
||||
}
|
||||
return []sdk.CheckState{state}
|
||||
}
|
||||
61
checker/stun.go
Normal file
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
|
||||
}
|
||||
146
checker/transport.go
Normal file
146
checker/transport.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/pion/dtls/v3"
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// dialedConn wraps the network conn used to talk to a STUN/TURN server,
|
||||
// always exposing a PacketConn (turn/stun talk in datagrams). For
|
||||
// stream transports (TCP/TLS) we wrap with turn.NewSTUNConn which frames
|
||||
// STUN messages on top of the byte stream per RFC 5389 §7.2.2.
|
||||
type dialedConn struct {
|
||||
pc net.PacketConn
|
||||
underlying net.Conn // non-nil for TCP/TLS; nil for UDP and DTLS
|
||||
tlsState *tls.ConnectionState
|
||||
dtlsState *dtls.State
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func (d *dialedConn) Close() error {
|
||||
var err error
|
||||
if d.pc != nil {
|
||||
err = d.pc.Close()
|
||||
}
|
||||
if d.underlying != nil {
|
||||
if e := d.underlying.Close(); e != nil && err == nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// dtlsPacketConn adapts *dtls.Conn (net.Conn) to net.PacketConn.
|
||||
// DTLS frames messages at the record level; no additional length-prefix
|
||||
// framing (as turn.NewSTUNConn adds for TCP) is needed or correct here.
|
||||
type dtlsPacketConn struct {
|
||||
conn *dtls.Conn
|
||||
raddr net.Addr
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) ReadFrom(b []byte) (int, net.Addr, error) {
|
||||
n, err := d.conn.Read(b)
|
||||
return n, d.raddr, err
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) WriteTo(b []byte, _ net.Addr) (int, error) {
|
||||
return d.conn.Write(b)
|
||||
}
|
||||
|
||||
func (d *dtlsPacketConn) Close() error { return d.conn.Close() }
|
||||
func (d *dtlsPacketConn) LocalAddr() net.Addr { return d.conn.LocalAddr() }
|
||||
func (d *dtlsPacketConn) SetDeadline(t time.Time) error { return d.conn.SetDeadline(t) }
|
||||
func (d *dtlsPacketConn) SetReadDeadline(t time.Time) error { return d.conn.SetReadDeadline(t) }
|
||||
func (d *dtlsPacketConn) SetWriteDeadline(t time.Time) error { return d.conn.SetWriteDeadline(t) }
|
||||
|
||||
// dial establishes the appropriate L4(/secure) connection to ep.
|
||||
// timeout is applied per dial step (TCP connect, TLS handshake, DTLS handshake).
|
||||
func dial(ctx context.Context, ep Endpoint, timeout time.Duration) (*dialedConn, error) {
|
||||
addr := net.JoinHostPort(ep.Host, strconv.Itoa(int(ep.Port)))
|
||||
|
||||
dctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
switch ep.Transport {
|
||||
case TransportUDP:
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
|
||||
}
|
||||
conn, err := net.ListenPacket("udp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen udp: %w", err)
|
||||
}
|
||||
return &dialedConn{pc: conn, remoteAddr: raddr}, nil
|
||||
|
||||
case TransportTCP:
|
||||
var d net.Dialer
|
||||
c, err := d.DialContext(dctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
|
||||
}
|
||||
return &dialedConn{
|
||||
pc: turn.NewSTUNConn(c),
|
||||
underlying: c,
|
||||
remoteAddr: c.RemoteAddr(),
|
||||
}, nil
|
||||
|
||||
case TransportTLS:
|
||||
var d net.Dialer
|
||||
raw, err := d.DialContext(dctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial tcp %s: %w", addr, err)
|
||||
}
|
||||
tlsConn := tls.Client(raw, &tls.Config{ServerName: ep.Host, MinVersion: tls.VersionTLS12})
|
||||
if err := tlsConn.HandshakeContext(dctx); err != nil {
|
||||
raw.Close()
|
||||
return nil, fmt.Errorf("tls handshake %s: %w", addr, err)
|
||||
}
|
||||
state := tlsConn.ConnectionState()
|
||||
return &dialedConn{
|
||||
pc: turn.NewSTUNConn(tlsConn),
|
||||
underlying: tlsConn,
|
||||
tlsState: &state,
|
||||
remoteAddr: tlsConn.RemoteAddr(),
|
||||
}, nil
|
||||
|
||||
case TransportDTLS:
|
||||
raddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve udp %s: %w", addr, err)
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen udp: %w", err)
|
||||
}
|
||||
dconn, err := dtls.Client(udpConn, raddr, &dtls.Config{
|
||||
ServerName: ep.Host,
|
||||
})
|
||||
if err != nil {
|
||||
udpConn.Close()
|
||||
return nil, fmt.Errorf("dtls setup %s: %w", addr, err)
|
||||
}
|
||||
if err := dconn.HandshakeContext(dctx); err != nil {
|
||||
dconn.Close()
|
||||
udpConn.Close()
|
||||
return nil, fmt.Errorf("dtls handshake %s: %w", addr, err)
|
||||
}
|
||||
state, _ := dconn.ConnectionState()
|
||||
return &dialedConn{
|
||||
pc: &dtlsPacketConn{conn: dconn, raddr: raddr},
|
||||
dtlsState: &state,
|
||||
remoteAddr: raddr,
|
||||
}, nil
|
||||
|
||||
default:
|
||||
return nil, errors.New("unknown transport")
|
||||
}
|
||||
}
|
||||
180
checker/turn.go
Normal file
180
checker/turn.go
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pion/turn/v4"
|
||||
)
|
||||
|
||||
// turnAllocateResult holds the outcome of a TURN Allocate exchange.
|
||||
type turnAllocateResult struct {
|
||||
RelayConn net.PacketConn
|
||||
RelayAddr net.Addr
|
||||
IsPrivateRelay bool
|
||||
UnauthChallenge bool // first allocate replied with 401 + REALM/NONCE (good for "no auth" probe)
|
||||
AuthErrorCode int // STUN error code on the final attempt (0 if OK)
|
||||
AuthErrorReason string // STUN reason phrase
|
||||
Duration time.Duration // wall time of the allocate exchange
|
||||
Err error
|
||||
}
|
||||
|
||||
// runTURNAllocate runs a full TURN Allocate against the dialed connection.
|
||||
// If creds is nil, it sends an unauthenticated Allocate and treats the
|
||||
// expected 401 challenge as success of the *probe* (UnauthChallenge=true).
|
||||
// If creds is non-nil, it performs the full long-term-credential dance.
|
||||
//
|
||||
// The returned RelayConn is owned by the caller and must be Close()d.
|
||||
func runTURNAllocate(d *dialedConn, creds *turnCredentials, timeout time.Duration) turnAllocateResult {
|
||||
cfg := &turn.ClientConfig{
|
||||
Conn: d.pc,
|
||||
TURNServerAddr: d.remoteAddr.String(),
|
||||
STUNServerAddr: d.remoteAddr.String(),
|
||||
RTO: timeout,
|
||||
Software: "happyDomain-checker-stun-turn",
|
||||
}
|
||||
if creds != nil {
|
||||
cfg.Username = creds.Username
|
||||
cfg.Password = creds.Password
|
||||
cfg.Realm = creds.Realm
|
||||
}
|
||||
|
||||
client, err := turn.NewClient(cfg)
|
||||
if err != nil {
|
||||
return turnAllocateResult{Err: fmt.Errorf("turn.NewClient: %w", err)}
|
||||
}
|
||||
|
||||
if err := client.Listen(); err != nil {
|
||||
client.Close()
|
||||
return turnAllocateResult{Err: fmt.Errorf("client.Listen: %w", err)}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
relay, err := client.Allocate()
|
||||
dur := time.Since(start)
|
||||
if err != nil {
|
||||
// Inspect the STUN error code to give the user a precise diagnostic.
|
||||
code, reason := stunErrorOf(err)
|
||||
// 401 with REALM/NONCE is the *expected* answer when probing without
|
||||
// credentials; surface that as a positive UnauthChallenge signal,
|
||||
// not as a failure, so the rule layer can flag "open relay" if we
|
||||
// got a 200 instead.
|
||||
if creds == nil && code == 401 {
|
||||
client.Close()
|
||||
return turnAllocateResult{
|
||||
UnauthChallenge: true,
|
||||
Duration: dur,
|
||||
AuthErrorCode: 401,
|
||||
AuthErrorReason: reason,
|
||||
}
|
||||
}
|
||||
client.Close()
|
||||
return turnAllocateResult{
|
||||
AuthErrorCode: code,
|
||||
AuthErrorReason: reason,
|
||||
Duration: dur,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
res := turnAllocateResult{
|
||||
RelayConn: relay,
|
||||
RelayAddr: relay.LocalAddr(),
|
||||
Duration: dur,
|
||||
}
|
||||
if udpAddr, ok := relay.LocalAddr().(*net.UDPAddr); ok {
|
||||
res.IsPrivateRelay = isPrivate(udpAddr.IP)
|
||||
}
|
||||
// We intentionally do not Close() the client here so that the relay
|
||||
// PacketConn stays usable; the caller closes both via RelayConn.Close().
|
||||
return res
|
||||
}
|
||||
|
||||
// runRelayEcho asks the TURN server to relay a single short datagram to the
|
||||
// configured probe peer. This proves that:
|
||||
// - CreatePermission succeeds (server acknowledges the Send indication),
|
||||
// - the TURN data path accepts traffic.
|
||||
//
|
||||
// A reply from the peer is not required or awaited.
|
||||
func runRelayEcho(relay net.PacketConn, peer string, timeout time.Duration) error {
|
||||
host, _, err := net.SplitHostPort(peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid probePeer %q: %w", peer, err)
|
||||
}
|
||||
if host == "" {
|
||||
return errors.New("empty probe peer host")
|
||||
}
|
||||
addr, err := net.ResolveUDPAddr("udp", peer)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve probe peer: %w", err)
|
||||
}
|
||||
if err := relay.SetWriteDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Single-byte DNS-shaped prefix; enough to trigger CreatePermission + Send.
|
||||
payload := []byte{0x00}
|
||||
if _, err := relay.WriteTo(payload, addr); err != nil {
|
||||
return fmt.Errorf("relay WriteTo: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// turnCredentials carries either explicit long-term credentials or values
|
||||
// derived from a REST-API shared secret.
|
||||
type turnCredentials struct {
|
||||
Username string
|
||||
Password string
|
||||
Realm string
|
||||
}
|
||||
|
||||
// restAPICredentials derives ephemeral credentials per the
|
||||
// draft-uberti-rtcweb-turn-rest scheme:
|
||||
//
|
||||
// username = "<unix_ts>:<optional_user>"
|
||||
// password = base64(hmac_sha1(secret, username))
|
||||
//
|
||||
// ttl is the validity window from now.
|
||||
func restAPICredentials(secret, user, realm string, ttl time.Duration) *turnCredentials {
|
||||
if ttl <= 0 {
|
||||
ttl = time.Hour
|
||||
}
|
||||
expiry := time.Now().Add(ttl).Unix()
|
||||
username := strconv.FormatInt(expiry, 10)
|
||||
if user != "" {
|
||||
username += ":" + user
|
||||
}
|
||||
mac := hmac.New(sha1.New, []byte(secret))
|
||||
mac.Write([]byte(username))
|
||||
password := base64.StdEncoding.EncodeToString(mac.Sum(nil))
|
||||
return &turnCredentials{
|
||||
Username: username,
|
||||
Password: password,
|
||||
Realm: realm,
|
||||
}
|
||||
}
|
||||
|
||||
// stunErrorOf parses a STUN error returned by pion/turn into (code, reason).
|
||||
// pion/turn does not wrap errors with %w, so we parse the formatted message.
|
||||
// pion formats error responses as: "... (error <code>: <reason>)"
|
||||
func stunErrorOf(err error) (int, string) {
|
||||
if err == nil {
|
||||
return 0, ""
|
||||
}
|
||||
msg := err.Error()
|
||||
if i := strings.LastIndex(msg, "(error "); i >= 0 {
|
||||
inner := strings.TrimSuffix(msg[i+7:], ")")
|
||||
if sep := strings.IndexByte(inner, ':'); sep > 0 {
|
||||
if code, err := strconv.Atoi(strings.TrimSpace(inner[:sep])); err == nil {
|
||||
return code, strings.TrimSpace(inner[sep+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, msg
|
||||
}
|
||||
132
checker/types.go
Normal file
132
checker/types.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// Package checker implements the STUN/TURN checker for happyDomain.
|
||||
//
|
||||
// The checker drives a target server through the STUN binding and TURN
|
||||
// allocation/relay protocols (RFC 5389, RFC 5766) using the Pion libraries,
|
||||
// then exposes a structured observation and a rich HTML report including
|
||||
// remediation guidance for the most common deployment mistakes.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// ObservationKeyStunTurn is the observation key for STUN/TURN test data.
|
||||
const ObservationKeyStunTurn sdk.ObservationKey = "stun_turn"
|
||||
|
||||
// Transport identifies the L4/L4-secure transport used to reach an endpoint.
|
||||
type Transport string
|
||||
|
||||
const (
|
||||
TransportUDP Transport = "udp"
|
||||
TransportTCP Transport = "tcp"
|
||||
TransportTLS Transport = "tls"
|
||||
TransportDTLS Transport = "dtls"
|
||||
)
|
||||
|
||||
// Endpoint is a single resolved server target to probe.
|
||||
type Endpoint struct {
|
||||
URI string `json:"uri"`
|
||||
Host string `json:"host"`
|
||||
Port uint16 `json:"port"`
|
||||
Transport Transport `json:"transport"`
|
||||
Secure bool `json:"secure"`
|
||||
IsTURN bool `json:"is_turn"` // false: STUN-only scheme, true: TURN scheme
|
||||
Source string `json:"source"` // "uri" or "srv:_turn._udp.example.com"
|
||||
}
|
||||
|
||||
// SubTestStatus mirrors sdk.Status as a string for JSON friendliness.
|
||||
type SubTestStatus string
|
||||
|
||||
const (
|
||||
SubTestOK SubTestStatus = "ok"
|
||||
SubTestInfo SubTestStatus = "info"
|
||||
SubTestWarn SubTestStatus = "warn"
|
||||
SubTestCrit SubTestStatus = "crit"
|
||||
SubTestSkipped SubTestStatus = "skipped"
|
||||
SubTestError SubTestStatus = "error"
|
||||
)
|
||||
|
||||
// SubTest is one fine-grained test executed against an endpoint.
|
||||
type SubTest struct {
|
||||
Name string `json:"name"`
|
||||
Status SubTestStatus `json:"status"`
|
||||
DurationMs int64 `json:"duration_ms"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
}
|
||||
|
||||
// EndpointReport gathers all sub-tests run against a single endpoint.
|
||||
type EndpointReport struct {
|
||||
Endpoint Endpoint `json:"endpoint"`
|
||||
SubTests []SubTest `json:"sub_tests"`
|
||||
}
|
||||
|
||||
// Worst returns the worst sub-test status of the endpoint.
|
||||
func (e EndpointReport) Worst() SubTestStatus {
|
||||
worst := SubTestOK
|
||||
for _, t := range e.SubTests {
|
||||
if statusRank(t.Status) > statusRank(worst) {
|
||||
worst = t.Status
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
|
||||
// FirstFailure returns the first non-OK/Info/Skipped sub-test.
|
||||
func (e EndpointReport) FirstFailure() *SubTest {
|
||||
for i := range e.SubTests {
|
||||
s := e.SubTests[i].Status
|
||||
if s != SubTestOK && s != SubTestInfo && s != SubTestSkipped {
|
||||
return &e.SubTests[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StunTurnData is the JSON-serializable observation payload.
|
||||
type StunTurnData struct {
|
||||
Zone string `json:"zone,omitempty"`
|
||||
Mode string `json:"mode,omitempty"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
Endpoints []EndpointReport `json:"endpoints"`
|
||||
GlobalError string `json:"global_error,omitempty"`
|
||||
}
|
||||
|
||||
// statusRank converts a SubTestStatus to an orderable severity.
|
||||
func statusRank(s SubTestStatus) int {
|
||||
switch s {
|
||||
case SubTestOK:
|
||||
return 0
|
||||
case SubTestSkipped:
|
||||
return 1
|
||||
case SubTestInfo:
|
||||
return 2
|
||||
case SubTestWarn:
|
||||
return 3
|
||||
case SubTestCrit:
|
||||
return 4
|
||||
case SubTestError:
|
||||
return 5
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// toSDKStatus converts the worst SubTestStatus seen to an sdk.Status.
|
||||
func toSDKStatus(s SubTestStatus) sdk.Status {
|
||||
switch s {
|
||||
case SubTestOK:
|
||||
return sdk.StatusOK
|
||||
case SubTestInfo, SubTestSkipped:
|
||||
return sdk.StatusInfo
|
||||
case SubTestWarn:
|
||||
return sdk.StatusWarn
|
||||
case SubTestCrit:
|
||||
return sdk.StatusCrit
|
||||
case SubTestError:
|
||||
return sdk.StatusError
|
||||
}
|
||||
return sdk.StatusUnknown
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue