// 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 ( "fmt" "html/template" "sort" "strings" sdk "git.happydns.org/checker-sdk-go/checker" ) // renderReport builds the HTML iframe contents displayed in the // happyDomain UI. The layout is deliberately close to the XMPP/TLS // reports so operators get a consistent experience across checkers. // // Structure: // 1. Header with overall status badge + SSHFP verdict chips. // 2. "What to fix" list (the most common / highest-severity issues // with inline copy-pasteable sshd_config or DNS snippets). // 3. Per-endpoint details (banner, host keys, algorithm tables, // auth methods). // // We render the algorithm tables with per-row severity classes so the // weak/broken entries light up visually. func renderReport(d *SSHData, rctx sdk.ReportContext) (string, error) { var states []sdk.CheckState if rctx != nil { states = rctx.States() } view := buildReportData(d, states) var buf strings.Builder if err := reportTpl.Execute(&buf, view); err != nil { return "", fmt.Errorf("render ssh report: %w", err) } return buf.String(), nil } type reportView struct { Domain string RunAt string StatusLabel string StatusClass string HasIssues bool TopFixes []reportFix SSHFPPresent bool SSHFPMatched bool SSHFPRecords []reportSSHFPRecord Endpoints []reportEndpoint HasAuthProbe bool AnyIPv4, AnyIPv6 bool } type reportFix struct { Severity string Code string Message string Fix string Endpoint string } type reportSSHFPRecord struct { Algorithm uint8 AlgoName string Type uint8 TypeName string Fingerprint string Matched bool } type reportEndpoint struct { Address string Host string Port uint16 IsIPv6 bool TCPConnected bool Banner string SoftwareVer string Vendor string ElapsedMS int64 Error string StatusLabel string StatusClass string AnyFail bool HostKeys []reportHostKey AlgoTables []reportAlgoTable AuthMethods []reportAuthMethod Issues []reportFix } type reportHostKey struct { Type string Bits int SHA256 string SHA1 string SSHFPMatched bool SSHFPFamily string SSHFPSnippet string } type reportAlgoTable struct { Title string Rows []reportAlgoRow } type reportAlgoRow struct { Name string Severity string // "", "warn", "crit", "info" Note string } type reportAuthMethod struct { Name string Severity string // "ok", "warn" Note string } func buildReportData(d *SSHData, states []sdk.CheckState) reportView { v := reportView{ Domain: d.Domain, RunAt: d.CollectedAt.Format("2006-01-02 15:04 MST"), SSHFPPresent: d.SSHFP.Present, } // Deduplicate: the same weak cipher reported by two endpoints merges into one row. // When no states are available, fall back to data-only rendering with no hints. type fix struct { severity string code string message string fixText string endpoint string } stateFix := func(s sdk.CheckState) (fix, bool) { sev := statusToSeverity(s.Status) if sev == "" { return fix{}, false } var fixText string if s.Meta != nil { if raw, ok := s.Meta["fix"]; ok { if str, ok := raw.(string); ok { fixText = str } } } return fix{ severity: sev, code: s.Code, message: s.Message, fixText: fixText, endpoint: s.Subject, }, true } // Per-endpoint grouping by Subject (endpoint Address). perEp := map[string][]fix{} var allFixes []fix seen := map[string]bool{} for _, s := range states { f, ok := stateFix(s) if !ok { continue } if f.endpoint != "" { perEp[f.endpoint] = append(perEp[f.endpoint], f) } key := f.code + "|" + f.message if seen[key] { continue } seen[key] = true allFixes = append(allFixes, f) } sort.SliceStable(allFixes, func(i, j int) bool { return sevRank(allFixes[i].severity) < sevRank(allFixes[j].severity) }) for _, f := range allFixes { if f.severity == SeverityInfo && !strings.Contains(f.code, "sshfp") { continue // informational clutter, keep in per-endpoint only } v.TopFixes = append(v.TopFixes, reportFix{ Severity: f.severity, Code: f.code, Message: f.message, Fix: f.fixText, Endpoint: f.endpoint, }) } v.HasIssues = len(v.TopFixes) > 0 worst := SeverityOK for _, f := range allFixes { if f.severity == SeverityCrit { worst = SeverityCrit break } if f.severity == SeverityWarn && worst != SeverityCrit { worst = SeverityWarn } } switch worst { case SeverityCrit: v.StatusLabel = "FAIL" v.StatusClass = "fail" case SeverityWarn: v.StatusLabel = "WARN" v.StatusClass = "warn" default: v.StatusLabel = "OK" v.StatusClass = "ok" } // SSHFP records table. for _, rr := range d.SSHFP.Records { matched := false for _, ep := range d.Endpoints { for _, k := range ep.HostKeys { if k.SSHFPAlgo == rr.Algorithm { if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, k.SHA256) { matched = true } if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, k.SHA1) { matched = true } } } } if matched { v.SSHFPMatched = true } v.SSHFPRecords = append(v.SSHFPRecords, reportSSHFPRecord{ Algorithm: rr.Algorithm, AlgoName: sshfpAlgoName(rr.Algorithm), Type: rr.Type, TypeName: sshfpHashName(rr.Type), Fingerprint: rr.Fingerprint, Matched: matched, }) } for _, ep := range d.Endpoints { re := reportEndpoint{ Address: ep.Address, Host: ep.Host, Port: ep.Port, IsIPv6: ep.IsIPv6, TCPConnected: ep.TCPConnected, Banner: ep.Banner, SoftwareVer: ep.SoftVer, Vendor: ep.Vendor, ElapsedMS: ep.ElapsedMS, Error: ep.Error, } if ep.IsIPv6 { v.AnyIPv6 = true } else { v.AnyIPv4 = true } perEpIssues := perEp[ep.Address] // Per-endpoint status label. epWorst := SeverityOK for _, f := range perEpIssues { if f.severity == SeverityCrit { epWorst = SeverityCrit break } if f.severity == SeverityWarn && epWorst != SeverityCrit { epWorst = SeverityWarn } } switch epWorst { case SeverityCrit: re.StatusLabel = "FAIL" re.StatusClass = "fail" re.AnyFail = true case SeverityWarn: re.StatusLabel = "WARN" re.StatusClass = "warn" default: re.StatusLabel = "OK" re.StatusClass = "ok" } for _, k := range ep.HostKeys { rh := reportHostKey{ Type: k.Type, Bits: k.Bits, SHA256: k.SHA256, SHA1: k.SHA1, } rh.SSHFPMatched = k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 rh.SSHFPFamily = sshfpAlgoName(k.SSHFPAlgo) rh.SSHFPSnippet = fmt.Sprintf("%d 2 %s", k.SSHFPAlgo, k.SHA256) re.HostKeys = append(re.HostKeys, rh) } re.AlgoTables = []reportAlgoTable{ {Title: "Key exchange (KEX)", Rows: algoRows(ep.KEX, kexAlgos)}, {Title: "Server host keys", Rows: algoRows(ep.HostKey, hostKeyAlgos)}, {Title: "Ciphers", Rows: algoRows(uniqueMerge(ep.CiphersC2S, ep.CiphersS2C), cipherAlgos)}, {Title: "MACs", Rows: algoRows(uniqueMerge(ep.MACsC2S, ep.MACsS2C), macAlgos)}, } if ep.AuthMethods != nil || ep.PasswordAuth || ep.PublicKeyAuth || ep.KeyboardInteractive { v.HasAuthProbe = true for _, m := range ep.AuthMethods { sev := SeverityOK note := "" switch m { case "password": sev = SeverityWarn note = "password auth over the internet is the #1 brute-force target" case "keyboard-interactive": note = "often used for 2FA, otherwise equivalent to password" case "publickey": note = "preferred method" } re.AuthMethods = append(re.AuthMethods, reportAuthMethod{ Name: m, Severity: sev, Note: note, }) } } for _, f := range perEpIssues { re.Issues = append(re.Issues, reportFix{ Severity: f.severity, Code: f.code, Message: f.message, Fix: f.fixText, }) } v.Endpoints = append(v.Endpoints, re) } return v } // Non-finding statuses return "" so callers skip them in fix listings. func statusToSeverity(s sdk.Status) string { switch s { case sdk.StatusCrit: return SeverityCrit case sdk.StatusWarn: return SeverityWarn case sdk.StatusInfo: return SeverityInfo } return "" } func algoRows(list []string, table map[string]algoVerdict) []reportAlgoRow { out := make([]reportAlgoRow, 0, len(list)) for _, name := range list { v := verdictFor(table, name) out = append(out, reportAlgoRow{ Name: name, Severity: v.severity, Note: v.reason, }) } return out } func sevRank(s string) int { switch s { case SeverityCrit: return 0 case SeverityWarn: return 1 case SeverityInfo: return 2 } return 3 } func sshfpAlgoName(a uint8) string { switch a { case 1: return "RSA" case 2: return "DSA" case 3: return "ECDSA" case 4: return "Ed25519" case 6: return "Ed448" } return fmt.Sprintf("algo %d", a) } func sshfpHashName(t uint8) string { switch t { case 1: return "SHA-1" case 2: return "SHA-256" } return fmt.Sprintf("hash %d", t) } var reportTpl = template.Must(template.New("ssh").Funcs(template.FuncMap{ "sevClass": func(s string) string { switch s { case SeverityCrit: return "fail" case SeverityWarn: return "warn" case SeverityInfo: return "muted" case SeverityOK: return "ok" } return "" }, }).Parse(` SSH Report: {{.Domain}}

