package checker import ( "context" "slices" "strconv" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // endpointReachableRule verifies that every probed endpoint accepts a // connection on its declared transport. type endpointReachableRule struct{} func (r *endpointReachableRule) Name() string { return "sip.endpoint_reachable" } func (r *endpointReachableRule) Description() string { return "Verifies that every discovered SIP endpoint accepts a connection on its transport." } func (r *endpointReachableRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadSIPData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Endpoints) == 0 { return []sdk.CheckState{notTestedState("sip.endpoint_reachable.skipped", "No endpoint discovered to probe.")} } var issues []Issue for _, ep := range data.Endpoints { // Skip "unresolvable target", that's the srvTargetsResolvableRule's concern. if !ep.Reachable && ep.ReachableErr == "" && ep.Error == "no A/AAAA records for target" { continue } if ep.Reachable { continue } code := CodeTCPUnreachable msg := "TCP port " + strconv.Itoa(int(ep.Port)) + " is closed or filtered on " + ep.Address + "." fix := "Verify the SIP server is running and the firewall/NAT forwards port " + strconv.Itoa(int(ep.Port)) + "." switch ep.Transport { case TransportUDP: code = CodeUDPUnreachable msg = "UDP port " + strconv.Itoa(int(ep.Port)) + " refused on " + ep.Address + "." fix = "Verify the SIP server listens on UDP " + strconv.Itoa(int(ep.Port)) + " and that no stateless firewall drops the reply." case TransportTLS: if ep.Error != "" && strings.HasPrefix(ep.Error, "tls handshake") { code = CodeTLSHandshake msg = "TLS handshake failed on " + ep.Address + ": " + strings.TrimPrefix(ep.Error, "tls handshake: ") fix = "Present a valid certificate (chain + SAN including `" + ep.Target + "`) and accept TLS 1.2+." } } issues = append(issues, Issue{ Code: code, Severity: SeverityCrit, Message: msg, Fix: fix, Endpoint: ep.Address, }) } // Nothing reachable at all. cov := computeCoverageView(data) if len(data.Endpoints) > 0 && !cov.AnyWorking { issues = append(issues, Issue{ Code: CodeAllDown, Severity: SeverityCrit, Message: "No SIP endpoint answered OPTIONS on any transport.", Fix: "Verify the SIP server is running and reachable on the published SRV ports.", }) } if len(issues) == 0 { return []sdk.CheckState{passState("sip.endpoint_reachable.ok", "All endpoints accepted a connection.")} } return statesFromIssues(issues) } // optionsResponseRule verifies that every reachable endpoint answers SIP // OPTIONS with a 2xx response. type optionsResponseRule struct{} func (r *optionsResponseRule) Name() string { return "sip.options_response" } func (r *optionsResponseRule) Description() string { return "Verifies that every reachable SIP endpoint answers OPTIONS with a 2xx response." } func (r *optionsResponseRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadSIPData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Endpoints) == 0 { return []sdk.CheckState{notTestedState("sip.options_response.skipped", "No endpoint discovered to probe.")} } var issues []Issue for _, ep := range data.Endpoints { switch { case ep.Reachable && !ep.OptionsSent: issues = append(issues, Issue{ Code: CodeOptionsNoAnswer, Severity: SeverityCrit, Message: ep.Address + " accepted the connection but the probe could not send an OPTIONS: " + ep.Error, Fix: "Investigate the server's SIP listener.", Endpoint: ep.Address, }) case ep.OptionsSent && ep.OptionsRawCode == 0: issues = append(issues, Issue{ Code: CodeOptionsNoAnswer, Severity: SeverityCrit, Message: ep.Address + " is reachable but silent on SIP OPTIONS.", Fix: "Enable unauthenticated OPTIONS (`handle_options = yes` in Kamailio, `allowguest = yes` in Asterisk/FreeSWITCH) or add the probe source to the ACL.", Endpoint: ep.Address, }) case ep.OptionsRawCode >= 300: issues = append(issues, Issue{ Code: CodeOptionsNon2xx, Severity: SeverityWarn, Message: ep.Address + " answered " + ep.OptionsStatus + " to OPTIONS.", Fix: "Check SIP routing / ACL. Some stacks reject unauthenticated OPTIONS with 403/404.", Endpoint: ep.Address, }) } } if len(issues) == 0 { return []sdk.CheckState{passState("sip.options_response.ok", "Every reachable endpoint answered OPTIONS with 2xx.")} } return statesFromIssues(issues) } // optionsCapabilitiesRule reviews what endpoints advertise in Allow: they // should at least list INVITE. A missing Allow header at all is surfaced // too, as a softer informational finding. type optionsCapabilitiesRule struct{} func (r *optionsCapabilitiesRule) Name() string { return "sip.options_capabilities" } func (r *optionsCapabilitiesRule) Description() string { return "Reviews the Allow header advertised in OPTIONS replies (INVITE support, Allow presence)." } func (r *optionsCapabilitiesRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadSIPData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } if len(data.Endpoints) == 0 { return []sdk.CheckState{notTestedState("sip.options_capabilities.skipped", "No endpoint discovered to probe.")} } var issues []Issue for _, ep := range data.Endpoints { if !ep.OK() { continue } switch { case len(ep.AllowMethods) > 0 && !slices.Contains(ep.AllowMethods, "INVITE"): issues = append(issues, Issue{ Code: CodeOptionsNoInvite, Severity: SeverityWarn, Message: ep.Address + " answered 2xx but does not advertise INVITE in Allow.", Fix: "Verify the dialplan / endpoint is allowed to place calls.", Endpoint: ep.Address, }) case len(ep.AllowMethods) == 0: issues = append(issues, Issue{ Code: CodeOptionsNoAllow, Severity: SeverityInfo, Message: ep.Address + " answered 2xx but did not advertise an Allow header.", Fix: "Configure the SIP stack to include Allow (benign but helps callers discover capabilities).", Endpoint: ep.Address, }) } } if len(issues) == 0 { return []sdk.CheckState{passState("sip.options_capabilities.ok", "Endpoints advertise INVITE in Allow.")} } return statesFromIssues(issues) } // ipv6CoverageRule verifies that at least one endpoint is reachable over // IPv6 whenever IPv4 is (i.e. we are not silently IPv4-only). type ipv6CoverageRule struct{} func (r *ipv6CoverageRule) Name() string { return "sip.ipv6_coverage" } func (r *ipv6CoverageRule) Description() string { return "Verifies at least one SIP endpoint is reachable over IPv6." } func (r *ipv6CoverageRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState { data, errSt := loadSIPData(ctx, obs) if errSt != nil { return []sdk.CheckState{*errSt} } cov := computeCoverageView(data) if cov.HasIPv4 && !cov.HasIPv6 { return statesFromIssues([]Issue{{ Code: CodeNoIPv6, Severity: SeverityInfo, Message: "No IPv6 endpoint reachable.", Fix: "Publish AAAA records for the SRV targets.", }}) } return []sdk.CheckState{passState("sip.ipv6_coverage.ok", "At least one SIP endpoint is reachable over IPv6.")} }