208 lines
7.4 KiB
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.")}
|
|
}
|