feat(infra): production host bootstrap and bare-metal Stalwart scaffolding

Host provisioning for the single-server production target: SSH + firewall
hardening (nftables allowlist), k3s node registration, bare-metal Stalwart
install with systemd units and TLS cert-sync from the cluster secret, and
Restic encrypted backup/restore (primary + DR) with timer units. Host-specific
secrets live in config.env (gitignored); config.env.example is the template.
Also gitignores MemPalace per-project files.
This commit is contained in:
Ronni Baslund
2026-06-07 00:19:48 +02:00
parent 5ed3d2bc5f
commit 3831c85285
18 changed files with 1432 additions and 0 deletions
@@ -0,0 +1,102 @@
# Stalwart Mail Server — Dezky PRODUCTION (bare-metal host, outside k3s)
#
# Topology (see host/README.md):
# - Mail protocol ports bind directly on the host's public IP.
# - Web/JMAP is served plaintext on 127-reachable :8080 and fronted by
# Traefik (k3s) for mail.dezky.eu:443. Stalwart does NOT bind 80/443 —
# those belong to Traefik.
# - TLS for the mail-protocol ports uses a cert ISSUED BY cert-manager
# (mail.dezky.eu) and copied here by stalwart/cert-sync.sh. Stalwart runs
# no ACME of its own (80/443 are Traefik's).
# - Storage is RocksDB on local disk — intentionally independent of the
# in-cluster Postgres so mail keeps flowing regardless of cluster state.
#
# Reference: https://stalw.art/docs
[server]
hostname = "mail.dezky.eu" # MUST match the IP's PTR/rDNS record
# ── Listeners ──────────────────────────────────────────────────────────────
# Mail protocols on the public IP; management/JMAP on internal 8080 only
# (firewall blocks 8080 from the world, allows the k3s pod CIDR + Traefik).
[server.listener]
"smtp" = { bind = "[::]:25", protocol = "smtp" }
"submission" = { bind = "[::]:587", protocol = "smtp", tls.implicit = false }
"submissions" = { bind = "[::]:465", protocol = "smtp", tls.implicit = true }
"imap" = { bind = "[::]:143", protocol = "imap", tls.implicit = false }
"imaps" = { bind = "[::]:993", protocol = "imap", tls.implicit = true }
"sieve" = { bind = "[::]:4190", protocol = "managesieve" }
# Internal HTTP: JMAP + WebAdmin + management API. Traefik terminates TLS for
# the public hostname and proxies here; platform-api (pod) calls it directly.
"http" = { bind = "0.0.0.0:8080", protocol = "http" }
# ── Storage — RocksDB on local disk (host-isolated from the cluster) ────────
[store."rocksdb"]
type = "rocksdb"
path = "/opt/stalwart/data"
compression = "lz4"
[storage]
data = "rocksdb"
fts = "rocksdb"
blob = "rocksdb"
lookup = "rocksdb"
directory = "internal"
[directory."internal"]
type = "internal"
store = "rocksdb"
# ── TLS — cert issued by cert-manager, synced here by cert-sync.sh ──────────
# Until the first sync runs, install.sh drops a self-signed bootstrap cert so
# the TLS listeners can start. cert-sync replaces it with the real LE cert.
[certificate."default"]
cert = "%{file:/opt/stalwart/etc/tls/cert.pem}%"
private-key = "%{file:/opt/stalwart/etc/tls/key.pem}%"
default = true
# ── Authentication ─────────────────────────────────────────────────────────
# Fallback admin is what platform-api uses for Basic auth on the JMAP
# management API (STALWART_ADMIN_USER/PASSWORD on the platform-api side).
[authentication]
fallback-admin.user = "admin"
fallback-admin.secret = "$env{STALWART_ADMIN_PASSWORD}"
# ── Resolver ───────────────────────────────────────────────────────────────
# DNSSEC-aware system resolver. Mail deliverability depends on clean DNS.
[resolver]
type = "system"
preserve-intermediates = true
concurrency = 4
# ── Spam filtering — built-in filter ON in production ──────────────────────
[spam-filter]
enable = true
# ── Logging — journald captures stdout ─────────────────────────────────────
[tracer."stdout"]
type = "stdout"
level = "info"
ansi = false
enable = true
# ── Audit webhook → platform-api (via the public api ingress) ──────────────
# Stalwart on the host reaches platform-api through Traefik on the public
# hostname; HMAC-signed so a public endpoint is safe.
[webhook."audit-ingest"]
url = "https://api.dezky.eu/ingest/stalwart/webhook"
signature-key = "$env{STALWART_WEBHOOK_SECRET}"
events = [
"auth.success",
"auth.failure",
"auth.banned",
"account.created",
"account.deleted",
"account.password-changed",
"message.rejected",
"policy.rejection",
"dkim.failure",
"dmarc.failure",
"spam.detected",
]
throttle = "1s"