panel: server status page, per-domain DNS checks, /domains move
Phase 13. Two new packages and one new screen. internal/health owns the shared status vocabulary (ok/warn/error/unknown) and the local checks: supervisord's process table, TLS certificate expiry and the two milter sockets. Each check reports a problem as a status rather than an error, so one broken component costs a line and not the page. internal/dnscheck does the read-only lookups: forward-confirmed reverse DNS for SELFPOST_HOSTNAME, and per-domain DKIM (compared against the key this server actually signs with), SPF and DMARC. Every check is bounded by a timeout and cached, and the resolver sits behind an interface so the tests drive every branch without touching the network. The SPF check is deliberately shallow: it looks for a mechanism literally covering the server's address and does not follow include:/redirect=, so a record that authorises us through an include is reported as "cannot tell" rather than as a failure. /status renders both, with the local checks in an HTMX-polled fragment and the DNS lookups behind a Re-check button, and becomes the panel's landing page: / now redirects there and the domain list lives at /domains. The Reload button moves onto /status, where it reads as what it is — a drift-recovery for the daemons — with text explaining what it regenerates. A template test fails on any remaining href="/" so a stale link cannot silently land on the wrong screen. Also fixes a defect this made visible: the panel could never read the mail queue in the documented deployment. postqueue relies on its setgid-postdrop bit, which the compose file's no-new-privileges disables, so the Queue screen always said "Could not read the mail queue" — including in the released 1.0.0 image. The panel user is now a real member of postdrop, which needs no setgid transition. Verified in a container on the dev server against real DNS: PTR matching (selfpost.example.com) and not matching (example.com), DKIM absent and mismatched, SPF absent and via include:, DMARC p=quarantine/p=reject/absent, and a resolver timeout degrading to "unknown" without hanging the page. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,13 @@ package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/mix/selfpost/internal/health"
|
||||
)
|
||||
|
||||
// The navigation is rendered from the layout, not copied into each page, so
|
||||
@@ -45,3 +50,122 @@ func TestNavMarksActivePage(t *testing.T) {
|
||||
t.Errorf("inactive pages are not linked:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavLeadsWithStatusAndPointsDomainsAtItsOwnPath(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.pages["status"].ExecuteTemplate(&buf, "nav", map[string]any{
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
}); err != nil {
|
||||
t.Fatalf("execute nav: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `<span aria-current="page">Status</span>`) {
|
||||
t.Errorf("the status page is not marked active:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `href="/domains"`) {
|
||||
t.Errorf("Domains does not link to /domains:\n%s", out)
|
||||
}
|
||||
if strings.Index(out, "Status") > strings.Index(out, "Domains") {
|
||||
t.Errorf("Status is not the first navigation entry:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Since the panel root now redirects to the status page, a link left pointing at
|
||||
// "/" silently lands on the wrong screen instead of failing — so no template may
|
||||
// contain one (phase 13.C).
|
||||
func TestNoTemplateLinksToTheBareRoot(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `href="/"`) {
|
||||
t.Errorf(`%s links to "/", which is now the status redirect; link to /domains (or the intended page) instead`, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The reload action is a server-health control and lives only on the status
|
||||
// page (phase 13.D).
|
||||
func TestReloadFormLivesOnlyOnTheStatusPage(t *testing.T) {
|
||||
forEachTemplate(t, func(name, body string) {
|
||||
if strings.Contains(body, `action="/reload"`) && name != "status.html" {
|
||||
t.Errorf("%s still posts to /reload; the reload control belongs on the status page", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStatusPageRendersEveryCheck(t *testing.T) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
t.Fatalf("loadTemplates: %v", err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err = tmpl.pages["status"].ExecuteTemplate(&buf, "layout.html", map[string]any{
|
||||
"Title": "SelfPost — status",
|
||||
"User": "admin",
|
||||
"Active": "status",
|
||||
"Processes": []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
|
||||
},
|
||||
"ProcessStatus": health.StatusError,
|
||||
"QueueSummary": "Mail queue is empty",
|
||||
"QueueStatus": health.StatusOK,
|
||||
"Cert": health.Certificate{
|
||||
Path: "/etc/postfix/tls/fullchain.pem", Subject: "mail.example.com",
|
||||
NotAfter: time.Now().Add(30 * 24 * time.Hour), DaysLeft: 30,
|
||||
Status: health.StatusOK, Detail: "Valid for another 30 day(s).",
|
||||
},
|
||||
"Sockets": []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening."},
|
||||
},
|
||||
"SocketStatus": health.StatusOK,
|
||||
"OverallStatus": health.StatusError,
|
||||
"OverallHeading": "A component needs attention — see the details below.",
|
||||
"Hostname": "mail.example.com",
|
||||
"PTR": dnscheckResult{
|
||||
Status: health.StatusError,
|
||||
Detail: "No address has a reverse record.",
|
||||
Records: []string{"203.0.113.10 → no PTR record"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("execute status page: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, want := range []string{
|
||||
"opendkim", "FATAL", "Mail queue is empty", "mail.example.com",
|
||||
"203.0.113.10 → no PTR record", `action="/reload"`,
|
||||
`hx-get="/status/fragment"`, `class="st st-error"`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("status page is missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dnscheckResult mirrors dnscheck.Result's shape for the template test, so the
|
||||
// web package's template tests do not depend on the checker's constructor.
|
||||
type dnscheckResult struct {
|
||||
Status health.Status
|
||||
Detail string
|
||||
Records []string
|
||||
}
|
||||
|
||||
// forEachTemplate runs fn over every embedded template's source.
|
||||
func forEachTemplate(t *testing.T, fn func(name, body string)) {
|
||||
t.Helper()
|
||||
entries, err := fs.ReadDir(assetsFS, "templates")
|
||||
if err != nil {
|
||||
t.Fatalf("read templates: %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
body, err := fs.ReadFile(assetsFS, path.Join("templates", e.Name()))
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", e.Name(), err)
|
||||
}
|
||||
fn(e.Name(), string(body))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user