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:
@@ -0,0 +1,165 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorstPicksMostSevere(t *testing.T) {
|
||||
cases := []struct {
|
||||
in []Status
|
||||
want Status
|
||||
}{
|
||||
{nil, StatusUnknown},
|
||||
{[]Status{StatusOK, StatusOK}, StatusOK},
|
||||
{[]Status{StatusOK, StatusWarn}, StatusWarn},
|
||||
{[]Status{StatusWarn, StatusError, StatusOK}, StatusError},
|
||||
{[]Status{StatusUnknown, StatusOK}, StatusOK},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := Worst(c.in...); got != c.want {
|
||||
t.Errorf("Worst(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcesses(t *testing.T) {
|
||||
// Real supervisorctl output: column-aligned, one line per program.
|
||||
out := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix FATAL Exited too quickly (process log may have details)
|
||||
postfix-reload STOPPED Not started
|
||||
logrotate RUNNING pid 25, uptime 0:04:08
|
||||
`
|
||||
procs := parseProcesses(out)
|
||||
if len(procs) != 5 {
|
||||
t.Fatalf("parsed %d processes, want 5: %+v", len(procs), procs)
|
||||
}
|
||||
want := map[string]Status{
|
||||
"opendkim": StatusOK,
|
||||
"panel": StatusOK,
|
||||
"postfix": StatusError,
|
||||
"postfix-reload": StatusOK, // one-shot: idle is its healthy state
|
||||
"logrotate": StatusOK,
|
||||
}
|
||||
for _, p := range procs {
|
||||
if want[p.Name] != p.Status {
|
||||
t.Errorf("%s (%s): status %q, want %q", p.Name, p.State, p.Status, want[p.Name])
|
||||
}
|
||||
}
|
||||
if procs[0].Detail != "pid 21, uptime 0:04:10" {
|
||||
t.Errorf("detail = %q", procs[0].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseProcessesSkipsNonStatusLines(t *testing.T) {
|
||||
out := `error: <class 'socket.error'>, [Errno 2] No such file or directory
|
||||
unix:///run/supervisor.sock refused connection
|
||||
`
|
||||
if procs := parseProcesses(out); len(procs) != 0 {
|
||||
t.Errorf("error output parsed as processes: %+v", procs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckCertificate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
valid := filepath.Join(dir, "valid.pem")
|
||||
writeCert(t, valid, "mail.example.com", 90*24*time.Hour)
|
||||
if got := CheckCertificate(valid); got.Status != StatusOK {
|
||||
t.Errorf("valid certificate: status %q (%s)", got.Status, got.Detail)
|
||||
} else if got.Subject != "mail.example.com" {
|
||||
t.Errorf("subject = %q", got.Subject)
|
||||
}
|
||||
|
||||
soon := filepath.Join(dir, "soon.pem")
|
||||
writeCert(t, soon, "mail.example.com", 3*24*time.Hour)
|
||||
if got := CheckCertificate(soon); got.Status != StatusWarn {
|
||||
t.Errorf("nearly expired certificate: status %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
|
||||
expired := filepath.Join(dir, "expired.pem")
|
||||
writeCert(t, expired, "mail.example.com", -24*time.Hour)
|
||||
if got := CheckCertificate(expired); got.Status != StatusError {
|
||||
t.Errorf("expired certificate: status %q (%s)", got.Status, got.Detail)
|
||||
}
|
||||
|
||||
if got := CheckCertificate(filepath.Join(dir, "absent.pem")); got.Status != StatusError {
|
||||
t.Errorf("missing certificate: status %q", got.Status)
|
||||
}
|
||||
|
||||
junk := filepath.Join(dir, "junk.pem")
|
||||
if err := os.WriteFile(junk, []byte("not a certificate\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CheckCertificate(junk); got.Status != StatusError {
|
||||
t.Errorf("unparsable certificate: status %q", got.Status)
|
||||
}
|
||||
|
||||
if got := CheckCertificate(""); got.Status != StatusUnknown {
|
||||
t.Errorf("unconfigured certificate: status %q", got.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSocket(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "opendkim.sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
|
||||
t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
|
||||
missing := filepath.Join(dir, "journal.sock")
|
||||
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
|
||||
t.Errorf("missing optional socket: status %q", got.Status)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
|
||||
t.Errorf("missing required socket: status %q", got.Status)
|
||||
}
|
||||
|
||||
plain := filepath.Join(dir, "plain")
|
||||
if err := os.WriteFile(plain, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
|
||||
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
}
|
||||
|
||||
// writeCert writes a self-signed certificate expiring after validFor (negative
|
||||
// for an already-expired one).
|
||||
func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(validFor),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user