Files
stack/infra/droplets/mail-setup.sh
kert f9399350c1
All checks were successful
CI / lint (push) Successful in 33s
CI / test (push) Successful in 14m57s
Deploy / notebooks (push) Has been skipped
Deploy / zotero (push) Has been skipped
Deploy / docs (push) Has been skipped
Deploy / api (push) Has been skipped
Deploy / mc (push) Has been skipped
CI / notebooks-smoke (push) Successful in 1m31s
Deploy / report (push) Successful in 13s
Notebooks Integration / notebooks-integration (push) Successful in 7m18s
Zotero Sync / zotero-sync (push) Successful in 55s
Package Supply Chain / pkg-supply-chain (push) Successful in 53s
fix(mail): maddy cert-renew hook used $HOSTNAME, empty under cron
The daily /etc/cron.daily/maddy-cert-renew deploy-hook referenced
/etc/letsencrypt/live/${HOSTNAME}/, but HOSTNAME is unset in cron's
environment, so it expanded to /etc/letsencrypt/live//fullchain.pem —
the copy failed silently every renewal. certbot renewed the cert into
/etc/letsencrypt/live but it never reached /srv/mail/tls, so maddy kept
serving the old cert until it expired (2026-07-14), breaking IMAPS/SMTP
TLS for every client (caught by corwins sentinel as a mail-poller
CERTIFICATE_VERIFY_FAILED).

Use $RENEWED_LINEAGE — the cert-dir path certbot exports into the
deploy-hook environment — and single-quote the hook so it stays literal
until certbot expands it at deploy time. The live droplet was fixed
out-of-band (cert copied, maddy restarted, hook replaced); this makes a
fresh provision correct too.
2026-07-16 09:33:46 -04:00

152 lines
6.3 KiB
Bash
Executable File

