package checker import ( "encoding/json" "fmt" "html/template" "net" "sort" "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)) 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) reportData { tlsIssues := tlsIssuesFromRelated(related) tlsByAddr := indexTLSByAddress(related) allIssues := append([]Issue(nil), d.Issues...) allIssues = append(allIssues, tlsIssues...) view := reportData{ Domain: d.Domain, RunAt: d.RunAt, FallbackProbed: d.SRV.FallbackProbed, HasIPv4: d.Coverage.HasIPv4, HasIPv6: d.Coverage.HasIPv6, WorkingUDP: d.Coverage.WorkingUDP, WorkingTCP: d.Coverage.WorkingTCP, WorkingTLS: d.Coverage.WorkingTLS, HasIssues: len(allIssues) > 0, HasTLSPosture: len(tlsByAddr) > 0, } worst := SeverityInfo for _, is := range allIssues { if is.Severity == SeverityCrit { worst = SeverityCrit break } if is.Severity == SeverityWarn { worst = SeverityWarn } } switch { case len(allIssues) == 0: view.StatusLabel = "OK" view.StatusClass = "ok" case worst == SeverityCrit: view.StatusLabel = "FAIL" view.StatusClass = "fail" case worst == SeverityWarn: view.StatusLabel = "WARN" view.StatusClass = "warn" default: view.StatusLabel = "INFO" view.StatusClass = "muted" } // Sort fixes crit → warn → info. sevRank := func(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 default: return 2 } } sort.SliceStable(allIssues, func(i, j int) bool { return sevRank(allIssues[i].Severity) < sevRank(allIssues[j].Severity) }) for _, is := range allIssues { view.Fixes = append(view.Fixes, reportFix{ Severity: is.Severity, Code: is.Code, Message: is.Message, Fix: is.Fix, Endpoint: is.Endpoint, }) } 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))) } // 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 }