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:
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Sync the mail.dezky.eu TLS cert from the cluster (issued by cert-manager) to
|
||||
# Stalwart on the host. The host IS the k3s node, so we read the secret via the
|
||||
# local kubeconfig — no external machinery. Reloads Stalwart only when the cert
|
||||
# actually changed (cert-manager renews ~30 days before expiry).
|
||||
#
|
||||
# Run by stalwart-cert-sync.timer (every 12h + on boot). Safe to run by hand.
|
||||
#
|
||||
# Forward dependency: needs the fleet layer to have created the TLS secret
|
||||
# (default: namespace 'mail', secret 'mail-tls'). Until then this is a no-op and
|
||||
# Stalwart keeps using the self-signed bootstrap cert from install.sh.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
TLS_NAMESPACE="${TLS_NAMESPACE:-mail}"
|
||||
TLS_SECRET="${TLS_SECRET:-mail-tls}"
|
||||
TLS_DIR="/opt/stalwart/etc/tls"
|
||||
KUBECONFIG_PATH="${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}"
|
||||
|
||||
# kubectl: prefer standalone, fall back to the k3s-bundled one
|
||||
if command -v kubectl >/dev/null 2>&1; then
|
||||
KUBECTL=(kubectl)
|
||||
elif command -v k3s >/dev/null 2>&1; then
|
||||
KUBECTL=(k3s kubectl)
|
||||
else
|
||||
error "Neither kubectl nor k3s found — is the node provisioned yet?"
|
||||
exit 1
|
||||
fi
|
||||
export KUBECONFIG="$KUBECONFIG_PATH"
|
||||
|
||||
# Pull the secret (no-op if it doesn't exist yet)
|
||||
if ! "${KUBECTL[@]}" -n "$TLS_NAMESPACE" get secret "$TLS_SECRET" >/dev/null 2>&1; then
|
||||
warn "Secret ${TLS_NAMESPACE}/${TLS_SECRET} not present yet — cert-manager hasn't issued it. Skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
TMP_CRT="$(mktemp)"; TMP_KEY="$(mktemp)"
|
||||
trap 'rm -f "$TMP_CRT" "$TMP_KEY"' EXIT
|
||||
|
||||
"${KUBECTL[@]}" -n "$TLS_NAMESPACE" get secret "$TLS_SECRET" \
|
||||
-o jsonpath='{.data.tls\.crt}' | base64 -d > "$TMP_CRT"
|
||||
"${KUBECTL[@]}" -n "$TLS_NAMESPACE" get secret "$TLS_SECRET" \
|
||||
-o jsonpath='{.data.tls\.key}' | base64 -d > "$TMP_KEY"
|
||||
|
||||
if [[ ! -s "$TMP_CRT" || ! -s "$TMP_KEY" ]]; then
|
||||
error "Fetched cert or key is empty — leaving current cert in place."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Only reload if something changed (compare hashes)
|
||||
changed=0
|
||||
mkdir -p "$TLS_DIR"
|
||||
if ! cmp -s "$TMP_CRT" "$TLS_DIR/cert.pem" 2>/dev/null; then changed=1; fi
|
||||
if ! cmp -s "$TMP_KEY" "$TLS_DIR/key.pem" 2>/dev/null; then changed=1; fi
|
||||
|
||||
if [[ $changed -eq 0 ]]; then
|
||||
info "Cert unchanged — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
install -o stalwart -g stalwart -m 0644 "$TMP_CRT" "$TLS_DIR/cert.pem"
|
||||
install -o stalwart -g stalwart -m 0640 "$TMP_KEY" "$TLS_DIR/key.pem"
|
||||
ok "Updated mail TLS cert from ${TLS_NAMESPACE}/${TLS_SECRET}."
|
||||
|
||||
# SIGHUP Stalwart to reload certs without dropping connections
|
||||
if systemctl is-active --quiet stalwart-mail; then
|
||||
systemctl reload stalwart-mail && ok "Reloaded stalwart-mail (SIGHUP)."
|
||||
else
|
||||
warn "stalwart-mail not active — cert staged, will be used on next start."
|
||||
fi
|
||||
@@ -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"
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install Stalwart mail server as a hardened host systemd service on the AX41.
|
||||
# Run AFTER bootstrap.sh (and ideally after k3s registration, so cert-sync can
|
||||
# immediately pull the real cert). Idempotent — safe to re-run to upgrade.
|
||||
#
|
||||
# sudo ./install.sh
|
||||
#
|
||||
# What it does: creates the stalwart user + /opt/stalwart layout, downloads a
|
||||
# pinned Stalwart binary, installs config.toml + the secrets EnvironmentFile,
|
||||
# drops a self-signed bootstrap cert (replaced later by cert-sync), and installs
|
||||
# the systemd units (mail service + cert-sync service/timer).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
CONFIG_FILE="${CONFIG_FILE:-$HOST_DIR/config.env}"
|
||||
|
||||
PREFIX="/opt/stalwart"
|
||||
STALWART_REPO="${STALWART_REPO:-stalwartlabs/mail-server}"
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
error "Run as root."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
error "Missing $CONFIG_FILE — fill in the STALWART_* values first."
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
|
||||
: "${STALWART_ADMIN_PASSWORD:?STALWART_ADMIN_PASSWORD required (openssl rand -hex 24)}"
|
||||
: "${STALWART_WEBHOOK_SECRET:?STALWART_WEBHOOK_SECRET required (openssl rand -hex 32)}"
|
||||
: "${STALWART_VERSION:=latest}"
|
||||
|
||||
# ── Step 1: user + directory layout ────────────────────────────────────────
|
||||
info "Step 1: stalwart user + ${PREFIX} layout..."
|
||||
if ! id -u stalwart >/dev/null 2>&1; then
|
||||
useradd --system --home-dir "$PREFIX" --shell /usr/sbin/nologin stalwart
|
||||
fi
|
||||
install -d -o stalwart -g stalwart -m 0750 "$PREFIX" "$PREFIX/bin" "$PREFIX/data" "$PREFIX/logs"
|
||||
install -d -o stalwart -g stalwart -m 0750 "$PREFIX/etc" "$PREFIX/etc/tls"
|
||||
ok "Layout ready."
|
||||
|
||||
# ── Step 2: download the Stalwart binary ───────────────────────────────────
|
||||
info "Step 2: fetching Stalwart binary (${STALWART_REPO}@${STALWART_VERSION})..."
|
||||
arch="$(uname -m)"
|
||||
case "$arch" in
|
||||
x86_64) target="x86_64-unknown-linux-gnu" ;;
|
||||
aarch64) target="aarch64-unknown-linux-gnu" ;;
|
||||
*) error "Unsupported arch: $arch"; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [[ "$STALWART_VERSION" == "latest" ]]; then
|
||||
api="https://api.github.com/repos/${STALWART_REPO}/releases/latest"
|
||||
warn "Using 'latest' — pin STALWART_VERSION to a tag in config.env after this install."
|
||||
else
|
||||
api="https://api.github.com/repos/${STALWART_REPO}/releases/tags/${STALWART_VERSION}"
|
||||
fi
|
||||
|
||||
asset_url="$(curl -fsSL "$api" \
|
||||
| grep -oE "https://[^\"]+${target}[^\"]+\.tar\.gz" \
|
||||
| head -n1)"
|
||||
if [[ -z "$asset_url" ]]; then
|
||||
error "Could not find a ${target} .tar.gz asset in ${STALWART_REPO}@${STALWART_VERSION}."
|
||||
error "Check the release assets or set STALWART_REPO/STALWART_VERSION."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
|
||||
info "Downloading $asset_url"
|
||||
curl -fsSL "$asset_url" -o "$tmp/stalwart.tar.gz"
|
||||
tar -xzf "$tmp/stalwart.tar.gz" -C "$tmp"
|
||||
bin="$(find "$tmp" -type f \( -name stalwart -o -name stalwart-mail \) | head -n1)"
|
||||
if [[ -z "$bin" ]]; then
|
||||
error "No 'stalwart'/'stalwart-mail' binary found in the archive."
|
||||
exit 1
|
||||
fi
|
||||
systemctl stop stalwart-mail 2>/dev/null || true
|
||||
install -o stalwart -g stalwart -m 0755 "$bin" "$PREFIX/bin/stalwart"
|
||||
ok "Installed $("$PREFIX/bin/stalwart" --version 2>/dev/null || echo 'stalwart binary')."
|
||||
|
||||
# ── Step 3: config + secrets EnvironmentFile ───────────────────────────────
|
||||
info "Step 3: config.toml + secrets env..."
|
||||
install -o stalwart -g stalwart -m 0640 "$SCRIPT_DIR/config.toml" "$PREFIX/etc/config.toml"
|
||||
umask 077
|
||||
cat > "$PREFIX/etc/stalwart.env" <<EOF
|
||||
# Generated by install.sh from config.env — DO NOT commit.
|
||||
STALWART_ADMIN_PASSWORD=${STALWART_ADMIN_PASSWORD}
|
||||
STALWART_WEBHOOK_SECRET=${STALWART_WEBHOOK_SECRET}
|
||||
EOF
|
||||
chown root:stalwart "$PREFIX/etc/stalwart.env"
|
||||
chmod 0640 "$PREFIX/etc/stalwart.env"
|
||||
ok "Config + secrets installed."
|
||||
|
||||
# ── Step 4: self-signed bootstrap cert (only if none yet) ──────────────────
|
||||
if [[ ! -s "$PREFIX/etc/tls/cert.pem" ]]; then
|
||||
info "Step 4: generating self-signed bootstrap cert (cert-sync replaces it)..."
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
|
||||
-keyout "$PREFIX/etc/tls/key.pem" \
|
||||
-out "$PREFIX/etc/tls/cert.pem" \
|
||||
-subj "/CN=mail.dezky.eu" >/dev/null 2>&1
|
||||
chown stalwart:stalwart "$PREFIX/etc/tls/"*.pem
|
||||
chmod 0644 "$PREFIX/etc/tls/cert.pem"; chmod 0640 "$PREFIX/etc/tls/key.pem"
|
||||
ok "Bootstrap cert in place."
|
||||
else
|
||||
ok "Step 4: TLS cert already present — keeping it."
|
||||
fi
|
||||
|
||||
# ── Step 5: cert-sync + systemd units ──────────────────────────────────────
|
||||
info "Step 5: installing cert-sync + systemd units..."
|
||||
install -o root -g root -m 0755 "$SCRIPT_DIR/cert-sync.sh" "$PREFIX/cert-sync.sh"
|
||||
install -m 0644 "$SCRIPT_DIR/stalwart-mail.service" /etc/systemd/system/stalwart-mail.service
|
||||
install -m 0644 "$SCRIPT_DIR/stalwart-cert-sync.service" /etc/systemd/system/stalwart-cert-sync.service
|
||||
install -m 0644 "$SCRIPT_DIR/stalwart-cert-sync.timer" /etc/systemd/system/stalwart-cert-sync.timer
|
||||
systemctl daemon-reload
|
||||
systemctl enable --now stalwart-mail.service
|
||||
systemctl enable --now stalwart-cert-sync.timer
|
||||
ok "Services enabled."
|
||||
|
||||
# Try an immediate cert sync (no-op until cert-manager has issued the secret)
|
||||
"$PREFIX/cert-sync.sh" || true
|
||||
|
||||
echo ""
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Stalwart installed & running ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
systemctl --no-pager --lines=0 status stalwart-mail || true
|
||||
echo ""
|
||||
warn "Follow-ups:"
|
||||
warn " • PTR/rDNS for the server IP MUST be 'mail.dezky.eu' (Hetzner Robot)."
|
||||
warn " • Publish DNS at simply.com: MX → mail.dezky.eu, SPF, DMARC; per-domain"
|
||||
warn " DKIM records come from Stalwart's dnsZoneFile via platform-api."
|
||||
warn " • platform-api (k3s) env: STALWART_API_URL=http://<node-ip>:8080"
|
||||
warn " STALWART_ADMIN_USER=admin STALWART_ADMIN_PASSWORD=<same as here>"
|
||||
warn " STALWART_WEBHOOK_SECRET=<same as here> STALWART_PROVISIONING_ENABLED=true"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Oneshot: sync the mail TLS cert from the cluster to Stalwart.
|
||||
# Triggered by stalwart-cert-sync.timer.
|
||||
[Unit]
|
||||
Description=Sync mail.dezky.eu TLS cert from cluster to Stalwart
|
||||
After=network-online.target k3s.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/opt/stalwart/cert-sync.sh
|
||||
@@ -0,0 +1,12 @@
|
||||
# Run cert-sync shortly after boot and every 12h thereafter. cert-manager
|
||||
# renews well before expiry, so twice-daily comfortably picks up new certs.
|
||||
[Unit]
|
||||
Description=Periodic mail TLS cert sync for Stalwart
|
||||
|
||||
[Timer]
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=12h
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,39 @@
|
||||
# Dezky — Stalwart mail server (bare-metal host service).
|
||||
#
|
||||
# Secrets (admin password, webhook secret) come from the EnvironmentFile, which
|
||||
# install.sh generates from config.env. The binary needs CAP_NET_BIND_SERVICE
|
||||
# to bind the privileged mail ports (25/143/...) while running as a non-root user.
|
||||
|
||||
[Unit]
|
||||
Description=Stalwart Mail Server (Dezky)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=stalwart
|
||||
Group=stalwart
|
||||
EnvironmentFile=/opt/stalwart/etc/stalwart.env
|
||||
ExecStart=/opt/stalwart/bin/stalwart --config /opt/stalwart/etc/config.toml
|
||||
# Stalwart reloads its TLS certs / config on SIGHUP — used by cert-sync.
|
||||
ExecReload=/bin/kill -HUP $MAINPID
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
LimitNOFILE=65536
|
||||
|
||||
# Bind privileged ports without full root
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
|
||||
# Hardening — Stalwart only needs to write under /opt/stalwart
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
ReadWritePaths=/opt/stalwart/data /opt/stalwart/logs /opt/stalwart/etc/tls
|
||||
ProtectKernelTunables=true
|
||||
ProtectControlGroups=true
|
||||
RestrictSUIDSGID=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user