#!/bin/bash
# Provision a DigitalOcean Maddy mail droplet.
#
# Runs under cloud-init with these env vars already exported by the
# builder (see src/cli/mail.py::_cloud_init):
# HOSTNAME fqdn of this mail server, e.g. mail.corwins.media
# PRIMARY_DOMAIN bare domain, e.g. corwins.media
# POSTMASTER_PASSWORD initial password for postmaster@<domain>
# GITEA_SMTP_PASSWORD initial password for gitea@<domain> (app-only account)
#
# Idempotent — safe to re-run. Installs Docker + Maddy, lays down config,
# provisions the two seed accounts, and opens the firewall for SMTP/IMAP.
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y -qq curl gettext-base ca-certificates ufw
# ── Docker Engine (upstream, not the ancient distro package) ──────
if ! command -v docker >/dev/null 2>&1; then
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
fi
# ── Maddy config + data dir ───────────────────────────────────────
install -d -m 0750 /srv/mail /srv/mail/tls /srv/mail/data
cat > /srv/mail/maddy.conf.tpl <<'MADDY_CONF_EOF'
__MADDY_CONF_TPL_PLACEHOLDER__
MADDY_CONF_EOF
# Render config atomically and detect changes so we only restart maddy
# when the rendered file actually differs.
envsubst '$HOSTNAME $PRIMARY_DOMAIN' \
< /srv/mail/maddy.conf.tpl > /srv/mail/data/maddy.conf.new
if ! cmp -s /srv/mail/data/maddy.conf /srv/mail/data/maddy.conf.new 2>/dev/null; then
mv /srv/mail/data/maddy.conf.new /srv/mail/data/maddy.conf
MADDY_CONF_CHANGED=1
else
rm -f /srv/mail/data/maddy.conf.new
fi
# Empty aliases file so the local_aliases chain doesn't error on first boot
[ -f /srv/mail/data/aliases ] || : > /srv/mail/data/aliases
# ── docker-compose.yml ────────────────────────────────────────────
cat > /srv/mail/docker-compose.yml <<COMPOSE_EOF
services:
mail:
image: foxcpp/maddy:latest
container_name: mail
restart: unless-stopped
hostname: ${HOSTNAME}
environment:
MADDY_HOSTNAME: ${HOSTNAME}
MADDY_DOMAIN: ${PRIMARY_DOMAIN}
ports:
- "25:25"
- "143:143"
- "465:465"
- "587:587"
- "993:993"
volumes:
- ./data:/data
- ./tls:/data/tls:ro
COMPOSE_EOF
# ── Let's Encrypt via DNS-01 (Cloudflare plugin) ──────────────────
# DNS-01 not HTTP-01 because:
# 1. The mail.$(domain) A record doesn't yet point at this droplet
# when cloud-init runs (the CLI writes DNS *after* the droplet is
# up), so HTTP-01 would race and fail.
# 2. Port 80 doesn't need to be internet-facing on a mail server.
# 3. DNS-01 works even when the droplet has no inbound HTTP at all.
#
# Requires CLOUDFLARE_API_TOKEN to be exported by the user_data wrapper.
apt-get install -y -qq certbot python3-certbot-dns-cloudflare
mkdir -p /etc/letsencrypt/cloudflare
cat > /etc/letsencrypt/cloudflare/credentials.ini <<CF_EOF
dns_cloudflare_api_token = ${CLOUDFLARE_API_TOKEN}
CF_EOF
chmod 600 /etc/letsencrypt/cloudflare/credentials.ini
# --keep-until-expiring makes this safe to re-run.
certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
--dns-cloudflare-propagation-seconds 20 \
--non-interactive --agree-tos --keep-until-expiring \
-m "postmaster@${PRIMARY_DOMAIN}" -d "${HOSTNAME}" || true
if [ -f "/etc/letsencrypt/live/${HOSTNAME}/fullchain.pem" ]; then
cp /etc/letsencrypt/live/${HOSTNAME}/fullchain.pem /srv/mail/tls/fullchain.pem
cp /etc/letsencrypt/live/${HOSTNAME}/privkey.pem /srv/mail/tls/privkey.pem
chmod 644 /srv/mail/tls/fullchain.pem
chmod 600 /srv/mail/tls/privkey.pem
fi
# Daily renew hook — DNS-01 doesn't need port 80.
#
# The deploy-hook references the cert dir by $RENEWED_LINEAGE, the path
# certbot exports into the hook's environment — NOT ${HOSTNAME}. Under cron
# HOSTNAME is unset, so the old hook expanded to
# /etc/letsencrypt/live//fullchain.pem, the copy failed silently, the
# renewed cert never reached /srv/mail/tls, and maddy served the stale cert
# until it expired (2026-07). Single-quoting the deploy-hook keeps
# $RENEWED_LINEAGE literal so certbot expands it at deploy time.
cat > /etc/cron.daily/maddy-cert-renew <<'CRON_EOF'
#!/bin/bash
set -e
certbot renew --quiet --deploy-hook 'cp "$RENEWED_LINEAGE/fullchain.pem" /srv/mail/tls/fullchain.pem && cp "$RENEWED_LINEAGE/privkey.pem" /srv/mail/tls/privkey.pem && chmod 644 /srv/mail/tls/fullchain.pem && chmod 600 /srv/mail/tls/privkey.pem && docker restart mail'
CRON_EOF
chmod +x /etc/cron.daily/maddy-cert-renew
# ── Firewall ──────────────────────────────────────────────────────
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 25/tcp
ufw allow 143/tcp
ufw allow 465/tcp
ufw allow 587/tcp
ufw allow 993/tcp
ufw --force enable
# ── Bring up maddy ────────────────────────────────────────────────
cd /srv/mail
docker compose pull
docker compose up -d
# Only restart when config actually drifted — keeps re-runs cheap.
if [ "${MADDY_CONF_CHANGED:-0}" = "1" ]; then
docker restart mail
fi
# Mailbox seeding is owned by the orchestrator (`stack mail` /
# `corwins mail`), not this script. The orchestrator SSHes in after
# Maddy is up and runs `maddy creds create` per mailbox. Keeping
# seeding out of cloud-init means adding a new mailbox is one CLI
# call (`stack mail rotate-creds <name>`) — no droplet rebuild, no
# template edit, no env-var threading.
echo "==> Maddy provisioning complete for ${HOSTNAME}"