package checker import ( "context" "fmt" "net" "strconv" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) func Rule() sdk.CheckRule { return &sipRule{} } type sipRule struct{} func (r *sipRule) Name() string { return "sip_server" } func (r *sipRule) Description() string { return "Checks DNS resolution, reachability and OPTIONS response of a SIP/VoIP server" } func (r *sipRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState { var data SIPData if err := obs.Get(ctx, ObservationKeySIP, &data); err != nil { return []sdk.CheckState{{ Status: sdk.StatusError, Message: fmt.Sprintf("failed to load SIP observation: %v", err), Code: "sip.observation_error", }} } issues := append([]Issue(nil), data.Issues...) related, _ := obs.GetRelated(ctx, TLSRelatedKey) issues = append(issues, tlsIssuesFromRelated(related)...) byEndpoint := map[string][]Issue{} var zoneIssues []Issue for _, is := range issues { if is.Endpoint == "" { zoneIssues = append(zoneIssues, is) continue } byEndpoint[is.Endpoint] = append(byEndpoint[is.Endpoint], is) } var out []sdk.CheckState out = append(out, zoneState(&data, zoneIssues)) for _, ep := range data.Endpoints { out = append(out, endpointState(ep, byEndpoint)) } if len(out) == 0 { return []sdk.CheckState{{ Status: sdk.StatusInfo, Message: "no SIP endpoint to evaluate", Code: "sip.no_endpoint", }} } return out } // zoneState summarises findings that are not tied to a specific endpoint: // SRV/NAPTR lookup errors, missing transports, overall coverage. func zoneState(data *SIPData, zoneIssues []Issue) sdk.CheckState { var transports []string if data.Coverage.WorkingUDP { transports = append(transports, "udp") } if data.Coverage.WorkingTCP { transports = append(transports, "tcp") } if data.Coverage.WorkingTLS { transports = append(transports, "tls") } meta := map[string]any{ "working_udp": data.Coverage.WorkingUDP, "working_tcp": data.Coverage.WorkingTCP, "working_tls": data.Coverage.WorkingTLS, "has_ipv4": data.Coverage.HasIPv4, "has_ipv6": data.Coverage.HasIPv6, "endpoints": len(data.Endpoints), "issue_count": len(data.Issues), } worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(zoneIssues) okMsg := fmt.Sprintf("SIP operational (%s, %d endpoints)", strings.Join(transports, "+"), len(data.Endpoints)) return buildCheckState(worst, data.Domain, "sip.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta) } // endpointState produces one CheckState per probed endpoint. func endpointState(ep EndpointProbe, byEndpoint map[string][]Issue) sdk.CheckState { subject := string(ep.Transport) + "://" + endpointSubject(ep) meta := map[string]any{ "transport": string(ep.Transport), "target": ep.Target, "port": ep.Port, "address": ep.Address, "is_ipv6": ep.IsIPv6, "reachable": ep.Reachable, } if ep.TLSVersion != "" { meta["tls_version"] = ep.TLSVersion } if ep.OptionsRawCode != 0 { meta["options_status"] = ep.OptionsStatus meta["options_rtt_ms"] = ep.OptionsRTTMs } // Match endpoint issues by either the address or the SRV target // (unresolvable-target issues key on ep.Target). var epIssues []Issue epIssues = append(epIssues, byEndpoint[ep.Address]...) if ep.Target != "" && ep.Target != ep.Address { epIssues = append(epIssues, byEndpoint[ep.Target]...) } worst, firstCrit, firstWarn, critMsgs, warnMsgs := reduceIssues(epIssues) okMsg := "OPTIONS " + ep.OptionsStatus if okMsg == "OPTIONS " { okMsg = "reachable" } return buildCheckState(worst, subject, "sip.endpoint.ok", okMsg, firstCrit, firstWarn, critMsgs, warnMsgs, meta) } // endpointSubject prefers the resolved address; falls back to target:port // when no address was reached (e.g. unresolvable SRV target). func endpointSubject(ep EndpointProbe) string { if ep.Address != "" { return ep.Address } if ep.Target != "" { return net.JoinHostPort(ep.Target, strconv.Itoa(int(ep.Port))) } return strconv.Itoa(int(ep.Port)) } func buildCheckState(worst sdk.Status, subject, okCode, okMsg, firstCrit, firstWarn string, critMsgs, warnMsgs []string, meta map[string]any) sdk.CheckState { switch worst { case sdk.StatusOK: return sdk.CheckState{Status: sdk.StatusOK, Subject: subject, Code: okCode, Message: okMsg, Meta: meta} case sdk.StatusInfo: return sdk.CheckState{Status: sdk.StatusInfo, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta} case sdk.StatusWarn: return sdk.CheckState{Status: sdk.StatusWarn, Subject: subject, Code: firstWarn, Message: joinTop(warnMsgs, 2), Meta: meta} default: return sdk.CheckState{Status: sdk.StatusCrit, Subject: subject, Code: firstCrit, Message: joinTop(critMsgs, 2), Meta: meta} } } // reduceIssues collapses a set of issues into a worst status, first codes // per severity, and separated message lists. // sdk.Status values are ordered numerically: OK < Info < Warn < Crit. func reduceIssues(issues []Issue) (worst sdk.Status, firstCrit, firstWarn string, critMsgs, warnMsgs []string) { worst = sdk.StatusOK for _, is := range issues { switch is.Severity { case SeverityCrit: if worst < sdk.StatusCrit { worst = sdk.StatusCrit } if firstCrit == "" { firstCrit = is.Code } critMsgs = append(critMsgs, is.Message) case SeverityWarn: if worst < sdk.StatusWarn { worst = sdk.StatusWarn } if firstWarn == "" { firstWarn = is.Code } warnMsgs = append(warnMsgs, is.Message) case SeverityInfo: if worst < sdk.StatusInfo { worst = sdk.StatusInfo } if firstWarn == "" { firstWarn = is.Code } warnMsgs = append(warnMsgs, is.Message) } } return } func joinTop(msgs []string, n int) string { if len(msgs) == 0 { return "" } if len(msgs) <= n { return strings.Join(msgs, "; ") } return strings.Join(msgs[:n], "; ") + fmt.Sprintf(" (+%d more)", len(msgs)-n) }