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}})
| Order | Pref | Flags | Service | Replacement |
{{range .NAPTR}}
| {{.Order}} |
{{.Preference}} |
{{.Flags}} |
{{.Service}} |
{{if .Replacement}}{{.Replacement}}{{else}}—{{end}} |
{{end}}
{{end}}
{{if .SRV}}
SRV records ({{len .SRV}})
| Prefix | Target | Port | Prio/Weight | A / AAAA |
{{range .SRV}}
{{.Prefix}} |
{{.Target}} |
{{.Port}} |
{{.Priority}} / {{.Weight}} |
{{range .IPv4}}{{.}} {{end}}
{{range .IPv6}}{{.}} {{end}}
|
{{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
}