package checker import ( "encoding/json" "fmt" "html/template" "net" "slices" "strconv" "strings" "time" sdk "git.happydns.org/checker-sdk-go/checker" ) // ─── View models ──────────────────────────────────────────────────── type reportFix struct { Severity string Code string Message string Fix string Endpoint string } type reportTLSPosture struct { CheckedAt time.Time ChainValid *bool HostnameMatch *bool NotAfter time.Time TLSVersion string Issues []reportFix } type reportEndpoint struct { Transport string TransportTag string // "SIP/UDP", "SIP/TCP", "SIPS/TLS" SRVPrefix string Target string Port uint16 Address string IsIPv6 bool Reachable bool ReachableErr string TLSVersion string TLSCipher string OptionsSent bool OptionsStatus string OptionsRTTMs int64 ServerHeader string UserAgent string AllowMethods []string ContactURI string ElapsedMS int64 Error string OK bool StatusLabel string StatusClass string TLSPosture *reportTLSPosture } type reportSRVEntry struct { Prefix string Target string Port uint16 Priority uint16 Weight uint16 IPv4 []string IPv6 []string } type reportData struct { Domain string RunAt string StatusLabel string StatusClass string HasIssues bool Fixes []reportFix NAPTR []NAPTRRecord SRV []reportSRVEntry FallbackProbed bool Endpoints []reportEndpoint HasIPv4 bool HasIPv6 bool WorkingUDP bool WorkingTCP bool WorkingTLS bool HasTLSPosture bool } // ─── Template ─────────────────────────────────────────────────────── var reportTpl = template.Must(template.New("sip").Funcs(template.FuncMap{ "deref": func(b *bool) bool { return b != nil && *b }, }).Parse(` SIP Report, {{.Domain}}

SIP / VoIP, {{.Domain}}

{{.StatusLabel}}
{{if .WorkingUDP}}✓{{else}}✗{{end}} UDP {{if .WorkingTCP}}✓{{else}}✗{{end}} TCP {{if .WorkingTLS}}✓{{else}}✗{{end}} TLS IPv4 IPv6
{{if .FallbackProbed}}
No SIP SRV records were published. Probed the bare domain on default ports.
{{end}} {{if .RunAt}}
Checked {{.RunAt}}
{{end}}
{{if .HasIssues}}

What to fix

{{range .Fixes}}
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}} {{if .NAPTR}}

NAPTR ({{len .NAPTR}})

{{range .NAPTR}} {{end}}
OrderPrefFlagsServiceReplacement
{{.Order}} {{.Preference}} {{.Flags}} {{.Service}} {{if .Replacement}}{{.Replacement}}{{else}}.{{end}}
{{end}} {{if .SRV}}

SRV records ({{len .SRV}})

{{range .SRV}} {{end}}
PrefixTargetPortPrio/WeightA / AAAA
{{.Prefix}} {{.Target}} {{.Port}} {{.Priority}} / {{.Weight}} {{range .IPv4}}{{.}} {{end}} {{range .IPv6}}{{.}} {{end}}
{{end}} {{if .Endpoints}}

Endpoint probes ({{len .Endpoints}})

