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.mixfed.ru) and not matching (mixfed.ru), 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:
2026-08-01 22:04:37 +03:00
parent 147072dbb9
commit ac5b37d1e2
28 changed files with 2112 additions and 101 deletions
+32 -1
View File
@@ -12,6 +12,7 @@ import (
"time"
"codeberg.org/mix/selfpost/internal/app"
"codeberg.org/mix/selfpost/internal/dnscheck"
"codeberg.org/mix/selfpost/internal/domain"
"codeberg.org/mix/selfpost/internal/store"
)
@@ -49,6 +50,15 @@ type Config struct {
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
TrustedProxyCIDRs []*net.IPNet
// TLSCertFile is the certificate Postfix serves on 465/587 (spec 8), read
// read-only by the status page to report how much validity is left.
TLSCertFile string
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
// connects to. The status page stats them: the first is required for mail
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
// only for the send log (the journal-milter fails open).
OpenDKIMSocket string
JournalSocket string
}
// Server is the panel HTTP application.
@@ -60,6 +70,7 @@ type Server struct {
tmpl *templates
sessions *sessionStore
setup *setupManager
dns *dnscheck.Checker
loginLimiter *rateLimiter
setupLimiter *rateLimiter
@@ -83,6 +94,10 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config
cfg: cfg,
tmpl: tmpl,
sessions: newSessionStore(),
// Published-DNS checks for the status page and the domain pages. The
// checker caches its own results, so page views do not each pay for a
// round of lookups (phase 13).
dns: dnscheck.New(),
// Setup: a handful of attempts per minute per IP is plenty for a
// legitimate admin and blunts automated probing (spec 7.6.1).
setupLimiter: newRateLimiter(10, time.Minute),
@@ -122,10 +137,20 @@ func (s *Server) Handler() http.Handler {
// Authenticated panel. Everything not matched by a more specific pattern
// above falls through to this sub-mux, wrapped once in the auth middleware.
authed := http.NewServeMux()
authed.HandleFunc("GET /{$}", s.handleDashboard)
// The landing page is the server status (phase 13.C): the first thing an
// administrator should see after logging in is whether the service is
// healthy, not the domain list. handleLogin still redirects to "/".
authed.HandleFunc("GET /{$}", redirectToStatus)
authed.HandleFunc("GET /status", s.handleStatus)
authed.HandleFunc("GET /status/fragment", s.handleStatusFragment)
authed.HandleFunc("POST /status/recheck", s.handleStatusRecheck)
authed.HandleFunc("GET /domains", s.handleDashboard)
authed.HandleFunc("POST /domains", s.handleAddDomain)
authed.HandleFunc("POST /domains/import", s.handleImportDomain)
authed.HandleFunc("GET /domains/{id}", s.handleDomainDetail)
authed.HandleFunc("POST /domains/{id}/dns-recheck", s.handleDomainDNSRecheck)
authed.HandleFunc("GET /domains/{id}/delete", s.handleDeleteConfirm)
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
@@ -159,6 +184,12 @@ func (s *Server) Handler() http.Handler {
return mux
}
// redirectToStatus points the panel root at the status page, so there is one
// canonical URL for that content instead of two (phase 13.C).
func redirectToStatus(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/status", http.StatusSeeOther)
}
func handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)