diff --git a/.env.example b/.env.example index 5680f46..ac27827 100644 --- a/.env.example +++ b/.env.example @@ -41,16 +41,15 @@ EMAIL_SERVERS_WITH_PRIORITY=[(10, "$SUBDOMAIN.$EMAIL_DOMAIN.")] ## In self-hosted, you typically trust all users. Set this variable to disable this option. DISABLE_ALIAS_SUFFIX=1 -## the DKIM private key used to compute DKIM-Signature -DKIM_PRIVATE_KEY_PATH=/dkim.key +## DKIM_PRIVATE_KEY_PATH, GNUPGHOME, TEMP_DIR and SAVE_UNSENT_DIR are all +## paths *inside* the container's sldata volume. There's no reason for you +## to configure these - they're fixed in simple-login-compose.yaml. ## DB Connection DB_URI=postgresql://$POSTGRES_USER:$POSTGRES_PASSWORD@sl-db:5432/$POSTGRES_DB FLASK_SECRET=paste-flask-secret-here -GNUPGHOME=/sl/pgp - LOCAL_FILE_UPLOAD=1 POSTFIX_SERVER=postfix diff --git a/docker-compose.yaml b/docker-compose.yaml index 53efee0..2078d5c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -7,6 +7,10 @@ include: - traefik-compose.yaml - simple-login-compose.yaml - postfix-compose.yaml + ## Migrates pre-existing ./db, ./pgp, ./upload, ./dkim.key content (if any) + ## into the named volumes above. Safe to leave in for a fresh install too. + ## Comment out once you've confirmed the migration succeeded. + - migrate-volumes-compose.yaml networks: internal: diff --git a/migrate-volumes-compose.yaml b/migrate-volumes-compose.yaml new file mode 100644 index 0000000..88e130d --- /dev/null +++ b/migrate-volumes-compose.yaml @@ -0,0 +1,59 @@ +# One-time migration helper: copies pre-existing data from the old +# bind-mount layout (./db, ./pgp, ./upload, ./dkim.key) into the named +# volumes now used by simple-login-compose.yaml, if you're upgrading an +# existing deployment. +# +# Safe to leave included even without any of the above present (e.g. a +# fresh install) - it just creates the sldata directory structure and +# does nothing else. Comment out the include line for this file in +# docker-compose.yaml once you've confirmed the migration succeeded. +# +# The whole project directory is bind-mounted read-only (rather than each +# legacy path individually) because the project directory always exists, +# whereas e.g. ./dkim.key commonly does not on a fresh setup - bind +# mounting a single nonexistent file path would make Docker silently +# create an empty directory there instead of leaving it absent. +services: + volume-migrate: + image: alpine:3.22 + volumes: + - .:/legacy:ro + - db:/new/db + - sldata:/new/sldata + - upload:/new/upload + entrypoint: + - /bin/sh + - -c + - | + set -eu + migrate() { + if [ -n "$$(ls -A "$2" 2>/dev/null)" ]; then + echo "[volume-migrate] $2 already has data, skipping" + return + fi + if [ -d "$1" ] && [ -n "$$(ls -A "$1" 2>/dev/null)" ]; then + echo "[volume-migrate] copying existing $1 -> $2" + cp -a "$1"/. "$2"/ + else + echo "[volume-migrate] no existing data at $1, starting fresh" + fi + } + mkdir -p /new/sldata/pgp /new/sldata/dkim /new/sldata/tmp /new/sldata/unsent + migrate /legacy/db /new/db + migrate /legacy/pgp /new/sldata/pgp + migrate /legacy/upload /new/upload + chmod 700 /new/sldata/pgp + if [ -n "$$(ls -A /new/sldata/dkim 2>/dev/null)" ]; then + echo "[volume-migrate] /new/sldata/dkim already has data, skipping" + elif [ -f /legacy/dkim.key ]; then + echo "[volume-migrate] copying existing dkim.key" + cp -a /legacy/dkim.key /new/sldata/dkim/dkim.key + chmod 600 /new/sldata/dkim/dkim.key + else + echo "[volume-migrate] no existing dkim.key" + fi + + postgres: + depends_on: + volume-migrate: + condition: service_completed_successfully diff --git a/postfix-compose.yaml b/postfix-compose.yaml index 40f3d43..af52470 100644 --- a/postfix-compose.yaml +++ b/postfix-compose.yaml @@ -13,13 +13,36 @@ services: networks: internal: ipv4_address: 10.0.0.53 - volumes: - - ./unbound/conf.d/:/config/:ro restart: unless-stopped + # Listener config is written at container start instead of bind-mounted + # from ./unbound/conf.d/, so this has no host-file dependency at all. + # Picked up via the image's own `include: /config/*.conf`, additively + # alongside its default listener on 5053 - so the image's built-in + # healthcheck (which probes 5053) is unaffected. + # Recipe: https://github.com/springcomp/self-hosted-simplelogin/pull/46#issuecomment-3707216659 + # + # module-config/identity/root-hints match what the previous + # bind-mounted unbound/conf.d/00-unbound.conf set, to not weaken + # anything the upstream maintainer deliberately configured: + # - module-config trims the cachedb module the image loads by default + # (unused without a configured cache backend) + # - root-hints pins the image's own dns-root-hints package file, which + # the image refreshes monthly via its own cron + # (/etc/periodic/monthly/dns-root-hints) - the compiled-in fallback + # Unbound would otherwise use only updates when the image itself does entrypoint: - /bin/sh - -ec - | + cat > /config/00-listen-port.conf <<'EOF' + server: + interface: 0.0.0.0@53 + log-queries: yes + verbosity: 2 + module-config: "validator iterator" + identity: "DNS" + root-hints: "/usr/share/dns-root-hints/named.root" + EOF unbound-anchor -a /var/run/unbound/root.key || true exec su -s /bin/sh unbound -c "sh /entrypoint.sh" diff --git a/simple-login-compose.yaml b/simple-login-compose.yaml index 91e5709..5046adb 100644 --- a/simple-login-compose.yaml +++ b/simple-login-compose.yaml @@ -1,10 +1,18 @@ x-sl-defaults: &sl-defaults image: simplelogin/$SL_IMAGE:$SL_VERSION env_file: .env + # Paths inside the container - not meant to be end-user configurable + # (we control the whole image/volume layout), so these are fixed here + # rather than left for .env to set. Overrides any stale value a .env + # might still carry from before this change. + environment: + DKIM_PRIVATE_KEY_PATH: /sl/dkim/dkim.key + GNUPGHOME: /sl/pgp + TEMP_DIR: /sl/tmp + SAVE_UNSENT_DIR: /sl/unsent volumes: - - ./pgp:/sl/pgp - - ./upload:/code/static/upload - - ./dkim.key:/dkim.key + - sldata:/sl + - upload:/code/static/upload networks: - internal @@ -28,7 +36,7 @@ services: networks: - internal volumes: - - ./db:/var/lib/postgresql/data + - db:/var/lib/postgresql/data restart: unless-stopped migration: @@ -41,8 +49,27 @@ services: init: <<: *sl-defaults - command: ["python", "init_app.py"] container_name: sl-init + # Ensures the sldata volume has the directory structure the app needs, + # and generates a DKIM key if none exists yet - runs on every start, + # but only ever acts once (mkdir -p and the missing-key check are both + # no-ops after the first successful run). This also covers the case + # where migrate-volumes-compose.yaml (which does the same mkdir -p, to + # have somewhere to copy pre-existing bind-mounted data into) has been + # commented out after migration, or was never used at all. + entrypoint: + - /bin/sh + - -c + - | + set -eu + umask 077 + mkdir -p /sl/pgp /sl/dkim /sl/tmp /sl/unsent + if [ ! -s "$$DKIM_PRIVATE_KEY_PATH" ]; then + echo "[dkim-init] No key at $$DKIM_PRIVATE_KEY_PATH — generating 1024-bit RSA key..." + openssl genrsa -out "$$DKIM_PRIVATE_KEY_PATH" 1024 + fi + chmod 600 "$$DKIM_PRIVATE_KEY_PATH" 2>/dev/null || true + exec python init_app.py depends_on: migration: condition: service_completed_successfully @@ -50,6 +77,23 @@ services: app: <<: *sl-defaults container_name: sl-app + # Prints the DKIM public key on every start, for convenience when + # setting up the DNS TXT record. No set -eu here: by the time this + # container starts, `init` above has already guaranteed a valid key + # exists, and the openssl pipe below wouldn't propagate a failure from + # its first stage anyway (no pipefail in /bin/sh), so -e would only + # look like protection without actually providing any. + # + # --timeout 45 (not the Dockerfile default of 15): a fix from an + # earlier deployment that never made it back into the app repo. + entrypoint: + - /bin/sh + - -c + - | + PUB="$(openssl rsa -in "$$DKIM_PRIVATE_KEY_PATH" -pubout -outform DER 2>/dev/null | openssl base64 -A)" + echo "[dkim-init] DKIM public key (p=) — add this to your DNS TXT record:" + echo "v=DKIM1; k=rsa; p=$$PUB" + exec gunicorn wsgi:app -b 0.0.0.0:7777 -w 2 --timeout 45 networks: - traefik - internal @@ -110,3 +154,8 @@ services: condition: service_completed_successfully email: condition: service_started + +volumes: + db: + sldata: + upload: diff --git a/unbound/conf.d/00-unbound.conf b/unbound/conf.d/00-unbound.conf deleted file mode 100644 index 2283164..0000000 --- a/unbound/conf.d/00-unbound.conf +++ /dev/null @@ -1,14 +0,0 @@ - # Module configuration - disable subnetcache (not needed for local resolver) - module-config: "validator iterator" - - # Listen to for queries from clients and answer from this network interface - # and port. - interface: 0.0.0.0 - interface: ::1 - port: 53 - - # Report this identity rather than the hostname of the server. - identity: "DNS" - - # root.hints enable unbound to perform recursive resolution - root-hints: "/usr/share/dns-root-hints/named.root" diff --git a/unbound/conf.d/10-logging.conf b/unbound/conf.d/10-logging.conf deleted file mode 100644 index c7599b8..0000000 --- a/unbound/conf.d/10-logging.conf +++ /dev/null @@ -1,2 +0,0 @@ - log-queries: yes - verbosity: 2