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." }