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
+1 -8
View File
@@ -1,12 +1,5 @@
{{define "content"}}
<div class="topbar">
<h1>SelfPost</h1>
<div class="actions muted">
<form class="inline" method="post" action="/reload">
<button type="submit">Reload</button>
</form>
</div>
</div>
<h1>Domains</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
+31 -1
View File
@@ -1,7 +1,7 @@
{{define "content"}}
<h1>{{.Domain.Name}}</h1>
<a class="back" href="/">&larr; All domains</a>
<a class="back" href="/domains">&larr; All domains</a>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{if .RateLimitErr}}<div class="flash error">{{.RateLimitErr}}</div>{{end}}
@@ -48,6 +48,36 @@
documentation). Mail is signed with selector <strong>{{.Domain.DKIMSelector}}</strong>.</p>
</div>
<div class="card">
<h2>DNS status <span class="st st-{{.DNS.Overall}}">{{.DNS.Overall}}</span></h2>
<p class="muted">What DNS publishes for <strong>{{.Domain.Name}}</strong> right
now, checked against the key this server signs with. Results are cached for a
few minutes — after publishing a record, use <em>Re-check</em>.</p>
<label>DKIM <span class="st st-{{.DNS.DKIM.Status}}">{{.DNS.DKIM.Status}}</span></label>
<p class="{{if eq .DNS.DKIM.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DKIM.Detail}}</p>
{{if .DNS.DKIM.Records}}<span class="code">{{range .DNS.DKIM.Records}}{{.}}
{{end}}</span>{{end}}
<label>SPF <span class="st st-{{.DNS.SPF.Status}}">{{.DNS.SPF.Status}}</span></label>
<p class="{{if eq .DNS.SPF.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.SPF.Detail}}</p>
{{if .DNS.SPF.Records}}<span class="code">{{range .DNS.SPF.Records}}{{.}}
{{end}}</span>{{end}}
<p class="muted">The SPF check is deliberately shallow: it looks for a
mechanism that literally covers this server's address and does not follow
<code>include:</code> or <code>redirect=</code>, so a record that authorises
the server through an include is reported as “cannot tell”, not as a failure.</p>
<label>DMARC <span class="st st-{{.DNS.DMARC.Status}}">{{.DNS.DMARC.Status}}</span></label>
<p class="{{if eq .DNS.DMARC.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARC.Detail}}</p>
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
{{end}}</span>{{end}}
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
<button type="submit">Re-check</button>
</form>
</div>
<div class="card">
<h2>Sending server settings</h2>
<p class="muted">Point the mail client or script at these settings and
+20 -1
View File
@@ -92,6 +92,24 @@
@media (prefers-color-scheme: dark) {
.nav .links [aria-current] { color: #e6e8eb !important; background: #22303f !important; }
}
/* Status badges: one vocabulary (ok/warn/error/unknown) shared by the server
status page and the per-domain DNS checks, so a colour means the same thing
everywhere. The class suffix is the check's own status value. */
.st {
display: inline-block; padding: 0.05rem 0.45rem; border-radius: 999px;
font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; border: 1px solid transparent;
}
.st-ok { background: #ecfdf3; color: #067647; border-color: #abefc6; }
.st-warn { background: #fffaeb; color: #b54708; border-color: #fedf89; }
.st-error { background: #fef3f2; color: #b42318; border-color: #fecdca; }
.st-unknown { background: #f0f2f4; color: #6b7280; border-color: #e2e5e9; }
@media (prefers-color-scheme: dark) {
.st-ok { background: #0d2818 !important; color: #75d99b !important; border-color: #1a5336 !important; }
.st-warn { background: #2e2308 !important; color: #f5c86b !important; border-color: #6b5210 !important; }
.st-error { background: #2d1211 !important; color: #f5a29b !important; border-color: #6b201a !important; }
.st-unknown { background: #22262b !important; color: #9aa3ad !important; border-color: #2b3138 !important; }
}
.code-row { display: flex; align-items: flex-start; gap: 0.5rem; }
.code-row .code { flex: 1; min-width: 0; }
button.copy {
@@ -121,7 +139,8 @@
{{define "nav"}}
<nav class="nav">
<div class="links">
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/">Domains</a>{{end}}
{{if eq .Active "status"}}<span aria-current="page">Status</span>{{else}}<a href="/status">Status</a>{{end}}
{{if eq .Active "domains"}}<span aria-current="page">Domains</span>{{else}}<a href="/domains">Domains</a>{{end}}
{{if eq .Active "sendlog"}}<span aria-current="page">Send log</span>{{else}}<a href="/sendlog">Send log</a>{{end}}
{{if eq .Active "queue"}}<span aria-current="page">Queue</span>{{else}}<a href="/queue">Queue</a>{{end}}
{{if eq .Active "logtail"}}<span aria-current="page">Log</span>{{else}}<a href="/logtail">Log</a>{{end}}
+43
View File
@@ -0,0 +1,43 @@
{{define "content"}}
<h1>Server status</h1>
{{if .Flash}}<div class="flash">{{.Flash}}</div>{{end}}
{{template "status_body" .}}
<div class="card">
<h2>Hostname and reverse DNS <span class="st st-{{.PTR.Status}}">{{.PTR.Status}}</span></h2>
<p class="muted">Receiving servers check that the name this server announces
resolves to its address <em>and</em> that the address resolves back to the same
name (forward-confirmed reverse DNS). A missing or mismatched reverse record is
the most common reason self-hosted mail is rejected or scored as spam. The
reverse record is set at the hosting provider, not in the domain's DNS zone.</p>
<label>Server hostname</label>
<span class="code">{{if .Hostname}}{{.Hostname}}{{else}}(SELFPOST_HOSTNAME is not set){{end}}</span>
{{if .PTR.Records}}
<label>Forward and reverse lookup</label>
<span class="code">{{range .PTR.Records}}{{.}}
{{end}}</span>
{{end}}
<p class="{{if eq .PTR.Status "ok"}}muted{{else}}error{{end}}">{{.PTR.Detail}}</p>
<form class="inline" method="post" action="/status/recheck">
<button type="submit">Re-check DNS</button>
</form>
</div>
<div class="card">
<h2>Configuration</h2>
<p class="muted">Regenerates the OpenDKIM and Postfix configuration from the
database and reloads both daemons. Use it if you edited the files by hand,
restored a backup, or the running configuration looks out of step with the
domain and application lists. It does not touch the mail queue or the TLS
certificate, and it is safe to run at any time.</p>
<form class="inline" method="post" action="/reload">
<button type="submit">Reload configuration</button>
</form>
</div>
{{end}}
+65
View File
@@ -0,0 +1,65 @@
{{define "status_body"}}
<div id="status-body" hx-get="/status/fragment" hx-trigger="every 5s" hx-swap="outerHTML">
<div class="card">
<h2>Overall <span class="st st-{{.OverallStatus}}">{{.OverallStatus}}</span></h2>
<p class="muted">{{.OverallHeading}}</p>
</div>
<div class="card">
<h2>Processes <span class="st st-{{.ProcessStatus}}">{{.ProcessStatus}}</span></h2>
{{if .ProcessError}}
<p class="error">Could not ask supervisord for the process list.</p>
{{else}}
<table>
<thead><tr><th>Program</th><th>State</th><th>Detail</th></tr></thead>
<tbody>
{{range .Processes}}
<tr>
<td>{{.Name}}</td>
<td><span class="st st-{{.Status}}">{{.State}}</span></td>
<td class="muted">{{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
<div class="card">
<h2>Mail queue <span class="st st-{{.QueueStatus}}">{{.QueueStatus}}</span></h2>
{{if .QueueError}}
<p class="error">{{.QueueError}}</p>
{{else}}
<p>{{if .QueueSummary}}{{.QueueSummary}}{{else}}Mail queue is empty.{{end}}
<a href="/queue">Full queue</a></p>
{{end}}
</div>
<div class="card">
<h2>TLS certificate <span class="st st-{{.Cert.Status}}">{{.Cert.Status}}</span></h2>
<p class="muted">The certificate Postfix serves on port 465{{if .Cert.Subject}} ({{.Cert.Subject}}){{end}}.
It is supplied by the reverse proxy through a read-only mount; SelfPost only reads it.</p>
{{if not .Cert.NotAfter.IsZero}}
<label>Expires</label>
<span class="code">{{.Cert.NotAfter.UTC.Format "2006-01-02 15:04 UTC"}}</span>
{{end}}
<p class="{{if eq .Cert.Status "ok"}}muted{{else}}error{{end}}">{{.Cert.Detail}}</p>
</div>
<div class="card">
<h2>Milter sockets <span class="st st-{{.SocketStatus}}">{{.SocketStatus}}</span></h2>
<table>
<thead><tr><th>Milter</th><th>Socket</th><th>State</th></tr></thead>
<tbody>
{{range .Sockets}}
<tr>
<td>{{.Name}}</td>
<td class="muted">{{.Path}}</td>
<td><span class="st st-{{.Status}}">{{.Status}}</span> {{.Detail}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
</div>
{{end}}