6d2d49257d
Both secret-bearing downloads can now be sealed with a password. Unticked, the forms produce exactly the files they did before. - internal/secretfile: envelope format — magic/type/scrypt params/salt/nonce prefix header, then 64 KiB AES-256-GCM chunks each authenticated with the header, its counter and an end-of-stream flag, so truncation, reordering and tampering fail to open instead of restoring a plausible prefix. Streams both ways, so a full backup never sits in memory. - Panel: "Encrypt with a password" checkbox on the full-backup and domain-export forms (shared partial, toggled from panel.js — no inline script); domain import detects an encrypted export by magic bytes, not by extension, and asks for the password. - selfpost-backup: writes .spbk when given a password and converts one back with -decrypt, which a restore needs. The password comes from SELFPOST_BACKUP_PASSWORD or -password-file, never argv. - Docs: README, security.md (+ accepted risk: encryption stays opt-in), architecture.md, progress.md, CHANGELOG. Verified locally: panel-encrypted archive decrypts through the CLI and unpacks; wrong password and password mismatch are refused; UI checked in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
8.6 KiB
Markdown
201 lines
8.6 KiB
Markdown
# 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](documentation-plan.md) §2.
|
|
|
|
User install/operations: [README.md](../README.md). Product boundaries:
|
|
[product.md](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](../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](../build/postfix-config.sh))
|
|
|
|
- **465/smtps** — implicit TLS, SASL required; primary listener.
|
|
- **587/submission** — only when `SUBMISSION_ENABLE=true`; STARTTLS with
|
|
`smtpd_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_unit` from `RATE_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 via `cert-reload`.
|
|
|
|
### OpenDKIM
|
|
|
|
Per-domain keys under `/data/opendkim/keys`; `KeyTable` / `SigningTable` maintained
|
|
by the panel. Socket `/run/opendkim/opendkim.sock`.
|
|
|
|
### Panel binary ([cmd/panel](../cmd/panel))
|
|
|
|
One process, three roles:
|
|
|
|
1. **HTTP server** — `:8080` (`PANEL_HTTP_ADDR`); HTTPS terminated by reverse
|
|
proxy only.
|
|
2. **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.
|
|
3. **log-tailer** — follows `MAIL_LOG`, updates send-log delivery status by
|
|
queue-id. Send-log `queued → sent` transitions depend on this goroutine alone
|
|
(`UpdateStatus` is only called from [internal/logtail](../internal/logtail/logtail.go)).
|
|
|
|
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](../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.
|
|
|
|
**Known gaps (same class of loss, not fixed by rename rotation):**
|
|
|
|
- **Panel restart** — `follow()` starts at end-of-file; lines written while the
|
|
panel was down are never parsed; in-flight send-log rows may stay `queued`.
|
|
- **Container recreate** — `/var/log` is ephemeral; the log is lost with the
|
|
container.
|
|
|
|
Possible follow-ups if these become painful: persist read offset across restarts,
|
|
mount mail log under `/data`, or reconcile stuck rows via `postqueue`.
|
|
|
|
---
|
|
|
|
## Panel HTTP surface
|
|
|
|
Route table: [internal/web/web.go](../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 |
|
|
| `/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_seen` and cookie `Max-Age` update at most once per hour
|
|
(`renewThreshold` in [internal/web/session.go](../internal/web/session.go)).
|
|
- **Password change** — all other sessions are deleted; the current session stays
|
|
active ([internal/store/sessions.go](../internal/store/sessions.go),
|
|
[handlers_account.go](../internal/web/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.
|
|
|
|
---
|
|
|
|
## Persistence (`/data` bind mount)
|
|
|
|
| Path | Contents |
|
|
|---|---|
|
|
| `selfpost.db` | SQLite: domains, apps, admin, sessions, send log, L2 limits |
|
|
| `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](../internal/secretfile/secretfile.go)): 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](security.md). Accepted trade-offs (CSRF
|
|
origin check, no CSRF tokens) are documented there separately.
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
Public and internal env vars: [README § Environment variables](../README.md#environment-variables).
|
|
Regression test: [cmd/panel/envdoc_test.go](../cmd/panel/envdoc_test.go).
|