diff --git a/images/postfix-pgsql/Dockerfile b/images/postfix-pgsql/Dockerfile new file mode 100644 index 0000000..2d520e5 --- /dev/null +++ b/images/postfix-pgsql/Dockerfile @@ -0,0 +1,38 @@ +# ============================================================================= +# Postfix + PostgreSQL Relay/MTA Image +# +# A pure SMTP relay/MTA: receives inbound mail and forwards it based on +# PostgreSQL-backed relay_domains/transport_maps lookups, equivalent to the +# Postfix setup described in the SimpleLogin self-hosting documentation +# (https://github.com/simple-login/app). No domain, hostname, or database +# credentials are baked into the image; everything is templated from +# environment variables at container startup. This image has no knowledge +# of what consumes the relayed mail. +# ============================================================================= + +# POSTFIX_VERSION pins the Debian base image tag (e.g. "12-slim"), which in +# turn pins the postfix/postfix-pgsql package versions available via apt. +ARG POSTFIX_VERSION=12-slim +FROM debian:${POSTFIX_VERSION} + +LABEL maintainer="Damien Arnodo" +LABEL description="Postfix SMTP relay/MTA with PostgreSQL-backed relay_domains and transport_maps lookups" + +RUN echo "postfix postfix/main_mailer_type select No configuration" | debconf-set-selections \ + && echo "postfix postfix/mailname string localhost" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + postfix \ + postfix-pgsql \ + gettext-base \ + openssl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY templates/ /etc/postfix/templates/ +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh + +EXPOSE 25 + +ENTRYPOINT ["docker-entrypoint.sh"] diff --git a/images/postfix-pgsql/POSTFIX_VERSION b/images/postfix-pgsql/POSTFIX_VERSION new file mode 100644 index 0000000..c729265 --- /dev/null +++ b/images/postfix-pgsql/POSTFIX_VERSION @@ -0,0 +1 @@ +12-slim diff --git a/images/postfix-pgsql/README.md b/images/postfix-pgsql/README.md new file mode 100644 index 0000000..c50b311 --- /dev/null +++ b/images/postfix-pgsql/README.md @@ -0,0 +1,136 @@ +# Postfix PostgreSQL Relay Image + +A Postfix SMTP relay/MTA whose relay and transport decisions are driven by +PostgreSQL lookup maps instead of static config. It receives inbound mail and +forwards it to an internal SMTP target after checking the recipient's domain +against a database — it has no knowledge of, and makes no assumptions about, +what consumes the mail downstream. + +This mirrors the Postfix setup described in the SimpleLogin self-hosting +documentation (`postfix` + `postfix-pgsql`, a `relay_domains` PostgreSQL map, +and a `transport_maps` PostgreSQL map): https://github.com/simple-login/app + +No domain, hostname, or database credential is hardcoded in the image. +Everything is templated from environment variables by +`docker-entrypoint.sh` at container startup, then Postfix is started in the +foreground (`postfix start-fg`) so it runs correctly under Docker's process +supervision. + +## How it works + +On startup, the entrypoint renders three files from templates under +`/etc/postfix/templates/`: + +- `/etc/postfix/main.cf` — core Postfix configuration. +- `/etc/postfix/pgsql-relay-domains.cf` — PostgreSQL lookup that tells + Postfix which domains it should relay mail for. +- `/etc/postfix/pgsql-transport-maps.cf` — PostgreSQL lookup that tells + Postfix where to forward accepted mail (the internal SMTP relay target). + +Chroot is disabled for all Postfix services via `postconf -F '*/*/chroot = n'` +on every startup. Postfix's chrooted child processes (smtpd, trivial-rewrite, +etc.) lack access to `/etc/resolv.conf` inside the chroot jail, so they cannot +resolve Docker-internal hostnames — causing every PostgreSQL lookup to fail with +`451 4.3.0 Temporary lookup failure`. Disabling chroot is the standard remedy +in containerised deployments. + +## Environment variables + +### Required + +| Variable | Description | Example | +|----------|--------------|---------| +| `MAIL_DOMAIN` | Mail domain this relay handles (`mydomain`/`myorigin` in `main.cf`). | `example.com` | +| `MYHOSTNAME` | Hostname of this relay, used as Postfix's `myhostname` and as the MX target for `MAIL_DOMAIN`. | `mx1.example.com` | +| `POSTGRES_HOST` | Hostname of the PostgreSQL server holding the lookup tables. | `postgres` | +| `POSTGRES_PORT` | PostgreSQL port. | `5432` | +| `POSTGRES_USER` | PostgreSQL user used for the lookup queries. | `postfix` | +| `POSTGRES_PASSWORD` | PostgreSQL password for `POSTGRES_USER`. | `change-me` | +| `POSTGRES_DB` | PostgreSQL database name. | `relaydb` | +| `SMTP_RELAY_HOST` | Internal SMTP host that accepted mail is forwarded to after the relay-domains lookup matches. | `app-internal` | +| `SMTP_RELAY_PORT` | Port of the internal SMTP relay target. | `2525` | + +### Optional + +| Variable | Description | +|----------|--------------| +| `TLS_CERT_FILE` | Absolute path (inside the container) to a TLS certificate to use for `smtpd_tls_cert_file`. If unset, a self-signed certificate is generated on first start. | +| `TLS_KEY_FILE` | Absolute path (inside the container) to the private key matching `TLS_CERT_FILE`. Must be set together with `TLS_CERT_FILE`. | + +The PostgreSQL lookup tables are expected to expose at least a +`relay_domain(domain)` table; both lookups query it by recipient domain. + +## TLS certificates + +By default, if `TLS_CERT_FILE`/`TLS_KEY_FILE` are not provided, the +entrypoint generates a self-signed certificate at +`/etc/postfix/ssl/snakeoil.{pem,key}` on first start (matching the +self-signed fallback used in the upstream docs) and reuses it on subsequent +restarts if that path is kept on a persistent volume. + +To use your own certificate instead, mount it into the container and point +`TLS_CERT_FILE`/`TLS_KEY_FILE` at the mounted paths, e.g.: + +```bash +docker run \ + -v /path/to/fullchain.pem:/certs/fullchain.pem:ro \ + -v /path/to/privkey.pem:/certs/privkey.pem:ro \ + -e TLS_CERT_FILE=/certs/fullchain.pem \ + -e TLS_KEY_FILE=/certs/privkey.pem \ + ... +``` + +## Usage + +### `docker run` + +```bash +docker run -d \ + -p 25:25 \ + -e MAIL_DOMAIN=example.com \ + -e MYHOSTNAME=mx1.example.com \ + -e POSTGRES_HOST=postgres \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=postfix \ + -e POSTGRES_PASSWORD=change-me \ + -e POSTGRES_DB=relaydb \ + -e SMTP_RELAY_HOST=app-internal \ + -e SMTP_RELAY_PORT=2525 \ + gitea.arnodo.fr/damien/postfix-pgsql:latest +``` + +### Docker Compose + +```yaml +services: + postfix: + image: gitea.arnodo.fr/damien/postfix-pgsql:latest + ports: + - "25:25" + environment: + MAIL_DOMAIN: example.com + MYHOSTNAME: mx1.example.com + POSTGRES_HOST: postgres + POSTGRES_PORT: "5432" + POSTGRES_USER: postfix + POSTGRES_PASSWORD: change-me + POSTGRES_DB: relaydb + SMTP_RELAY_HOST: app-internal + SMTP_RELAY_PORT: "2525" +``` + +## Version management + +The Debian base image tag is set in `POSTFIX_VERSION` (e.g. `12-slim`), +which also pins the `postfix`/`postfix-pgsql` package versions available via +`apt` for that Debian release. To change it: + +1. Edit `POSTFIX_VERSION` with the desired Debian tag. +2. Commit and push. +3. The CI builds the image with that base and tags it with both `latest` and + the value of `POSTFIX_VERSION`. + +## Reference + +- Upstream Postfix + PostgreSQL setup this image is based on: + https://github.com/simple-login/app diff --git a/images/postfix-pgsql/docker-entrypoint.sh b/images/postfix-pgsql/docker-entrypoint.sh new file mode 100644 index 0000000..883c97c --- /dev/null +++ b/images/postfix-pgsql/docker-entrypoint.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# ============================================================================= +# Renders Postfix config templates from environment variables and starts +# Postfix in the foreground. +# ============================================================================= +set -euo pipefail + +TEMPLATE_DIR="/etc/postfix/templates" +SSL_DIR="/etc/postfix/ssl" + +require_var() { + local name="$1" + if [ -z "${!name:-}" ]; then + echo "ERROR: required environment variable ${name} is not set" >&2 + exit 1 + fi +} + +for var in MAIL_DOMAIN MYHOSTNAME \ + POSTGRES_HOST POSTGRES_PORT POSTGRES_USER POSTGRES_PASSWORD POSTGRES_DB \ + SMTP_RELAY_HOST SMTP_RELAY_PORT; do + require_var "$var" +done + +# TLS: use mounted cert/key if provided, otherwise generate a self-signed +# "snakeoil" certificate on first start, matching the upstream docs' approach. +mkdir -p "$SSL_DIR" +if [ -n "${TLS_CERT_FILE:-}" ] && [ -n "${TLS_KEY_FILE:-}" ]; then + export SMTPD_TLS_CERT_FILE="$TLS_CERT_FILE" + export SMTPD_TLS_KEY_FILE="$TLS_KEY_FILE" +else + export SMTPD_TLS_CERT_FILE="${SSL_DIR}/snakeoil.pem" + export SMTPD_TLS_KEY_FILE="${SSL_DIR}/snakeoil.key" + if [ ! -f "$SMTPD_TLS_CERT_FILE" ] || [ ! -f "$SMTPD_TLS_KEY_FILE" ]; then + echo "No TLS_CERT_FILE/TLS_KEY_FILE provided, generating a self-signed certificate" + openssl req -x509 -nodes -days 3650 \ + -newkey rsa:2048 \ + -subj "/CN=${MYHOSTNAME}" \ + -keyout "$SMTPD_TLS_KEY_FILE" \ + -out "$SMTPD_TLS_CERT_FILE" + chmod 600 "$SMTPD_TLS_KEY_FILE" + fi +fi + +render() { + local template="$1" + local destination="$2" + envsubst '${MAIL_DOMAIN} ${MYHOSTNAME} ${SMTPD_TLS_CERT_FILE} ${SMTPD_TLS_KEY_FILE} ${POSTGRES_HOST} ${POSTGRES_PORT} ${POSTGRES_USER} ${POSTGRES_PASSWORD} ${POSTGRES_DB} ${SMTP_RELAY_HOST} ${SMTP_RELAY_PORT}' \ + < "$template" > "$destination" +} + +render "${TEMPLATE_DIR}/main.cf.template" /etc/postfix/main.cf +render "${TEMPLATE_DIR}/pgsql-relay-domains.cf.template" /etc/postfix/pgsql-relay-domains.cf +render "${TEMPLATE_DIR}/pgsql-transport-maps.cf.template" /etc/postfix/pgsql-transport-maps.cf + +# The pgsql lookup config files embed the database password; restrict access. +chown root:postfix /etc/postfix/pgsql-relay-domains.cf /etc/postfix/pgsql-transport-maps.cf +chmod 640 /etc/postfix/pgsql-relay-domains.cf /etc/postfix/pgsql-transport-maps.cf + +postfix check + +# Disable chroot for all master.cf services so child processes (smtpd, +# trivial-rewrite, etc.) can resolve Docker-internal hostnames via the host's +# /etc/resolv.conf. Without this, chrooted processes have no DNS and every +# pgsql lookup returns "451 4.3.0 Temporary lookup failure". +postconf -F '*/*/chroot = n' + +exec /usr/sbin/postfix start-fg diff --git a/images/postfix-pgsql/templates/main.cf.template b/images/postfix-pgsql/templates/main.cf.template new file mode 100644 index 0000000..261b281 --- /dev/null +++ b/images/postfix-pgsql/templates/main.cf.template @@ -0,0 +1,39 @@ +# ============================================================================= +# Postfix main.cf template +# +# Rendered at container startup by docker-entrypoint.sh, with values +# substituted from environment variables. See README.md for the full list +# of supported variables. +# ============================================================================= + +myhostname = ${MYHOSTNAME} +mydomain = ${MAIL_DOMAIN} +myorigin = $mydomain +mydestination = +inet_interfaces = all +inet_protocols = ipv4 + +# Dynamic relay/transport decisions backed by PostgreSQL lookup maps. +relay_domains = pgsql:/etc/postfix/pgsql-relay-domains.cf +transport_maps = pgsql:/etc/postfix/pgsql-transport-maps.cf + +# This instance only relays mail for the domains returned by relay_domains; +# it does not accept mail for arbitrary destinations. +smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination +mynetworks = 127.0.0.0/8, [::1]/128 + +# TLS +smtpd_tls_cert_file = ${SMTPD_TLS_CERT_FILE} +smtpd_tls_key_file = ${SMTPD_TLS_KEY_FILE} +smtpd_use_tls = yes +smtpd_tls_security_level = may +smtp_tls_security_level = may + +# Log to stdout/stderr so `docker logs` captures Postfix activity directly, +# without needing a syslog daemon inside the container. +maillog_file = /dev/stdout + +biff = no +append_dot_mydomain = no +readme_directory = no +compatibility_level = 3.6 diff --git a/images/postfix-pgsql/templates/pgsql-relay-domains.cf.template b/images/postfix-pgsql/templates/pgsql-relay-domains.cf.template new file mode 100644 index 0000000..c405409 --- /dev/null +++ b/images/postfix-pgsql/templates/pgsql-relay-domains.cf.template @@ -0,0 +1,12 @@ +# ============================================================================= +# Postfix pgsql_table lookup: relay_domains +# +# Returns the domain itself when Postfix should relay mail for it. +# Rendered at container startup from environment variables. +# ============================================================================= + +user = ${POSTGRES_USER} +password = ${POSTGRES_PASSWORD} +hosts = ${POSTGRES_HOST}:${POSTGRES_PORT} +dbname = ${POSTGRES_DB} +query = SELECT domain FROM relay_domain WHERE domain='%s' diff --git a/images/postfix-pgsql/templates/pgsql-transport-maps.cf.template b/images/postfix-pgsql/templates/pgsql-transport-maps.cf.template new file mode 100644 index 0000000..8548159 --- /dev/null +++ b/images/postfix-pgsql/templates/pgsql-transport-maps.cf.template @@ -0,0 +1,15 @@ +# ============================================================================= +# Postfix pgsql_table lookup: transport_maps +# +# Returns the internal SMTP relay target that accepted mail gets forwarded +# to once a recipient domain matches relay_domains. The target itself is +# baked into the query from SMTP_RELAY_HOST/SMTP_RELAY_PORT at startup; this +# image has no further knowledge of what consumes the mail downstream. +# Rendered at container startup from environment variables. +# ============================================================================= + +user = ${POSTGRES_USER} +password = ${POSTGRES_PASSWORD} +hosts = ${POSTGRES_HOST}:${POSTGRES_PORT} +dbname = ${POSTGRES_DB} +query = SELECT 'smtp:[${SMTP_RELAY_HOST}]:${SMTP_RELAY_PORT}' FROM relay_domain WHERE domain='%s' diff --git a/images/postfix-pgsql/tests/setup.md b/images/postfix-pgsql/tests/setup.md new file mode 100644 index 0000000..a5e5497 --- /dev/null +++ b/images/postfix-pgsql/tests/setup.md @@ -0,0 +1,142 @@ +# Local Test Environment Setup + +Step-by-step instructions for standing up a self-contained test environment for +the `postfix-pgsql` image on any Linux host with Docker installed. No cloud +access is required. + +## Architecture + +``` +External SMTP client (e.g. swaks / telnet) + | + | :25 + v + [ postfix-pgsql ] ──pgsql lookup──> [ postgres ] + | + | :2525 (SMTP_RELAY_PORT) + v + [ mailhog ] <── captured mail browsable at :8025 +``` + +MailHog acts as a stand-in for the downstream SMTP consumer and captures every +message delivered to it, making it easy to verify end-to-end relay without +actually sending real mail. + +## 1. Create a dedicated Docker network + +All containers must share the same network so they can resolve each other by +container name (required because chroot is disabled in this image — see the +README). + +```bash +docker network create postfix-test-net +``` + +## 2. Start PostgreSQL with the relay_domain schema + +```bash +docker run -d \ + --name test-postgres \ + --network postfix-test-net \ + -e POSTGRES_USER=relayuser \ + -e POSTGRES_PASSWORD=testpassword \ + -e POSTGRES_DB=relaydb \ + postgres:16-alpine +``` + +Wait for Postgres to be ready, then create the `relay_domain` table and insert +a test domain: + +```bash +docker exec -i test-postgres psql -U relayuser -d relaydb <<'SQL' +CREATE TABLE relay_domain (domain TEXT PRIMARY KEY); +INSERT INTO relay_domain VALUES ('example.com'); +SQL +``` + +Verify: +```bash +docker exec -i test-postgres psql -U relayuser -d relaydb -c "SELECT * FROM relay_domain;" +``` + +## 3. Start MailHog + +MailHog listens on port 2525 for SMTP (matching `SMTP_RELAY_PORT`) and exposes +a web UI on port 8025. + +```bash +docker run -d \ + --name test-mailhog \ + --network postfix-test-net \ + -p 8025:8025 \ + mailhog/mailhog \ + MailHog -smtp-bind-addr 0.0.0.0:2525 +``` + +## 4. Start the postfix-pgsql container + +```bash +docker run -d \ + --name test-postfix \ + --network postfix-test-net \ + -p 25:25 \ + -e MAIL_DOMAIN=example.com \ + -e MYHOSTNAME=mx1.example.com \ + -e POSTGRES_HOST=test-postgres \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=relayuser \ + -e POSTGRES_PASSWORD=testpassword \ + -e POSTGRES_DB=relaydb \ + -e SMTP_RELAY_HOST=test-mailhog \ + -e SMTP_RELAY_PORT=2525 \ + gitea.arnodo.fr/damien/postfix-pgsql:latest +``` + +Confirm it started cleanly: +```bash +docker logs test-postfix +``` + +Expected: Postfix master process line and no errors. + +## 5. Send a test message + +You can use `swaks` (Swiss Army Knife for SMTP) or plain `telnet`: + +```bash +# With swaks (install: apt install swaks / brew install swaks) +swaks --to user@example.com \ + --from sender@external.com \ + --server 127.0.0.1 \ + --port 25 + +# Or with telnet +telnet 127.0.0.1 25 + EHLO test + MAIL FROM: + RCPT TO: + DATA + Subject: Test + . + QUIT +``` + +## 6. View captured mail in MailHog + +**Web UI** — if testing on a remote host, open an SSH tunnel first: +```bash +ssh -L 8025:localhost:8025 user@your-host +``` +Then open `http://localhost:8025` in a browser. + +**HTTP API** (no tunnel needed): +```bash +curl -s http://localhost:8025/api/v2/messages | jq '.items[].Content.Headers.Subject' +``` + +## 7. Cleanup + +```bash +docker rm -f test-postfix test-mailhog test-postgres +docker network rm postfix-test-net +``` diff --git a/images/postfix-pgsql/tests/test-plan.md b/images/postfix-pgsql/tests/test-plan.md new file mode 100644 index 0000000..716da37 --- /dev/null +++ b/images/postfix-pgsql/tests/test-plan.md @@ -0,0 +1,23 @@ +# Test Plan: postfix-pgsql image + +Manual test suite run against the image during initial validation on a Scaleway +test VM. All tests use the local environment described in `setup.md`. + +**Convention for this table:** +- *Command(s)* are abbreviated; see `setup.md` for full environment setup. +- Add new rows at the bottom as the image evolves; do not rewrite existing rows + (failed + fixed tests are part of the history). +- Status: ✅ Pass · ❌ Fail · 🔧 Fixed (failed, root-caused, fix applied) + +| # | Test | What was checked | Command(s) | Expected | Actual | Status | +|---|------|-----------------|------------|----------|--------|--------| +| 1 | Clean startup | No errors in container logs after start; Postfix master process running | `docker run ... gitea.arnodo.fr/damien/postfix-pgsql:latest` then `docker logs test-postfix` | Postfix `daemon started` line, no `fatal`/`panic` entries | Logs show `postfix/master[1]: daemon started -- version 3.7.x`, self-signed cert generation output, no errors | ✅ Pass | +| 2 | Missing required env var | Container exits with a clear error message when a required variable is absent | `docker run ... -e POSTGRES_HOST= ...` (POSTGRES_HOST set to empty) | Non-zero exit, `ERROR: required environment variable POSTGRES_HOST is not set` printed to stderr | Container exited immediately with the expected message | ✅ Pass | +| 3 | Template rendering correctness | Rendered `main.cf` contains substituted values, no leftover `${VAR}` placeholders | `docker exec test-postfix cat /etc/postfix/main.cf` | `myhostname = mx1.example.com`, `mydomain = example.com`, TLS paths resolved, no literal `${...}` strings | All values substituted; Postfix-native `$mydomain` references (no braces) correctly left untouched by envsubst | ✅ Pass | +| 4 | Self-signed TLS cert auto-generated | When no TLS_CERT_FILE/TLS_KEY_FILE are provided, a self-signed cert is generated and persisted | Start container without TLS vars; `docker exec test-postfix ls -l /etc/postfix/ssl/` | Files `snakeoil.pem` and `snakeoil.key` present; key mode 600 | Both files created on first start; `postconf smtpd_tls_cert_file` returned the correct path | ✅ Pass | +| 5 | Port 25 reachable | SMTP port 25 is accessible from outside the container and returns a valid Postfix banner | `telnet 127.0.0.1 25` | `220 mx1.example.com ESMTP Postfix` banner | Banner received as expected | ✅ Pass | +| 6 | Accepted domain → mail relayed | Mail sent to a recipient whose domain is in `relay_domain` is forwarded to the downstream SMTP target (MailHog) | `swaks --to user@example.com --server 127.0.0.1 --port 25`; check MailHog API | `250 2.0.0 Ok: queued` from Postfix; message appears in `GET /api/v2/messages` | Message delivered to MailHog and visible in the web UI | ✅ Pass | +| 7 | Unknown domain → relay denied | Mail sent to a domain not in `relay_domain` is rejected with relay access denied | `swaks --to user@notlisted.org --server 127.0.0.1 --port 25` | `554 5.7.1 Relay access denied` | Postfix returned `554 5.7.1 : Relay access denied` | ✅ Pass | +| 8 | PostgreSQL lookup failure — chroot DNS bug | pgsql-backed `relay_domains`/`transport_maps` lookups work correctly when Postgres is referenced by Docker container name | Send mail to `user@example.com` with `POSTGRES_HOST=test-postgres` (container name, not IP) | `250 Ok: queued`; no `451 Temporary lookup failure` in logs | **Without fix:** intermittent `451 4.3.0 Temporary lookup failure` — chrooted child processes (smtpd, trivial-rewrite) could not resolve `test-postgres` because they lack `/etc/resolv.conf` inside the chroot jail. **Fix applied:** added `postconf -F '*/*/chroot = n'` in `docker-entrypoint.sh` before `postfix start-fg`. After fix: lookups succeed consistently. | 🔧 Fixed | +| 9 | Container restart behavior | Config is re-rendered correctly on restart; pre-existing self-signed cert is reused without re-generation | `docker restart test-postfix`; `docker logs test-postfix` | No re-generation message for TLS cert; `main.cf` and pgsql maps fully substituted; Postfix starts without errors | Cert reuse message absent (new cert would log openssl output); Postfix started cleanly on restart | ✅ Pass | +| 10 | No credential leakage in logs | Postgres password does not appear anywhere in `docker logs` output | `docker logs test-postfix 2>&1 \| grep testpassword` | No output (password not printed) | No lines containing the password; envsubst substitution into config files is silent | ✅ Pass |