// This file is part of the happyDomain (R) project. // Copyright (c) 2020-2026 happyDomain // Authors: Pierre-Olivier Mercier, et al. // // This program is offered under a commercial and under the AGPL license. // For commercial licensing, contact us at . // // For AGPL licensing: // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package checker import ( "encoding/json" "fmt" "html/template" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) type reportData struct { ServiceDomain string Records []reportRecord Alerts []reportAlert Totals reportTotals } type reportRecord struct { Owner string Service string Proto string Target string Port uint16 Priority uint16 Weight uint16 IsNullTarget bool IsCNAME bool CNAMEChain string Addresses []string ResolveError string Probes []reportProbe } type reportProbe struct { Address string Proto string Connected bool LatencyMs float64 Error string StatusClass string StatusLabel string } type reportAlert struct { Severity string // "crit", "warn", "info" Title string Body template.HTML } type reportTotals struct { Records int OKProbes int BadProbes int } var htmlTpl = template.Must(template.New("srv").Parse(` SRV Records Report

SRV Records — {{if .ServiceDomain}}{{.ServiceDomain}}{{else}}service{{end}}

{{.Totals.Records}} record(s) {{.Totals.OKProbes}} reachable probe(s) {{if .Totals.BadProbes}}{{.Totals.BadProbes}} failed probe(s){{end}}
{{if .Alerts}}

What needs attention

{{range .Alerts}}
{{.Title}}
{{.Body}}
{{end}}
{{end}}

Records

{{range .Records}}
{{if .IsNullTarget}}(null target){{else}}{{.Target}}{{end}}:{{.Port}} prio {{.Priority}} · weight {{.Weight}} {{if .Service}}_{{.Service}}._{{.Proto}}{{end}} {{if .IsNullTarget}}null target{{end}} {{if .IsCNAME}}target is CNAME{{end}} {{if .ResolveError}}DNS error{{end}}
{{if .CNAMEChain}}

CNAME chain: {{.CNAMEChain}} — RFC 2782 forbids a CNAME as SRV target.

{{end}} {{if .ResolveError}}

Resolution failed: {{.ResolveError}}

{{end}} {{if .Addresses}}

Resolves to: {{range .Addresses}}{{.}} {{end}}

{{end}} {{if .Probes}} {{range .Probes}} {{end}}
AddressProtoStatusLatencyDetails
{{.Address}} {{.Proto}} {{.StatusLabel}} {{if .LatencyMs}}{{printf "%.1f ms" .LatencyMs}}{{end}} {{if .Error}}{{.Error}}{{end}}
{{end}}
{{end}}
`)) func (p *srvProvider) GetHTMLReport(ctx sdk.ReportContext) (string, error) { var d SRVData if err := json.Unmarshal(ctx.Data(), &d); err != nil { return "", fmt.Errorf("failed to unmarshal SRV report: %w", err) } rd := reportData{ServiceDomain: d.ServiceDomain} rd.Totals.Records = len(d.Records) var resolveFails, cnames, nulls []string type tcpFailure struct{ owner, address, err string } var tcpDown []tcpFailure for _, r := range d.Records { rec := reportRecord{ Owner: r.Owner, Service: r.Service, Proto: r.Proto, Target: r.Target, Port: r.Port, Priority: r.Priority, Weight: r.Weight, IsNullTarget: r.IsNullTarget, IsCNAME: r.IsCNAME, Addresses: r.Addresses, ResolveError: r.ResolveError, } if len(r.CNAMEChain) > 0 { rec.CNAMEChain = strings.Join(r.CNAMEChain, " → ") } if r.IsNullTarget { nulls = append(nulls, r.Owner) } if r.IsCNAME { cnames = append(cnames, template.HTMLEscapeString(r.Target)) } if r.ResolveError != "" { resolveFails = append(resolveFails, fmt.Sprintf("%s (%s)", template.HTMLEscapeString(r.Target), template.HTMLEscapeString(r.ResolveError))) } for _, pr := range r.Probes { rp := reportProbe{ Address: pr.Address, Proto: pr.Proto, Connected: pr.Connected, LatencyMs: pr.LatencyMs, Error: pr.Error, } switch { case pr.Connected: rp.StatusClass = "ok" rp.StatusLabel = "reachable" rd.Totals.OKProbes++ default: rp.StatusClass = "crit" rp.StatusLabel = "unreachable" rd.Totals.BadProbes++ if pr.Proto == "tcp" { tcpDown = append(tcpDown, tcpFailure{r.Owner, pr.Address, pr.Error}) } } rec.Probes = append(rec.Probes, rp) } rd.Records = append(rd.Records, rec) } if len(resolveFails) > 0 { rd.Alerts = append(rd.Alerts, reportAlert{ Severity: "crit", Title: fmt.Sprintf("DNS resolution failed for %d SRV target(s)", len(resolveFails)), Body: template.HTML(fmt.Sprintf( "%s
Clients will not be able to reach the service. Fix: either publish A/AAAA records for the target(s), or remove the broken SRV record.", strings.Join(resolveFails, "
"))), }) } if len(cnames) > 0 { rd.Alerts = append(rd.Alerts, reportAlert{ Severity: "warn", Title: "SRV target is a CNAME (RFC 2782 violation)", Body: template.HTML(fmt.Sprintf( "Target(s): %s
RFC 2782 requires SRV targets to resolve directly to A/AAAA. "+ "Some clients will refuse to follow the CNAME. Fix: point the SRV record to a hostname with A/AAAA records, "+ "or replace the CNAME with an ALIAS/ANAME at the DNS provider.", ""+strings.Join(cnames, ", ")+"")), }) } if len(tcpDown) > 0 { var items []string for _, f := range tcpDown { items = append(items, fmt.Sprintf("%s (%s): %s", template.HTMLEscapeString(f.address), template.HTMLEscapeString(f.owner), template.HTMLEscapeString(f.err))) } rd.Alerts = append(rd.Alerts, reportAlert{ Severity: "crit", Title: fmt.Sprintf("%d target(s) unreachable on their advertised TCP port", len(tcpDown)), Body: template.HTML(strings.Join(items, "
") + "
Check: (1) the server is running and bound to the right port; " + "(2) firewall/security-group allows inbound TCP to that port; " + "(3) the SRV record is not pointing at an old IP."), }) } if len(nulls) > 0 && len(nulls) == len(d.Records) { rd.Alerts = append(rd.Alerts, reportAlert{ Severity: "warn", Title: "All SRV records use the null target (\".\"): service is explicitly disabled", Body: template.HTML( "RFC 2782 defines a single SRV record with target \".\" to signal that the service is " + "intentionally not available. If this is what you want, the configuration is correct. " + "If you expected clients to reach this service, replace the null target with a real hostname."), }) } var buf strings.Builder if err := htmlTpl.Execute(&buf, rd); err != nil { return "", fmt.Errorf("failed to render SRV HTML report: %w", err) } return buf.String(), nil }