checker-sip/checker/rules_endpoint.go

208 lines
7.4 KiB
Go

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