feat(smtp-relay): initial custom postfix+sasl relay image
Postfix relay image with Cyrus SASL (sasldb2) authentication. Replaces mwader/postfix-relay with a controlled image built via Kaniko and stored in Harbor. Credentials injected from Vault ExternalSecret at startup.
This commit is contained in:
commit
9e21e1e669
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
FROM debian:12-slim
|
||||||
|
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
postfix \
|
||||||
|
ca-certificates \
|
||||||
|
sasl2-bin \
|
||||||
|
libsasl2-modules \
|
||||||
|
netcat-openbsd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& (getent group postdrop || groupadd -r postdrop) \
|
||||||
|
&& usermod -aG postdrop postfix
|
||||||
|
|
||||||
|
COPY main.cf /etc/postfix/main.cf
|
||||||
|
COPY master.cf /etc/postfix/master.cf
|
||||||
|
COPY smtpd.conf /etc/postfix/sasl/smtpd.conf
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 25 587
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
63
README.md
Normal file
63
README.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# smtp-relay
|
||||||
|
|
||||||
|
Postfix SMTP relay with Cyrus SASL authentication and TLS.
|
||||||
|
|
||||||
|
Used by Mailu (personal + Solidaria NGO) on valhalla to route outbound mail through
|
||||||
|
hermes, which has a trusted residential IP accepted by Gmail and Hotmail.
|
||||||
|
|
||||||
|
Image: `harbor.manabo.org/library/smtp-relay`
|
||||||
|
Deployed on: hermes (`clusters/hermes/smtp-relay/` in asgard)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./build.sh 1.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Packages the Dockerfile context, uploads to MinIO, runs Kaniko in-cluster on valhalla,
|
||||||
|
and pushes the resulting image to Harbor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Vault secrets (`app/smtp-relay/smtp-relay-sasl`)
|
||||||
|
|
||||||
|
| Key | Description |
|
||||||
|
|-----|-------------|
|
||||||
|
| `relay_user` | SASL username (e.g. `relayuser`) |
|
||||||
|
| `relay_pass` | SASL password (plaintext — stored in Vault) |
|
||||||
|
| `relay_domain` | SASL domain (e.g. `manabo.org`) |
|
||||||
|
|
||||||
|
### TLS (`certs/smtp-relay-tls`)
|
||||||
|
|
||||||
|
Wildcard cert for `relay.manabo.org` — pushed to Vault via PushSecret on valhalla.
|
||||||
|
|
||||||
|
### Env vars (from ExternalSecret)
|
||||||
|
|
||||||
|
| Var | Source |
|
||||||
|
|-----|--------|
|
||||||
|
| `RELAY_AUTH_USER` | `relay_user` |
|
||||||
|
| `RELAY_AUTH_PASS` | `relay_pass` |
|
||||||
|
| `RELAY_AUTH_DOMAIN` | `relay_domain` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
- Listens on ports **25** (SMTP, TLS optional) and **587** (submission, TLS required)
|
||||||
|
- Uses `hostNetwork: true` — ports exposed directly on the hermes host IP
|
||||||
|
- Entrypoint creates a `sasldb2` user from the env vars on every start
|
||||||
|
- Only clients authenticated via SASL can relay mail
|
||||||
|
- TLS cert mounted from Vault ExternalSecret
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **sasldb2 recreated on every restart**: credentials are read from env vars and
|
||||||
|
`saslpasswd2` re-creates the sasldb. This is intentional (stateless SASL).
|
||||||
|
- **No DKIM**: DKIM signing is not implemented in this image. Relay delivers mail
|
||||||
|
as-is; DKIM signatures must be added by the sending MTA (Mailu).
|
||||||
103
build.sh
Normal file
103
build.sh
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# build.sh [tag]
|
||||||
|
# Packages the smtp-relay context, uploads to MinIO, runs Kaniko in-cluster, waits.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TAG="${1:-latest}"
|
||||||
|
HARBOR="harbor.manabo.org"
|
||||||
|
IMAGE="${HARBOR}/library/smtp-relay:${TAG}"
|
||||||
|
BUCKET="kaniko-builds"
|
||||||
|
CONTEXT_KEY="smtp-relay/context.tar.gz"
|
||||||
|
|
||||||
|
echo "==> Building ${IMAGE}"
|
||||||
|
|
||||||
|
echo "==> Packaging context ..."
|
||||||
|
tar -czf /tmp/kaniko-context.tar.gz \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='build.sh' \
|
||||||
|
--exclude='k8s' \
|
||||||
|
-C "$(dirname "$0")" .
|
||||||
|
echo "==> Uploading to MinIO (${BUCKET}/${CONTEXT_KEY}) ..."
|
||||||
|
mc cp /tmp/kaniko-context.tar.gz "minio/${BUCKET}/${CONTEXT_KEY}"
|
||||||
|
rm /tmp/kaniko-context.tar.gz
|
||||||
|
|
||||||
|
JOB_NAME="kaniko-smtp-relay-$(date +%s)"
|
||||||
|
echo "==> Launching Kaniko job: ${JOB_NAME}"
|
||||||
|
|
||||||
|
cat <<EOF | kubectl apply -f -
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ${JOB_NAME}
|
||||||
|
namespace: kaniko
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
ttlSecondsAfterFinished: 300
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: harbor-pull-secret
|
||||||
|
initContainers:
|
||||||
|
- name: fetch-context
|
||||||
|
image: harbor.manabo.org/library/minio/mc:RELEASE.2025-08-13T08-35-41Z
|
||||||
|
command: ["/bin/sh", "-c"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
mc alias set minio \$MINIO_ENDPOINT \$MC_ACCESS_KEY \$MC_SECRET_KEY --api S3v4 &&
|
||||||
|
mc cp minio/${BUCKET}/${CONTEXT_KEY} /context/context.tar.gz
|
||||||
|
env:
|
||||||
|
- name: MC_ACCESS_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: minio-kaniko-creds
|
||||||
|
key: access-key
|
||||||
|
- name: MC_SECRET_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: minio-kaniko-creds
|
||||||
|
key: secret-key
|
||||||
|
- name: MINIO_ENDPOINT
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: minio-kaniko-creds
|
||||||
|
key: endpoint
|
||||||
|
volumeMounts:
|
||||||
|
- name: context
|
||||||
|
mountPath: /context
|
||||||
|
containers:
|
||||||
|
- name: kaniko
|
||||||
|
image: harbor.manabo.org/gcr/kaniko-project/executor:v1.23.2
|
||||||
|
args:
|
||||||
|
- "--context=tar:///context/context.tar.gz"
|
||||||
|
- "--destination=${IMAGE}"
|
||||||
|
- "--snapshot-mode=redo"
|
||||||
|
- "--log-format=text"
|
||||||
|
volumeMounts:
|
||||||
|
- name: context
|
||||||
|
mountPath: /context
|
||||||
|
- name: docker-config
|
||||||
|
mountPath: /kaniko/.docker/
|
||||||
|
volumes:
|
||||||
|
- name: context
|
||||||
|
emptyDir: {}
|
||||||
|
- name: docker-config
|
||||||
|
secret:
|
||||||
|
secretName: harbor-push-config
|
||||||
|
items:
|
||||||
|
- key: .dockerconfigjson
|
||||||
|
path: config.json
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "==> Waiting for build (timeout 10m) ..."
|
||||||
|
kubectl wait "job/${JOB_NAME}" -n kaniko \
|
||||||
|
--for=condition=complete \
|
||||||
|
--timeout=600s || {
|
||||||
|
echo "==> Build FAILED. Logs:"
|
||||||
|
POD=$(kubectl get pods -n kaniko -l "job-name=${JOB_NAME}" -o name | head -1)
|
||||||
|
kubectl logs -n kaniko "$POD" --all-containers
|
||||||
|
kubectl delete "job/${JOB_NAME}" -n kaniko --ignore-not-found
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "==> Done: ${IMAGE}"
|
||||||
34
entrypoint.sh
Normal file
34
entrypoint.sh
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Required (from ExternalSecret)
|
||||||
|
: "${RELAY_AUTH_USER:?Missing RELAY_AUTH_USER}"
|
||||||
|
: "${RELAY_AUTH_PASS:?Missing RELAY_AUTH_PASS}"
|
||||||
|
: "${RELAY_AUTH_DOMAIN:?Missing RELAY_AUTH_DOMAIN}"
|
||||||
|
|
||||||
|
# Optional hostname override
|
||||||
|
if [ -n "${POSTFIX_MYHOSTNAME:-}" ]; then
|
||||||
|
echo "${POSTFIX_MYHOSTNAME}" > /etc/mailname
|
||||||
|
postconf -e "myhostname=${POSTFIX_MYHOSTNAME}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create SASL user in sasldb2
|
||||||
|
echo "${RELAY_AUTH_PASS}" | saslpasswd2 -p -c -u "${RELAY_AUTH_DOMAIN}" "${RELAY_AUTH_USER}"
|
||||||
|
|
||||||
|
chown root:sasl /etc/sasldb2 2>/dev/null || true
|
||||||
|
chmod 640 /etc/sasldb2 2>/dev/null || true
|
||||||
|
|
||||||
|
# Postfix spool directories (required inside container)
|
||||||
|
mkdir -p /var/spool/postfix /var/lib/postfix
|
||||||
|
chown root:root /var/spool/postfix
|
||||||
|
chmod 755 /var/spool/postfix
|
||||||
|
|
||||||
|
mkdir -p /var/spool/postfix/public /var/spool/postfix/maildrop
|
||||||
|
chown root:postdrop /var/spool/postfix/public /var/spool/postfix/maildrop
|
||||||
|
chmod 1730 /var/spool/postfix/public /var/spool/postfix/maildrop
|
||||||
|
|
||||||
|
mkdir -p /var/spool/postfix/pid
|
||||||
|
chown root:root /var/spool/postfix/pid
|
||||||
|
chmod 755 /var/spool/postfix/pid
|
||||||
|
|
||||||
|
exec postfix start-fg
|
||||||
27
main.cf
Normal file
27
main.cf
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
myhostname = relay.manabo.org
|
||||||
|
myorigin = $myhostname
|
||||||
|
mydestination =
|
||||||
|
local_transport = error:local delivery disabled
|
||||||
|
inet_interfaces = all
|
||||||
|
inet_protocols = all
|
||||||
|
mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128
|
||||||
|
|
||||||
|
# SASL authentication (Cyrus SASL / sasldb2)
|
||||||
|
smtpd_sasl_auth_enable = yes
|
||||||
|
smtpd_sasl_type = cyrus
|
||||||
|
smtpd_sasl_path = smtpd
|
||||||
|
smtpd_sasl_security_options = noanonymous
|
||||||
|
smtpd_sasl_local_domain = $myhostname
|
||||||
|
cyrus_sasl_config_path = /etc/postfix/sasl
|
||||||
|
|
||||||
|
# Only accept mail from authenticated clients
|
||||||
|
smtpd_relay_restrictions = permit_sasl_authenticated, reject
|
||||||
|
smtpd_recipient_restrictions = permit_sasl_authenticated, reject_unauth_destination
|
||||||
|
|
||||||
|
# TLS (cert mounted from ExternalSecret)
|
||||||
|
smtpd_tls_cert_file = /etc/postfix/tls/tls.crt
|
||||||
|
smtpd_tls_key_file = /etc/postfix/tls/tls.key
|
||||||
|
smtpd_tls_security_level = may
|
||||||
|
smtpd_tls_auth_only = yes
|
||||||
|
smtpd_tls_loglevel = 1
|
||||||
|
smtp_tls_security_level = may
|
||||||
36
master.cf
Normal file
36
master.cf
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Postfix master process configuration
|
||||||
|
# service type private unpriv chroot wakeup maxproc command + args
|
||||||
|
|
||||||
|
# Port 25 — SMTP (TLS optional, SASL required to relay)
|
||||||
|
smtp inet n - y - - smtpd
|
||||||
|
|
||||||
|
# Port 587 — Submission (TLS required, SASL required)
|
||||||
|
submission inet n - y - - smtpd
|
||||||
|
-o smtpd_tls_security_level=encrypt
|
||||||
|
-o smtpd_tls_auth_only=yes
|
||||||
|
-o smtpd_sasl_auth_enable=yes
|
||||||
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
||||||
|
-o smtpd_tls_loglevel=1
|
||||||
|
|
||||||
|
pickup unix n - y 60 1 pickup
|
||||||
|
cleanup unix n - y - 0 cleanup
|
||||||
|
qmgr unix n - n 300 1 qmgr
|
||||||
|
rewrite unix - - y - - trivial-rewrite
|
||||||
|
bounce unix - - y - 0 bounce
|
||||||
|
defer unix - - y - 0 bounce
|
||||||
|
trace unix - - y - 0 bounce
|
||||||
|
verify unix - - y - 1 verify
|
||||||
|
flush unix n - y 1000? 0 flush
|
||||||
|
proxymap unix - - n - - proxymap
|
||||||
|
proxywrite unix - - n - 1 proxymap
|
||||||
|
smtp unix - - y - - smtp
|
||||||
|
relay unix - - y - - smtp
|
||||||
|
showq unix n - y - - showq
|
||||||
|
error unix - - y - - error
|
||||||
|
retry unix - - y - - error
|
||||||
|
discard unix - - y - - discard
|
||||||
|
local unix - n n - - local
|
||||||
|
virtual unix - n n - - virtual
|
||||||
|
lmtp unix - - y - - lmtp
|
||||||
|
anvil unix - - y - 1 anvil
|
||||||
|
scache unix - - y - 1 scache
|
||||||
3
smtpd.conf
Normal file
3
smtpd.conf
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
pwcheck_method: auxprop
|
||||||
|
auxprop_plugin: sasldb
|
||||||
|
mech_list: PLAIN LOGIN
|
||||||
Loading…
Reference in New Issue
Block a user