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
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.
152 lines
6.3 KiB
Bash
Executable File
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}"
|