package checker import ( "context" "fmt" sdk "git.happydns.org/checker-sdk-go/checker" ) // turnOpenRelayRule flags servers that accept an unauthenticated TURN // Allocate (open relay, abuse vector) and warns on non-standard replies. type turnOpenRelayRule struct{} func (r *turnOpenRelayRule) Name() string { return "stun_turn.turn_open_relay" } func (r *turnOpenRelayRule) Description() string { return "Verifies the TURN server requires authentication (challenges unauthenticated Allocate with 401)." } func (r *turnOpenRelayRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.Mode == "stun" || !hasTURNEndpoint(data) { return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN endpoint to evaluate.")} } var states []sdk.CheckState seen := false for _, ep := range data.Endpoints { if !ep.TURNNoAuth.Attempted { continue } seen = true switch { case ep.TURNNoAuth.OK: // Allocate accepted without credentials => open relay. states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "stun_turn.turn_open_relay.open", Subject: epSubject(ep.Endpoint), Message: "TURN allocation accepted without authentication", Meta: map[string]any{"fix": "Enable long-term credentials (`lt-cred-mech` for coturn). Open relays are abused for spam and DDoS amplification."}, }) case ep.TURNNoAuth.UnauthChallenge: // Expected 401 + REALM/NONCE, nothing to emit, pass below. default: states = append(states, sdk.CheckState{ Status: sdk.StatusWarn, Code: "stun_turn.turn_open_relay.unexpected", Subject: epSubject(ep.Endpoint), Message: fmt.Sprintf("unexpected response (code=%d): %s", ep.TURNNoAuth.ErrorCode, ep.TURNNoAuth.ErrorReason), Meta: map[string]any{"fix": "Server did not behave like a standard TURN. Verify it actually implements RFC 5766."}, }) } } if !seen { return []sdk.CheckState{skippedState("stun_turn.turn_open_relay.skipped", "No TURN Allocate probe attempted.")} } if len(states) == 0 { return []sdk.CheckState{passState("stun_turn.turn_open_relay.ok", "Server correctly challenged unauthenticated Allocate requests.")} } return states } // turnAuthRule evaluates the outcome of the authenticated TURN Allocate. type turnAuthRule struct{} func (r *turnAuthRule) Name() string { return "stun_turn.turn_auth" } func (r *turnAuthRule) Description() string { return "Verifies the supplied TURN credentials (or REST shared secret) yield a successful Allocate." } func (r *turnAuthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if data.Mode == "stun" || !hasTURNEndpoint(data) { return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN endpoint to evaluate.")} } if !data.HasCreds { return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "No TURN credentials supplied; authenticated Allocate not attempted.")} } var states []sdk.CheckState seen := false for _, ep := range data.Endpoints { if !ep.TURNAuth.Attempted { continue } seen = true if ep.TURNAuth.OK { continue } states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "stun_turn.turn_auth.failed", Subject: epSubject(ep.Endpoint), Message: joinMsg(ep.TURNAuth.Error, fmt.Sprintf("STUN error code: %d", ep.TURNAuth.ErrorCode)), Meta: map[string]any{"fix": allocateFix(ep.TURNAuth.ErrorCode)}, }) } if !seen { return []sdk.CheckState{skippedState("stun_turn.turn_auth.skipped", "Authenticated Allocate not attempted on any endpoint.")} } if len(states) == 0 { return []sdk.CheckState{passState("stun_turn.turn_auth.ok", "Authenticated TURN Allocate succeeded on every endpoint.")} } return states } // turnRelayPublicRule flags private relay addresses (missing relay-ip). type turnRelayPublicRule struct{} func (r *turnRelayPublicRule) Name() string { return "stun_turn.relay_public" } func (r *turnRelayPublicRule) Description() string { return "Flags TURN servers whose allocated relay address is private/loopback (missing public relay-ip)." } func (r *turnRelayPublicRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } var states []sdk.CheckState seen := false for _, ep := range data.Endpoints { if !ep.TURNAuth.OK { continue } seen = true if !ep.TURNAuth.IsPrivateRelay { continue } states = append(states, sdk.CheckState{ Status: sdk.StatusCrit, Code: "stun_turn.relay_public.private", Subject: epSubject(ep.Endpoint), Message: fmt.Sprintf("relay address is private: %s", ep.TURNAuth.RelayAddr), Meta: map[string]any{"fix": "Set `relay-ip=` (coturn). The relay range must be publicly reachable for clients to use TURN."}, }) } if !seen { return []sdk.CheckState{skippedState("stun_turn.relay_public.skipped", "No successful TURN allocation to evaluate.")} } if len(states) == 0 { return []sdk.CheckState{passState("stun_turn.relay_public.ok", "Every relay address is public.")} } return states } // turnRelayEchoRule reports relay-path breakage. type turnRelayEchoRule struct{} func (r *turnRelayEchoRule) Name() string { return "stun_turn.relay_echo" } func (r *turnRelayEchoRule) Description() string { return "Verifies the TURN relay path can carry traffic to the configured probe peer (CreatePermission + Send)." } func (r *turnRelayEchoRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } var states []sdk.CheckState seen := false for _, ep := range data.Endpoints { if !ep.RelayEcho.Attempted { continue } seen = true if ep.RelayEcho.OK { continue } states = append(states, sdk.CheckState{ Status: sdk.StatusWarn, Code: "stun_turn.relay_echo.failed", Subject: epSubject(ep.Endpoint), Message: ep.RelayEcho.Error, Meta: map[string]any{"fix": "Relay path could not carry traffic to the probe peer. Check the firewall/NAT around the server's relay range (`min-port`/`max-port`/`relay-ip` for coturn)."}, }) } if !seen { return []sdk.CheckState{skippedState("stun_turn.relay_echo.skipped", "No relay allocation available to exercise.")} } if len(states) == 0 { return []sdk.CheckState{passState("stun_turn.relay_echo.ok", "Relay echo succeeded on every tested endpoint.")} } return states } // allocateFix mirrors the coturn/RFC 5766 guidance the old Collect emitted. func allocateFix(code int) string { switch code { case 401: return "Server kept rejecting the credentials. Check username/password (or the REST shared secret), and verify the server clock (NTP), as TURN nonces are time-sensitive." case 403: return "Server forbade the request. The user may not have allocation rights, or a peer-address filter is in effect." case 437: return "Allocation Mismatch. Wait a few seconds for the previous allocation to expire and retry, or restart the TURN server." case 441: return "Wrong Credentials. Double-check username/password; for REST-API auth ensure the shared secret matches the server's `static-auth-secret`." case 442: return "Unsupported Transport Protocol. Try a different transport in the URI (`?transport=tcp`/`udp`) or enable it server-side." case 486: return "Allocation Quota Reached. Lower per-user concurrent allocations or raise `user-quota`." case 508: return "Insufficient Capacity. Server is out of relay ports; raise `total-quota` or extend the `min-port`/`max-port` range." } return "TURN Allocate failed. Inspect the error and confirm the server speaks RFC 5766 on this transport." }