Let a server service carry helper scripts and timers, starting with UseSend's cert refresh

This commit is contained in:
Gabriel Brown committed 2026-10-30 13:44:29 -04:00
1 parent a987cbab66
commit 95fa480469
9 files changed
+304 -59

No files matched your search

+5 -2
View File
@@ -8,14 +8,17 @@ AWS_DEFAULT_REGION=us-east-2
DATABASE_URL=CHANGE_ME
GITHUB_ID=Ov23liz34colyJed9t9S
GITHUB_SECRET=CHANGE_ME
MINIO_ROOT_USER=usesend
MINIO_ROOT_PASSWORD=CHANGE_ME
NEXTAUTH_SECRET=CHANGE_ME
NEXT_PUBLIC_IS_CLOUD=false
POSTGRES_USER=usesend
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=usesend
REDIS_URL=redis://usesend-redis:6379
RUSTFS_ACCESS_KEY=CHANGE_ME
RUSTFS_SECRET_KEY=CHANGE_ME
S3_COMPATIBLE_API_URL=https://storage.usesend.gbrown.org
S3_COMPATIBLE_PUBLIC_URL=https://storage.usesend.gbrown.org/unsend
S3_COMPATIBLE_BUCKET=unsend
SMTP_HOST=smtp.usesend.gbrown.org
SMTP_USER=usesend
USESEND_PORT=3000
+39
View File
@@ -0,0 +1,39 @@
# TLS certificate for the UseSend SMTP proxy
The `usesend/smtp-proxy` image ships a default certificate that **cannot complete a
TLS 1.3 handshake** — OpenSSL rejects it with `no suitable signature algorithm`.
Because the proxy also refuses `AUTH` until `STARTTLS` has succeeded, that default
cert made *all* inbound SMTP impossible. These files replace it.
## What lives in certs/
`fullchain.pem` and `privkey.pem`, copied from NPM's Let's Encrypt store:
~/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43/
That certificate carries `*.usesend.gbrown.org`, which is what lets clients validate
the proxy as `smtp.usesend.gbrown.org`. A wildcard matches exactly one label, so
`*.gbrown.org` alone would not cover it.
Clients reach that name two ways, neither of them public DNS:
- containers on `nginx-bridge` get the network alias set in `compose.yml`
- LAN and home-server clients get a UniFi DNS record pointing it at `192.168.2.2`
## Why copies instead of a bind mount
NPM mounts that letsencrypt tree with `:Z`, which gives it a *private* SELinux MCS
category. A second container mounting the same path would relabel it and the two
would fight over it. Copying keeps the stacks independent.
## Keeping the copies fresh
NPM renews on its own schedule, so `usesend-cert-refresh.timer` runs
`refresh-smtp-certs` daily. It copies the cert when NPM's fingerprint changes,
refuses a half-renewed cert/key pair, and restarts the proxy.
If the copies ever expire, every app sending mail through the proxy fails at once,
with a TLS error rather than anything that mentions certificates. Check with:
openssl x509 -in ~/Server/UseSend/certs/fullchain.pem -noout -enddate
systemctl --user list-timers usesend-cert-refresh.timer
+69 -30
View File
@@ -21,25 +21,69 @@ services:
tty: true
stdin_open: true
restart: unless-stopped
minio:
image: minio/minio
# S3-compatible storage for images uploaded in the email editor, following
# upstream's docker/prod/compose.yml (RustFS replaced MinIO, whose images are
# no longer published). The browser uploads straight to S3_COMPATIBLE_API_URL
# and sent emails link to S3_COMPATIBLE_PUBLIC_URL, so NPM proxies
# storage.usesend.gbrown.org -> http://usesend-storage:9000. No host ports:
# the console (9001) is only reachable from inside nginx-bridge.
# Pinned like upstream. Bump the tag deliberately when upstream does.
rustfs:
image: docker.io/rustfs/rustfs:1.0.4
container_name: usesend-storage
hostname: usesend-storage
networks:
- nginx-bridge
env_file: .env
# RustFS runs as uid 10001; keep-id keeps ./volumes/rustfs gib-owned (AGENTS.md §3.2)
userns_mode: keep-id:uid=10001,gid=10001
security_opt:
- no-new-privileges:true
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
labels:
com.centurylinklabs.watchtower.enable: 'true'
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?err}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?err}
RUSTFS_ADDRESS: 0.0.0.0:9000
RUSTFS_CONSOLE_ADDRESS: 0.0.0.0:9001
RUSTFS_CONSOLE_ENABLE: 'true'
RUSTFS_OBS_LOGGER_LEVEL: error
TZ: America/New_York
volumes:
- ./volumes/minio:/data:Z
entrypoint: sh
command: -c 'mkdir -p /data/unsend && minio server /data --console-address ':9001' --address ':9002''
tty: true
stdin_open: true
- ./volumes/rustfs:/data:Z
command: ["/data"]
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 20s
timeout: 10s
retries: 5
start_period: 40s
restart: unless-stopped
# One-shot: creates the bucket, allows browser uploads from the app (CORS) and
# makes objects publicly readable so images load in sent emails. Reruns on
# every unit start, which is harmless.
rustfs-init:
image: docker.io/amazon/aws-cli:2.34.30
container_name: usesend-storage-init
networks:
- nginx-bridge
entrypoint: ["/bin/sh", "-ec"]
environment:
AWS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY:?err}
AWS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY:?err}
AWS_DEFAULT_REGION: us-east-1
AWS_EC2_METADATA_DISABLED: 'true'
BUCKET: ${S3_COMPATIBLE_BUCKET:-unsend}
APP_ORIGIN: ${USESEND_URL:?err}
depends_on:
rustfs:
condition: service_healthy
command:
- |
s3="aws --endpoint-url http://usesend-storage:9000 s3api"
$$s3 head-bucket --bucket "$$BUCKET" >/dev/null 2>&1 || $$s3 create-bucket --bucket "$$BUCKET"
$$s3 put-bucket-cors --bucket "$$BUCKET" --cors-configuration \
'{"CORSRules":[{"AllowedOrigins":["'"$$APP_ORIGIN"'"],"AllowedMethods":["GET","HEAD","PUT"],"AllowedHeaders":["*"],"ExposeHeaders":["ETag"],"MaxAgeSeconds":3600}]}'
$$s3 put-bucket-policy --bucket "$$BUCKET" --policy \
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::'"$$BUCKET"'/*"]}]}'
restart: 'no'
smtp-server:
image: usesend/smtp-proxy:latest
container_name: usesend-smtp-server
@@ -47,26 +91,15 @@ services:
networks:
nginx-bridge:
# The proxy REQUIRES STARTTLS before it will accept AUTH, so clients must
# reach it by a name the certificate actually covers. The wildcard is
# *.gbrown.org, which matches ONE label -- hence smtp-usesend, not
# smtp.usesend.
# reach it by a name the certificate actually covers: smtp.usesend.gbrown.org,
# validated by the *.usesend.gbrown.org SAN on npm-43 (see certs/README.md).
#
# Resolution: podman's aardvark-dns answers this name authoritatively for
# containers ON nginx-bridge, handing back the container IP. NPM is not
# involved at all -- it only reverse-proxies HTTP(S), and SMTP ports are
# never published to the host, so the proxy is unreachable from outside.
#
# NOTE: there IS a wildcard *.gbrown.org record in Cloudflare, so this name
# also resolves publicly (to Cloudflare, which serves nothing for it). The
# consequence: if this container is STOPPED, aardvark-dns stops answering,
# the lookup falls through to that public record, and clients dial
# Cloudflare on 587 and hang until timeout instead of failing fast.
# involved at all -- it only reverse-proxies HTTP(S). Cloudflare is not
# involved either: there is deliberately no public DNS record for this name,
# so if the container is stopped, lookups fail fast with NXDOMAIN.
aliases:
# Both names resolve to this container and BOTH validate against npm-43,
# which carries *.gbrown.org and *.usesend.gbrown.org. smtp.usesend is the
# name UseSend's own UI advertises and is the target to standardise on;
# smtp-usesend stays until every consumer has been moved across.
- smtp-usesend.gbrown.org
- smtp.usesend.gbrown.org
env_file: .env
environment:
@@ -83,8 +116,8 @@ services:
# brute-force it, and contradicts the Cloudflare-only posture in §7.
# The `Home` wg interface sits in firewalld's `trusted` zone, so no rule is needed.
# Rootless can bind these because net.ipv4.ip_unprivileged_port_start = 80.
# Consumers on the home server reach it as smtp-usesend.gbrown.org (UniFi DNS maps
# that name to 192.168.2.2) so the *.gbrown.org cert still validates by hostname.
# Consumers on the home server reach it as smtp.usesend.gbrown.org (a UniFi DNS record
# maps that name to 192.168.2.2) so the cert still validates by hostname.
- "192.168.2.2:587:587"
- "192.168.2.2:465:465"
volumes:
@@ -121,6 +154,12 @@ services:
- API_RATE_LIMIT=${API_RATE_LIMIT:-1}
- SMTP_HOST=${SMTP_HOST:-smtp.usesend.gbrown.org}
- SMTP_USER=${SMTP_USER:-usesend}
# Editor image uploads (the rustfs service above). Uploads are disabled if any is unset.
- S3_COMPATIBLE_ACCESS_KEY=${RUSTFS_ACCESS_KEY:?err}
- S3_COMPATIBLE_SECRET_KEY=${RUSTFS_SECRET_KEY:?err}
- S3_COMPATIBLE_API_URL=${S3_COMPATIBLE_API_URL:?err}
- S3_COMPATIBLE_PUBLIC_URL=${S3_COMPATIBLE_PUBLIC_URL:?err}
- S3_COMPATIBLE_BUCKET=${S3_COMPATIBLE_BUCKET:-unsend}
labels:
com.centurylinklabs.watchtower.enable: 'true'
depends_on:
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Keep the UseSend SMTP proxy's TLS certificate in step with NPM's Let's Encrypt store.
#
# The proxy refuses AUTH until STARTTLS succeeds, so an expired or mismatched cert
# takes down ALL outbound mail for every app that sends through it -- and it fails
# with a bare TLS error that never mentions certificates. Hence this timer.
#
# Copies rather than bind-mounts because NPM mounts its letsencrypt tree with :Z
# (private SELinux category) and two consumers on one path fight over the label.
#
# Uses npm-43 because it carries *.usesend.gbrown.org, the SAN that validates
# smtp.usesend.gbrown.org. A wildcard matches exactly ONE label, so *.gbrown.org
# alone would not cover it.
set -euo pipefail
SRC=$HOME/Server/Nginx_Proxy_Manager/volumes/letsencrypt/live/npm-43
DST=$HOME/Server/UseSend/certs
CONTAINER=usesend-smtp-server
fp() { openssl x509 -in "$1" -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2; }
certpub(){ openssl x509 -in "$1" -noout -pubkey 2>/dev/null | openssl sha256 | awk '{print $2}'; }
keypub() { openssl pkey -in "$1" -pubout 2>/dev/null | openssl sha256 | awk '{print $2}'; }
[ -r "$SRC/fullchain.pem" ] && [ -r "$SRC/privkey.pem" ] || {
echo "source cert unreadable under $SRC -- did NPM move it?" >&2; exit 1; }
src_fp=$(fp "$SRC/fullchain.pem")
dst_fp=$(fp "$DST/fullchain.pem" 2>/dev/null || true)
if [ -n "$dst_fp" ] && [ "$src_fp" = "$dst_fp" ]; then
echo "cert unchanged (${src_fp:0:17}...); nothing to do"
exit 0
fi
# Refuse to install a half-renewed pair: certbot writes the two files separately, so
# a run that lands between them would leave the proxy with a key that is not its cert.
if [ "$(certpub "$SRC/fullchain.pem")" != "$(keypub "$SRC/privkey.pem")" ]; then
echo "source cert and key do not match -- renewal probably mid-flight, retrying next run" >&2
exit 0
fi
echo "cert changed (${dst_fp:0:17}... -> ${src_fp:0:17}...), installing"
# -L dereferences: live/ entries are symlinks into ../../archive/, outside this copy.
cp -L "$SRC/fullchain.pem" "$DST/fullchain.pem.new"
cp -L "$SRC/privkey.pem" "$DST/privkey.pem.new"
chmod 644 "$DST/fullchain.pem.new"
chmod 640 "$DST/privkey.pem.new"
mv -f "$DST/fullchain.pem.new" "$DST/fullchain.pem"
mv -f "$DST/privkey.pem.new" "$DST/privkey.pem"
echo "restarting $CONTAINER"
podman restart "$CONTAINER" >/dev/null
echo "done; cert now valid until $(openssl x509 -in "$DST/fullchain.pem" -noout -enddate | cut -d= -f2)"
@@ -0,0 +1,15 @@
[Unit]
Description=Refresh UseSend SMTP proxy TLS certificate from NPM's Let's Encrypt store
Documentation=file://%h/Server/UseSend/README.md
After=podman-usesend.service
[Service]
Type=oneshot
ExecStart=%h/Server/UseSend/refresh-smtp-certs
# The script calls `podman restart`, which leaves conmon and the container's
# supervising processes inside THIS unit's cgroup. With the default KillMode,
# systemd waits for that cgroup to empty and the unit times out and reports
# `failed` even though ExecStart returned 0 and the cert was installed correctly.
# KillMode=process makes systemd track only the script itself.
KillMode=process
TimeoutStartSec=300
@@ -0,0 +1,12 @@
[Unit]
Description=Daily check for a renewed UseSend SMTP proxy certificate
[Timer]
OnCalendar=daily
# Spread the load and avoid landing exactly when certbot is writing.
RandomizedDelaySec=2h
# Catch up after the host has been off; the cert does not care that we were asleep.
Persistent=true
[Install]
WantedBy=timers.target