The status page answered "are the components running" but said nothing about the machine underneath them, so a server slowed to a crawl by a busy processor or one about to have Postfix OOM-killed looked entirely healthy until the queue backed up. internal/health/machine.go reads the kernel's counters in /proc: the aggregate processor times and core count from /proc/stat, the load average from /proc/loadavg, memory and swap from /proc/meminfo, and per-interface byte counters from /proc/net/dev. CPU busy time and network throughput are rates, so a MachineSampler holds the previous reading and each call reports the difference — one shared sampler on the Server, since a per-request one would never have anything to subtract. A window longer than a minute only re-baselines: a page opened after the panel sat idle would otherwise average that whole stretch and present it as the current load. Memory is derived from MemAvailable rather than MemFree, because Linux spends every spare page on cache and MemFree would report a permanent emergency. A fully busy processor (>=90%) warns and an exhausted machine (>=97%) errors, both counting towards the page's headline verdict, since either delays or kills the mail path. Throughput has no comparable threshold — what counts as a lot depends on the link — so it is reported and never graded. Loopback is excluded: that traffic is the container talking to itself. Like every other check here, an unreadable counter degrades to "unknown" with an explanation instead of failing the page, so the panel still runs outside Linux for development. The usage bars are <meter> elements. The panel's CSP has no inline-style exemption, so a bar's length has to travel on an attribute; the element also grades its own colour from low/high/optimum, and the percentage is printed beside it for anything that does not render meters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
11 KiB
SelfPost — architecture (as-built)
Source of truth: the code tree, not historical specs. Synchronise this file when env keys, routes, or mail-path behaviour change. Verification method: documentation-plan.md §2.
User install/operations: README.md. Product boundaries: product.md.
Image and processes
Single Debian slim image. entrypoint.sh (root) fixes /data ownership and
milter socket directories, requires SELFPOST_HOSTNAME (FQDN with at least
one dot; no scheme, port, or spaces — invalid or empty value → exit 1 before
Postfix config or supervisord), then execs supervisord as PID 1.
The hostname is not a tunable default: it must match PTR/rDNS, TLS CN/SAN, and
SASL realm together. Soft fallbacks (localhost in the panel vs container ID in
Postfix) split realms and break SMTP AUTH with a silent 535 in clients while
the panel looks healthy. Shipped docker-compose.yml already requires the
variable; the entrypoint gate catches docker run, custom compose, and k8s.
Managed programs (build/supervisord.conf):
| Program | User | Priority | Role |
|---|---|---|---|
opendkim |
opendkim | 100 | DKIM signing milter |
panel |
panel | 200 | HTTP UI + journal-milter + log-tailer goroutine |
postfix |
root (wrapper) | 300 | MTA — started only after both milter sockets exist |
postfix-reload |
root | — | On-demand postfix reload (autostart off) |
cert-reload |
root | 400 | Daily postfix reload for renewed TLS certs |
logrotate |
root | 400 | Periodic mail.log rotation |
Start order: OpenDKIM → panel (opens journal-milter socket) → Postfix wrapper
polls unix sockets (timeout MILTER_WAIT_TIMEOUT, default 30s) then
postfix start-fg.
crashexit event listener exits the container on any managed process FATAL so
Docker restart: unless-stopped recreates a broken instance.
Liveness: GET /healthz (unauthenticated) returns 200 when opendkim,
panel, and postfix are RUNNING; Docker HEALTHCHECK uses the same probe.
Mail path
Client ──TLS+SASL──► Postfix (465 smtps, optional 587 submission)
│
├─► OpenDKIM milter (sign, tempfail on failure)
├─► journal-milter (send log + L2 rate limits, fail-open)
└─► outbound MX delivery (port 25 client)
Postfix (build/postfix-config.sh)
- 465/smtps — implicit TLS, SASL required; primary listener.
- 587/submission — only when
SUBMISSION_ENABLE=true; STARTTLS withsmtpd_tls_security_level=encrypt. - No open relay —
permit_sasl_authenticated,reject_unauth_destination;smtpd_sender_login_maps+reject_sender_login_mismatch. - Level-1 rate limit —
smtpd_client_message_rate_limit/anvil_rate_time_unitfromRATE_LIMIT_*env vars; independent of milter. - Chroot disabled for all services (DNS/TLS inside container).
- TLS certs — read-only mount at
TLS_CERT_FILE/TLS_KEY_FILE; daily reload viacert-reload.
OpenDKIM
Per-domain keys under /data/opendkim/keys; KeyTable / SigningTable maintained
by the panel. Socket /run/opendkim/opendkim.sock.
Panel binary (cmd/panel)
One process, three roles:
- HTTP server —
:8080(PANEL_HTTP_ADDR); HTTPS terminated by reverse proxy only. - journal-milter — unix socket
JOURNAL_MILTER_SOCKET; records From/To/ Subject/SASL user at DATA; enforces level-2 rate limits; fail-open (default_action=accept) so milter failure does not stop mail. The level-2 count is the stored send-log rows plus the messages this process has admitted but not yet written (internal/milter/inflight.go), so concurrent sessions cannot each spend the same last slot; a reservation is released at end-of-message, on ABORT, or after a 10-minute TTL. - log-tailer — follows
MAIL_LOG, updates send-log delivery status by queue-id. Send-logqueued → senttransitions depend on this goroutine alone (UpdateStatusis only called from internal/logtail).
Milter chain in Postfix: OpenDKIM (tempfail) then journal (accept on failure).
Log tailer and mail.log rotation
mail.log lives under /var/log (not in /data). Rotation uses rename +
postfix reload (build/logrotate-mail.conf), not
copytruncate — the latter can drop status=sent lines and leave send-log rows
stuck at queued. After rename, logrotate runs create 0644 root root (Postfix
recreates the file lazily on first write as mode 0600, which the unprivileged
panel user cannot read). follow() drains the old inode once more before
switching descriptors; the panel treats a missing log file as an empty tail, not
an error.
Read offset is persisted (logtail_state table, migration 0003): the
tailer stores its position plus a fingerprint of the log's first 512 bytes, and
on start resumes from it, parsing the tail written while the panel was down. If
the fingerprint no longer matches (rotated or recreated in the meantime) it reads
the current file from the start; re-parsing lines is harmless because
UpdateStatus writes the same status onto the same row. Only a first-ever start,
with nothing stored, begins at end-of-file, so installing the panel does not
replay a pre-existing log.
Remaining gap:
- Container recreate —
/var/logis ephemeral; the log is lost with the container, so the delivery lines for rows stillqueuedare gone with it and those rows stayqueuedforever.
Possible follow-ups if this becomes painful: mount the mail log under /data, or
reconcile stuck rows via postqueue.
Panel HTTP surface
Route table: internal/web/web.go. Authenticated unless noted.
| Route | Purpose |
|---|---|
/healthz |
Liveness (no auth) |
/setup/* |
One-time admin bootstrap |
/login, /logout |
Session auth |
/status |
Process, cert, socket, PTR checks; machine CPU/memory/network |
/domains, /domains/* |
Domain and application CRUD, DKIM, L2 limits |
/deliveries |
Send log with filters |
/mail-queue |
Postfix queue view |
/system-log |
mail.log tail |
/reload |
Reload OpenDKIM + Postfix maps |
/backup |
Full backup download, domain import |
/account |
Admin username/password |
HTMX polling refreshes monitoring fragments; polling does not extend session
idle timeout (only non-HX-Request GET and mutating requests count as activity).
Sessions
Stored in SQLite (sessions table, migration 0002): cookie holds a random
token; the database stores SHA-256 of the token, not the token itself — a
stolen DB or backup archive does not alone grant login, but a browser that still
holds the cookie works after process restart, redeploy, or full backup restore.
- Idle timeout — sliding window,
PANEL_SESSION_IDLE_DAYS(default 7); no absolute cap (regular use keeps the session alive indefinitely). - Renewal — DB
last_seenand cookieMax-Ageupdate at most once per hour (renewThresholdin internal/web/session.go). - Password change — all other sessions are deleted; the current session stays active (internal/store/sessions.go, handlers_account.go).
Restoring an older backup also restores session rows: a session invalidated after that backup was taken can become valid again if the browser still has the cookie and idle timeout has not expired.
Code layers
Handlers never touch SQLite or the filesystem directly; every write that has to
land in more than one place (SQLite row, sasldb2 entry, Postfix map, OpenDKIM
table) goes through a service, which is also where the rollback of a partial
failure lives. The adapters below the services are the only code that knows
about Postfix, OpenDKIM, DNS or the log file, which is what makes them
substitutable in tests — milter.Store, app.SenderMaps and
logtail.StatusStore are the seams the unit tests replace with fakes.
flowchart TB
subgraph cmd ["cmd — composition root"]
panel["panel: HTTP + journal-milter + log-tailer"]
backupcli["selfpost-backup CLI"]
end
subgraph web ["internal/web — HTTP surface"]
handlers["handlers_*.go, templates, session/security"]
end
subgraph services ["Services — multi-store operations + rollback"]
domainSvc["internal/domain"]
appSvc["internal/app"]
end
subgraph persistence ["Persistence"]
store["internal/store — SQLite, embedded migrations"]
end
subgraph adapters ["Adapters — the only infrastructure-aware code"]
postfix["internal/postfix"]
milterPkg["internal/milter"]
logtail["internal/logtail"]
dnscheck["internal/dnscheck"]
backupPkg["internal/backup"]
health["internal/health"]
secretfile["internal/secretfile"]
end
panel --> web
panel --> milterPkg
panel --> logtail
backupcli --> backupPkg
backupcli --> secretfile
web --> domainSvc
web --> appSvc
web --> backupPkg
web --> dnscheck
web --> health
web --> secretfile
domainSvc --> store
appSvc --> store
milterPkg --> store
logtail --> store
domainSvc --> postfix
appSvc --> postfix
The three roles inside the panel process (HTTP server, journal-milter,
log-tailer goroutine) share one binary and one SQLite handle on purpose — see
Panel binary for why, and Persistence below for the
single-connection trade-off that follows from it.
Persistence (/data bind mount)
| Path | Contents |
|---|---|
selfpost.db |
SQLite: domains, apps, admin, sessions, send log, L2 limits, log-tailer offset |
setup-token |
First-run setup token file |
opendkim/ |
DKIM keys + tables |
sasl/sasldb2 |
Application SASL credentials |
postfix/sender_login_maps |
Login → From binding |
manifest.json |
Backup version stamp (consumed on restore) |
Not in /data: TLS certificates (reverse-proxy mount), Postfix queue
(transit mail not migrated by design).
Rotation: send-log retention SEND_LOG_RETENTION_DAYS (default 90);
mail.log via logrotate (14 rotated files, check every 6h, rename +
postfix reload in postrotate — see § Log tailer above).
Backup: panel button or selfpost-backup CLI — SQLite snapshot + tar of
/data tree; version check on restore. Stopped-container tar of ./data is
safe (see README).
Optional encryption of the two secret-bearing downloads
(internal/secretfile): password →
scrypt → AES-256-GCM over 64 KiB chunks, each authenticated with the header,
its counter and an end-of-stream flag (so truncation and reordering fail to
open). Full backup .tar.gz → .spbk, domain export .json → .spde; the
plain forms remain the default. Domain import detects the envelope by magic
bytes; an encrypted full backup is converted back with selfpost-backup -decrypt before restore.
Security (summary)
Mandatory checklist: security.md. Accepted trade-offs (CSRF origin check, no CSRF tokens) are documented there separately.
Configuration
Public and internal env vars: README § Environment variables. Regression test: cmd/panel/envdoc_test.go.