diff --git a/CHANGELOG.md b/CHANGELOG.md index 69eaa4d..a7bb538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Added +- A **DNS** badge in the domain list, one per row, carrying the same + ok/warn/error/unknown vocabulary as the rest of the panel: the worst of that + domain's DKIM, SPF and DMARC checks, so a domain whose records were never + published is visible without opening it. The badge links to that domain's + DNS status card. The checks run concurrently across the listed domains — + each carries its own timeout, and in series a dead resolver would multiply + that wait by the number of domains — and share the checker's cache with the + domain page, so a repeat view costs no lookups. A domain whose DKIM key + cannot be read stays "unknown" rather than being reported as misconfigured, + since the missing half is this server's. - Machine metrics on the status page: a **Machine** card reporting the processor (busy percentage, core count, load average), memory and swap, and network throughput and totals per interface, read from the kernel's counters diff --git a/internal/web/handlers_domains.go b/internal/web/handlers_domains.go index 88eae4c..767e4fa 100644 --- a/internal/web/handlers_domains.go +++ b/internal/web/handlers_domains.go @@ -5,10 +5,20 @@ import ( "fmt" "net/http" "strconv" + "sync" + "github.com/mixeme/selfpost/internal/health" "github.com/mixeme/selfpost/internal/store" ) +// domainRow is one line of the domain list: the stored domain plus the rolled-up +// verdict of its published DNS records, so the operator sees which domains still +// need a record published without opening each one. +type domainRow struct { + store.Domain + DNS health.Status +} + // handleDashboard is the authenticated landing page: the list of sending // domains with their DKIM/selector and application counts, plus the add-domain // form (product.md). @@ -30,13 +40,43 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status "Title": "SelfPost", "User": currentUser(r), "Active": "domains", - "Domains": domains, + "Domains": s.domainRows(domains), "Error": formErr, "FormName": formName, "Flash": dashboardFlash(r), }) } +// domainRows attaches each domain's DNS verdict to its row. The checks run +// concurrently rather than one after another: each carries its own timeout, so +// in series a dead resolver would multiply that wait by the number of domains +// and the list would look hung. The checker caches results for a few minutes, +// so a repeat view of the list costs no lookups at all, and it is the same +// cache the domain page fills — opening a domain after the list is free. +func (s *Server) domainRows(domains []store.Domain) []domainRow { + rows := make([]domainRow, len(domains)) + var wg sync.WaitGroup + for i, d := range domains { + rows[i] = domainRow{Domain: d, DNS: health.StatusUnknown} + wg.Add(1) + go func() { + defer wg.Done() + record, err := s.domains.DKIMRecord(d) + if err != nil { + // Without the expected key there is nothing to compare DNS + // against; leave the row unknown rather than accusing the + // domain of a misconfiguration this server caused. + logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err) + return + } + dns, _ := s.domainDNS(d, record, false) + rows[i].DNS = dns.Overall + }() + } + wg.Wait() + return rows +} + // dashboardFlash maps a fixed redirect flag to a fixed message, so status text // after a redirect is never attacker-influenced. func dashboardFlash(r *http.Request) string { diff --git a/internal/web/static/panel.css b/internal/web/static/panel.css index c5b0ff5..09ccd9d 100644 --- a/internal/web/static/panel.css +++ b/internal/web/static/panel.css @@ -336,6 +336,11 @@ button.danger:hover, a.danger:hover { background: #912018; } .st-warn { background: var(--st-warn-bg); color: var(--st-warn-fg); border-color: var(--st-warn-border); } .st-error { background: var(--st-error-bg); color: var(--st-error-fg); border-color: var(--st-error-border); } .st-unknown { background: var(--st-unknown-bg); color: var(--st-unknown-fg); border-color: var(--st-unknown-border); } +/* In the domain list the badge is also the link to that domain's DNS section, + so it must not pick up the link colour and underline that would fight with + the badge's own palette. */ +a.st, a.st:hover { color: inherit; text-decoration: none; } +a.st:hover { filter: brightness(1.08); } /* Usage bars on the status page's machine card. rather than a div sized from the reading, because the CSP forbids inline styles (see the note at the top of this file) and a bar's length has to travel on an attribute. The diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index d9bcab5..fa8d1b7 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -22,12 +22,13 @@ {{if .Domains}} - + {{range .Domains}} + @@ -35,6 +36,9 @@ {{end}}
DomainSelectorApps
DomainDNSSelectorApps
{{.Name}}{{.DNS}} {{.DKIMSelector}} {{.AppCount}} Delete
+

The DNS badge is the worst of the domain's DKIM, SPF + and DMARC checks. Results are cached for a few minutes; open a domain for the + details and a Re-check button.

{{else}}

No domains yet. Add one above to get started.

{{end}}