Files
selfpost/internal/health/health.go
T
mix ac5b37d1e2 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>
2026-08-01 22:04:37 +03:00

54 lines
1.8 KiB
Go

// 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
}