Initial commit
This commit is contained in:
commit
06036c89d9
29 changed files with 4891 additions and 0 deletions
679
checker/report.go
Normal file
679
checker/report.go
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
// 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 <contact@happydomain.org>.
|
||||
//
|
||||
// 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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
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(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSH Report: {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
pre { font-family: ui-monospace, monospace; font-size: .78rem; background: #111827; color: #e5e7eb; padding: .6rem .8rem; border-radius: 6px; overflow-x: auto; margin: .35rem 0 0; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: .5rem 0 .3rem; }
|
||||
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: .25rem; }
|
||||
th, td { text-align: left; padding: .25rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
tr.crit td:first-child { border-left: 3px solid #dc2626; }
|
||||
tr.warn td:first-child { border-left: 3px solid #f59e0b; }
|
||||
tr.info td:first-child { border-left: 3px solid #3b82f6; }
|
||||
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix.muted { border-color: #9ca3af; background: #f9fafb; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.fail { background: #fee2e2; color: #991b1b; }
|
||||
.chip.warn { background: #fef3c7; color: #92400e; }
|
||||
.chip.ok { background: #d1fae5; color: #065f46; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
|
||||
.fp {
|
||||
font-family: ui-monospace, monospace; font-size: .72rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>SSH: <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .SSHFPPresent}}
|
||||
{{if .SSHFPMatched}}<span class="badge ok">SSHFP verified</span>
|
||||
{{else}}<span class="badge fail">SSHFP mismatch</span>{{end}}
|
||||
{{else}}<span class="badge muted">no SSHFP</span>{{end}}
|
||||
{{if .AnyIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .AnyIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .TopFixes}}
|
||||
<div class="fix {{sevClass .Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .SSHFPPresent}}
|
||||
<div class="section">
|
||||
<h2>SSHFP records</h2>
|
||||
<table>
|
||||
<tr><th>Algorithm</th><th>Hash</th><th>Fingerprint</th><th>Status</th></tr>
|
||||
{{range .SSHFPRecords}}
|
||||
<tr>
|
||||
<td>{{.AlgoName}} ({{.Algorithm}})</td>
|
||||
<td>{{.TypeName}} ({{.Type}})</td>
|
||||
<td class="fp">{{.Fingerprint}}</td>
|
||||
<td>{{if .Matched}}<span class="badge ok">match</span>{{else}}<span class="badge warn">no match</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="section">
|
||||
<h2>SSHFP records</h2>
|
||||
<p class="note">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.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Endpoints}}
|
||||
<div class="section">
|
||||
<h2>Endpoints ({{len .Endpoints}})</h2>
|
||||
{{range .Endpoints}}
|
||||
<details{{if .AnyFail}} open{{end}}>
|
||||
<summary>
|
||||
<span class="conn-addr">{{.Address}}{{if .Banner}} · {{.Banner}}{{end}}</span>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
<dl class="kv">
|
||||
<dt>Host</dt><dd>{{.Host}}</dd>
|
||||
<dt>IP</dt><dd><code>{{.Address}}</code>{{if .IsIPv6}} (IPv6){{end}}</dd>
|
||||
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
{{if .SoftwareVer}}<dt>Version</dt><dd><code>{{.SoftwareVer}}</code>{{if .Vendor}} · <span class="note">{{.Vendor}}</span>{{end}}</dd>{{end}}
|
||||
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
|
||||
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
|
||||
{{if .HostKeys}}
|
||||
<h3>Host keys</h3>
|
||||
<table>
|
||||
<tr><th>Type</th><th>Bits</th><th>SHA-256 fingerprint</th><th>SSHFP</th></tr>
|
||||
{{range .HostKeys}}
|
||||
<tr>
|
||||
<td>{{.Type}}</td>
|
||||
<td>{{if .Bits}}{{.Bits}}{{else}}-{{end}}</td>
|
||||
<td class="fp">{{.SHA256}}</td>
|
||||
<td>
|
||||
{{if .SSHFPMatched}}<span class="badge ok">verified</span>
|
||||
{{else}}<span class="badge warn">no match</span>
|
||||
<div class="note">Add: <code>IN SSHFP {{.SSHFPSnippet}}</code></div>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{range .AlgoTables}}
|
||||
{{if .Rows}}
|
||||
<h3>{{.Title}}</h3>
|
||||
<table>
|
||||
<tr><th>Algorithm</th><th>Verdict</th></tr>
|
||||
{{range .Rows}}
|
||||
<tr class="{{.Severity}}">
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td>
|
||||
{{if eq .Severity "crit"}}<span class="badge fail">broken</span>
|
||||
{{else if eq .Severity "warn"}}<span class="badge warn">weak</span>
|
||||
{{else if eq .Severity "info"}}<span class="badge muted">info</span>
|
||||
{{else if eq .Severity "ok"}}<span class="badge ok">good</span>
|
||||
{{else}}<span class="badge ok">OK</span>{{end}}
|
||||
{{if .Note}} <span class="note">{{.Note}}</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .AuthMethods}}
|
||||
<h3>Authentication methods</h3>
|
||||
<div class="chiprow">
|
||||
{{range .AuthMethods}}<span class="chip {{sevClass .Severity}}">{{.Name}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Issues}}
|
||||
<h3>Findings</h3>
|
||||
{{range .Issues}}
|
||||
<div class="fix {{sevClass .Severity}}">
|
||||
<div class="code">{{.Code}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="footer">SSH checker: algorithm posture inspired by <a href="https://github.com/jtesta/ssh-audit">ssh-audit</a>. For client-side audits, run that tool locally.</p>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
Loading…
Add table
Add a link
Reference in a new issue