SSH: {{.Domain}}

{{.StatusLabel}}
{{if .SSHFPPresent}} {{if .SSHFPMatched}}SSHFP verified {{else}}SSHFP mismatch{{end}} {{else}}no SSHFP{{end}} {{if .AnyIPv4}}IPv4{{end}} {{if .AnyIPv6}}IPv6{{end}}
Checked {{.RunAt}}
{{if .HasIssues}}

What to fix

{{range .TopFixes}}
{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}}
{{end}} {{if .SSHFPPresent}}

SSHFP records

{{range .SSHFPRecords}} {{end}}
AlgorithmHashFingerprintStatus
{{.AlgoName}} ({{.Algorithm}}) {{.TypeName}} ({{.Type}}) {{.Fingerprint}} {{if .Matched}}match{{else}}no match{{end}}
{{else}}

SSHFP records

No SSHFP records are published for this service. Clients trust the host key the first time they connect (TOFU). Publishing SSHFP records (with DNSSEC) lets clients verify the server automatically.

{{end}} {{if .Endpoints}}

Endpoints ({{len .Endpoints}})

{{range .Endpoints}} {{.Address}}{{if .Banner}} · {{.Banner}}{{end}} {{.StatusLabel}}
Host
{{.Host}}
IP
{{.Address}}{{if .IsIPv6}} (IPv6){{end}}
TCP
{{if .TCPConnected}}✓ connected{{else}}✗ failed{{end}}
{{if .SoftwareVer}}
Version
{{.SoftwareVer}}{{if .Vendor}} · {{.Vendor}}{{end}}
{{end}}
Duration
{{.ElapsedMS}} ms
{{if .Error}}
Error
{{.Error}}
{{end}}
{{if .HostKeys}}

Host keys

{{range .HostKeys}} {{end}}
TypeBitsSHA-256 fingerprintSSHFP
{{.Type}} {{if .Bits}}{{.Bits}}{{else}}-{{end}} {{.SHA256}} {{if .SSHFPMatched}}verified {{else}}no match
Add: IN SSHFP {{.SSHFPSnippet}}
{{end}}
{{end}} {{range .AlgoTables}} {{if .Rows}}

{{.Title}}

{{range .Rows}} {{end}}
AlgorithmVerdict
{{.Name}} {{if eq .Severity "crit"}}broken {{else if eq .Severity "warn"}}weak {{else if eq .Severity "info"}}info {{else if eq .Severity "ok"}}good {{else}}OK{{end}} {{if .Note}} {{.Note}}{{end}}
{{end}} {{end}} {{if .AuthMethods}}

Authentication methods

{{range .AuthMethods}}{{.Name}}{{end}}
{{end}} {{if .Issues}}

Findings

{{range .Issues}}
{{.Code}}
{{.Message}}
{{if .Fix}}
→ {{.Fix}}
{{end}}
{{end}} {{end}}
{{end}}
{{end}} `))