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,83 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// certWarnDays is how close to expiry the certificate has to be before the
|
||||
// status page complains. Let's Encrypt renews at 30 days left, so two weeks is
|
||||
// comfortably past the point where automatic renewal should have happened.
|
||||
const certWarnDays = 14
|
||||
|
||||
// Certificate is the state of the TLS certificate Postfix serves on 465/587
|
||||
// (spec 8: TLS_CERT_FILE). The panel only reads it — the file is supplied by
|
||||
// the reverse proxy through a read-only mount.
|
||||
type Certificate struct {
|
||||
Path string
|
||||
Subject string
|
||||
NotAfter time.Time
|
||||
DaysLeft int
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// CheckCertificate parses the leaf certificate at path and reports how much
|
||||
// validity is left. A missing or unparsable file is an error status rather than
|
||||
// an error return: the status page reports it in place, like every other check.
|
||||
func CheckCertificate(path string) Certificate {
|
||||
c := Certificate{Path: path}
|
||||
if path == "" {
|
||||
c.Status = StatusUnknown
|
||||
c.Detail = "No certificate path is configured (TLS_CERT_FILE)."
|
||||
return c
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
c.Status = StatusError
|
||||
c.Detail = fmt.Sprintf("Could not read the certificate at %s.", path)
|
||||
return c
|
||||
}
|
||||
leaf, err := parseLeaf(data)
|
||||
if err != nil {
|
||||
c.Status = StatusError
|
||||
c.Detail = fmt.Sprintf("%s does not contain a readable certificate.", path)
|
||||
return c
|
||||
}
|
||||
|
||||
c.Subject = leaf.Subject.CommonName
|
||||
c.NotAfter = leaf.NotAfter
|
||||
c.DaysLeft = int(time.Until(leaf.NotAfter).Hours() / 24)
|
||||
switch {
|
||||
case !time.Now().Before(leaf.NotAfter):
|
||||
c.Status = StatusError
|
||||
c.Detail = "The certificate has expired. Senders will refuse the TLS connection."
|
||||
case c.DaysLeft < certWarnDays:
|
||||
c.Status = StatusWarn
|
||||
c.Detail = fmt.Sprintf("Expires in %d day(s). Check that renewal on the host still works.", c.DaysLeft)
|
||||
default:
|
||||
c.Status = StatusOK
|
||||
c.Detail = fmt.Sprintf("Valid for another %d day(s).", c.DaysLeft)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// parseLeaf returns the first certificate in a PEM chain — the leaf, which is
|
||||
// the one whose validity clients see.
|
||||
func parseLeaf(data []byte) (*x509.Certificate, error) {
|
||||
for rest := data; len(rest) > 0; {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
return x509.ParseCertificate(block.Bytes)
|
||||
}
|
||||
return nil, fmt.Errorf("no CERTIFICATE block found")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Package health reports the running container's own operating state for the
|
||||
// panel's status screen: the supervised processes, the TLS certificate Postfix
|
||||
// serves, and the milter sockets delivery depends on.
|
||||
//
|
||||
// Every check is read-only and reports a problem as a Status value rather than
|
||||
// an error return, so one broken component degrades a single line of the status
|
||||
// page instead of blanking the whole thing. The package also owns the Status
|
||||
// vocabulary shared with internal/dnscheck, so the panel renders every check —
|
||||
// local or DNS — through one set of badges.
|
||||
package health
|
||||
|
||||
// Status is the outcome of a single check, in the order the status page treats
|
||||
// them: unknown < ok < warn < error, worst wins for a group.
|
||||
type Status string
|
||||
|
||||
const (
|
||||
// StatusUnknown means the check could not be performed at all (a missing
|
||||
// setting, an unreachable resolver) — not evidence of a problem.
|
||||
StatusUnknown Status = "unknown"
|
||||
// StatusOK means the checked component is in its expected state.
|
||||
StatusOK Status = "ok"
|
||||
// StatusWarn means something is off but mail still flows.
|
||||
StatusWarn Status = "warn"
|
||||
// StatusError means mail delivery is (or soon will be) affected.
|
||||
StatusError Status = "error"
|
||||
)
|
||||
|
||||
// severity orders statuses so a group can report its worst member.
|
||||
func (s Status) severity() int {
|
||||
switch s {
|
||||
case StatusError:
|
||||
return 3
|
||||
case StatusWarn:
|
||||
return 2
|
||||
case StatusOK:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Worst returns the most severe of the given statuses, or StatusUnknown when
|
||||
// there are none. It is how the status page rolls a list of checks up into one
|
||||
// headline.
|
||||
func Worst(statuses ...Status) Status {
|
||||
worst := StatusUnknown
|
||||
for _, s := range statuses {
|
||||
if s.severity() > worst.severity() {
|
||||
worst = s
|
||||
}
|
||||
}
|
||||
return worst
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supervisorConf is the supervisord configuration the panel's other control
|
||||
// calls already address (see internal/postfix and internal/domain).
|
||||
const supervisorConf = "/etc/supervisor/supervisord.conf"
|
||||
|
||||
// oneShotPrograms are supervisord entries that are meant to sit idle: they are
|
||||
// started on demand and exit immediately, so STOPPED/EXITED is their healthy
|
||||
// state rather than a fault (see build/supervisord.conf).
|
||||
var oneShotPrograms = map[string]bool{
|
||||
"postfix-reload": true,
|
||||
}
|
||||
|
||||
// Process is one supervised program as supervisord reports it.
|
||||
type Process struct {
|
||||
Name string
|
||||
State string // supervisord's own state word, e.g. RUNNING
|
||||
Detail string // the rest of the line: pid/uptime, or exit information
|
||||
Status Status
|
||||
}
|
||||
|
||||
// Processes returns the state of every supervised program (spec 4's three
|
||||
// processes plus the reload/cert/logrotate helpers).
|
||||
//
|
||||
// The command takes fixed arguments and no user input, so it never goes through
|
||||
// a shell (spec 7.6.3). `supervisorctl status` deliberately exits non-zero when
|
||||
// some program is not running, so the output is parsed first and the exit status
|
||||
// only matters when nothing could be parsed from it.
|
||||
func Processes() ([]Process, error) {
|
||||
cmd := exec.Command("supervisorctl", "-c", supervisorConf, "status")
|
||||
out, err := cmd.CombinedOutput()
|
||||
procs := parseProcesses(string(out))
|
||||
if len(procs) == 0 {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("supervisorctl status: %w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil, fmt.Errorf("supervisorctl status: no programs reported")
|
||||
}
|
||||
return procs, nil
|
||||
}
|
||||
|
||||
// supervisorStates are the state words supervisord prints. Lines whose second
|
||||
// field is not one of them are not status lines (banners, error text) and are
|
||||
// skipped, so unexpected output cannot masquerade as a process.
|
||||
var supervisorStates = map[string]bool{
|
||||
"STOPPED": true,
|
||||
"STARTING": true,
|
||||
"RUNNING": true,
|
||||
"BACKOFF": true,
|
||||
"STOPPING": true,
|
||||
"EXITED": true,
|
||||
"FATAL": true,
|
||||
"UNKNOWN": true,
|
||||
}
|
||||
|
||||
// parseProcesses turns supervisorctl's tabular output into Process values. Each
|
||||
// status line is "<name> <STATE> <detail...>", column-aligned with spaces.
|
||||
func parseProcesses(out string) []Process {
|
||||
var procs []Process
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || !supervisorStates[fields[1]] {
|
||||
continue
|
||||
}
|
||||
name, state := fields[0], fields[1]
|
||||
procs = append(procs, Process{
|
||||
Name: name,
|
||||
State: state,
|
||||
Detail: strings.Join(fields[2:], " "),
|
||||
Status: processStatus(name, state),
|
||||
})
|
||||
}
|
||||
return procs
|
||||
}
|
||||
|
||||
// processStatus grades a supervisord state. A one-shot program that is not
|
||||
// running is healthy; anything else that is not RUNNING means a component of
|
||||
// the mail path is down or flapping.
|
||||
func processStatus(name, state string) Status {
|
||||
switch state {
|
||||
case "RUNNING":
|
||||
return StatusOK
|
||||
case "STARTING", "STOPPING":
|
||||
return StatusWarn
|
||||
case "STOPPED", "EXITED":
|
||||
if oneShotPrograms[name] {
|
||||
return StatusOK
|
||||
}
|
||||
return StatusError
|
||||
default: // BACKOFF, FATAL, UNKNOWN
|
||||
return StatusError
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package health
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Socket is the state of one milter socket Postfix connects to.
|
||||
type Socket struct {
|
||||
Name string
|
||||
Path string
|
||||
Present bool
|
||||
Status Status
|
||||
Detail string
|
||||
}
|
||||
|
||||
// CheckSocket stats a milter socket. required distinguishes the two milters:
|
||||
// OpenDKIM runs with default_action=tempfail, so a missing socket stops mail
|
||||
// leaving the server, while the journal-milter fails open — mail still goes out,
|
||||
// only the send log stops being written.
|
||||
func CheckSocket(name, path string, required bool) Socket {
|
||||
s := Socket{Name: name, Path: path}
|
||||
if path == "" {
|
||||
s.Status = StatusUnknown
|
||||
s.Detail = "No socket path is configured."
|
||||
return s
|
||||
}
|
||||
fi, err := os.Stat(path)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.Status = missingStatus(required)
|
||||
s.Detail = missingDetail(name, required)
|
||||
case fi.Mode()&os.ModeSocket == 0:
|
||||
s.Status = missingStatus(required)
|
||||
s.Detail = fmt.Sprintf("%s exists but is not a socket.", path)
|
||||
default:
|
||||
s.Present = true
|
||||
s.Status = StatusOK
|
||||
s.Detail = "Listening."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func missingStatus(required bool) Status {
|
||||
if required {
|
||||
return StatusError
|
||||
}
|
||||
return StatusWarn
|
||||
}
|
||||
|
||||
func missingDetail(name string, required bool) string {
|
||||
if required {
|
||||
return fmt.Sprintf("The %s socket is missing. Postfix rejects mail with a temporary error until it is back.", name)
|
||||
}
|
||||
return fmt.Sprintf("The %s socket is missing. Mail still goes out, but the send log is not being written.", name)
|
||||
}
|
||||
Reference in New Issue
Block a user