{{range .Endpoints}} {{.TransportTag}} · {{.Address}} {{.StatusLabel}}
Target
{{.Target}}:{{.Port}}{{if .SRVPrefix}} ({{.SRVPrefix}}){{end}}
Reachable
{{if .Reachable}}✓ connected{{else}}✗ {{if .ReachableErr}}{{.ReachableErr}}{{else}}unreachable{{end}}{{end}}
{{if or .TLSVersion .TLSCipher}}
TLS
{{.TLSVersion}}{{if and .TLSVersion .TLSCipher}} · {{end}}{{.TLSCipher}}
{{end}} {{if .OptionsSent}}
OPTIONS
{{if .OptionsStatus}}{{.OptionsStatus}}{{else}}no reply{{end}} {{if .OptionsRTTMs}} · {{.OptionsRTTMs}} ms{{end}}
{{end}} {{if .ServerHeader}}
Server
{{.ServerHeader}}
{{end}} {{if .UserAgent}}
User-Agent
{{.UserAgent}}
{{end}} {{if .ContactURI}}
Contact
{{.ContactURI}}
{{end}} {{if .AllowMethods}}
Allow
{{range .AllowMethods}}{{.}}{{end}}
{{end}} {{if .TLSPosture}}
TLS posture
{{if .TLSPosture.ChainValid}} {{if deref .TLSPosture.ChainValid}}✓ chain valid{{else}}✗ chain invalid{{end}} {{end}} {{if .TLSPosture.HostnameMatch}} · {{if deref .TLSPosture.HostnameMatch}}✓ hostname match{{else}}✗ hostname mismatch{{end}} {{end}} {{if not .TLSPosture.NotAfter.IsZero}} · expires {{.TLSPosture.NotAfter.Format "2006-01-02"}} {{end}} {{if not .TLSPosture.CheckedAt.IsZero}}
TLS checked {{.TLSPosture.CheckedAt.Format "2006-01-02 15:04 MST"}}
{{end}} {{range .TLSPosture.Issues}}
{{.Code}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}}
Duration
{{.ElapsedMS}} ms
{{if .Error}}
Error
{{.Error}}
{{end}}
{{end}}
{{end}} `)) // ─── Rendering ────────────────────────────────────────────────────── // GetHTMLReport implements sdk.CheckerHTMLReporter. Related TLS // observations (tls_probes) are folded in so cert posture surfaces on // the SIP page directly. func (p *sipProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) { var d SIPData if err := json.Unmarshal(rctx.Data(), &d); err != nil { return "", fmt.Errorf("unmarshal sip observation: %w", err) } view := buildReportData(&d, rctx.Related(TLSRelatedKey), rctx.States()) var buf strings.Builder if err := reportTpl.Execute(&buf, view); err != nil { return "", fmt.Errorf("render sip report: %w", err) } return buf.String(), nil } func buildReportData(d *SIPData, related []sdk.RelatedObservation, states []sdk.CheckState) reportData { tlsByAddr := indexTLSByAddress(related) // Coverage is a pure aggregation of the raw endpoint probes: it // powers the header chips and is NOT a judgment. cov := computeCoverageView(d) view := reportData{ Domain: d.Domain, RunAt: d.RunAt, FallbackProbed: d.SRV.FallbackProbed, HasIPv4: cov.HasIPv4, HasIPv6: cov.HasIPv6, WorkingUDP: cov.WorkingUDP, WorkingTCP: cov.WorkingTCP, WorkingTLS: cov.WorkingTLS, HasTLSPosture: len(tlsByAddr) > 0, } // Hint/fix section reads ONLY from ctx.States(): Message, Meta["fix"], // Status. When no states are supplied (data-only rendering path), we // skip the section entirely and show a neutral status based on the // raw probe facts. view.Fixes, view.StatusLabel, view.StatusClass = buildFixesFromStates(states) view.HasIssues = len(view.Fixes) > 0 if len(states) == 0 { // Data-only view: no judgment, no hint block. Status reflects // raw reachability only. view.HasIssues = false if len(d.Endpoints) == 0 { view.StatusLabel = "UNKNOWN" view.StatusClass = "muted" } else if cov.AnyWorking { view.StatusLabel = "OK" view.StatusClass = "ok" } else { view.StatusLabel = "FAIL" view.StatusClass = "fail" } } view.NAPTR = append(view.NAPTR, d.NAPTR...) addSRV := func(prefix string, records []SRVRecord) { for _, r := range records { view.SRV = append(view.SRV, reportSRVEntry{ Prefix: prefix, Target: r.Target, Port: r.Port, Priority: r.Priority, Weight: r.Weight, IPv4: r.IPv4, IPv6: r.IPv6, }) } } addSRV("_sip._udp", d.SRV.UDP) addSRV("_sip._tcp", d.SRV.TCP) addSRV("_sips._tcp", d.SRV.SIPS) for _, ep := range d.Endpoints { re := reportEndpoint{ Transport: string(ep.Transport), TransportTag: transportTag(ep.Transport), SRVPrefix: ep.SRVPrefix, Target: ep.Target, Port: ep.Port, Address: ep.Address, IsIPv6: ep.IsIPv6, Reachable: ep.Reachable, ReachableErr: ep.ReachableErr, TLSVersion: ep.TLSVersion, TLSCipher: ep.TLSCipher, OptionsSent: ep.OptionsSent, OptionsStatus: ep.OptionsStatus, OptionsRTTMs: ep.OptionsRTTMs, ServerHeader: ep.ServerHeader, UserAgent: ep.UserAgent, AllowMethods: ep.AllowMethods, ContactURI: ep.ContactURI, ElapsedMS: ep.ElapsedMS, Error: ep.Error, OK: ep.OK(), } if re.OK { re.StatusLabel = "OK" re.StatusClass = "ok" } else if ep.Reachable { re.StatusLabel = "partial" re.StatusClass = "warn" } else { re.StatusLabel = "unreachable" re.StatusClass = "fail" } if meta, hit := tlsByAddr[ep.Address]; hit { re.TLSPosture = meta } else if meta, hit := tlsByAddr[endpointKey(ep.Target, ep.Port)]; hit { re.TLSPosture = meta } view.Endpoints = append(view.Endpoints, re) } return view } func transportTag(t Transport) string { switch t { case TransportUDP: return "SIP/UDP" case TransportTCP: return "SIP/TCP" case TransportTLS: return "SIPS/TLS" } return string(t) } func endpointKey(host string, port uint16) string { return net.JoinHostPort(host, strconv.Itoa(int(port))) } // buildFixesFromStates projects the rule-produced CheckStates onto the // report's hint/fix list. It reads ONLY from sdk.CheckState fields: // Message, Meta["fix"], Status, Code, Subject. No re-derivation from raw // observation happens here. // // Returns the (sorted) fixes plus the overall status label/class. When // states is empty, callers skip the hint section entirely; the neutral // status returned here ("OK") is meant to be overridden by the caller in // that data-only path. func buildFixesFromStates(states []sdk.CheckState) ([]reportFix, string, string) { var fixes []reportFix worst := sdk.StatusOK for _, s := range states { // Only surface states that carry a finding (non-OK, non-Unknown). switch s.Status { case sdk.StatusCrit, sdk.StatusWarn, sdk.StatusInfo, sdk.StatusError: default: continue } sev := statusToSeverity(s.Status) fix, _ := s.Meta["fix"].(string) fixes = append(fixes, reportFix{ Severity: sev, Code: s.Code, Message: s.Message, Fix: fix, Endpoint: s.Subject, }) if statusRank(s.Status) > statusRank(worst) { worst = s.Status } } sevRank := func(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } slices.SortStableFunc(fixes, func(a, b reportFix) int { return sevRank(a.Severity) - sevRank(b.Severity) }) var label, class string switch { case len(fixes) == 0: label, class = "OK", "ok" case worst == sdk.StatusCrit || worst == sdk.StatusError: label, class = "FAIL", "fail" case worst == sdk.StatusWarn: label, class = "WARN", "warn" default: label, class = "INFO", "muted" } return fixes, label, class } func statusToSeverity(s sdk.Status) string { switch s { case sdk.StatusCrit, sdk.StatusError: return SeverityCrit case sdk.StatusWarn: return SeverityWarn case sdk.StatusInfo: return SeverityInfo default: return SeverityInfo } } func statusRank(s sdk.Status) int { switch s { case sdk.StatusCrit, sdk.StatusError: return 3 case sdk.StatusWarn: return 2 case sdk.StatusInfo: return 1 default: return 0 } } // indexTLSByAddress returns a map keyed by "host:port" pointing at a // reportTLSPosture, so the template can match a related observation to // the right endpoint. func indexTLSByAddress(related []sdk.RelatedObservation) map[string]*reportTLSPosture { out := map[string]*reportTLSPosture{} for _, r := range related { v := parseTLSRelated(r) if v == nil { continue } addr := v.address() if addr == "" { continue } posture := &reportTLSPosture{ CheckedAt: r.CollectedAt, ChainValid: v.ChainValid, HostnameMatch: v.HostnameMatch, NotAfter: v.NotAfter, TLSVersion: v.TLSVersion, } for _, is := range v.Issues { sev := strings.ToLower(is.Severity) if sev != SeverityCrit && sev != SeverityWarn && sev != SeverityInfo { continue } posture.Issues = append(posture.Issues, reportFix{ Severity: sev, Code: is.Code, Message: is.Message, Fix: is.Fix, }) } out[addr] = posture } return out }