diff --git a/mail-infra/helm/smtp-server/.helmignore b/mail-infra/helm/smtp-server/.helmignore new file mode 100644 index 0000000..22432d1 --- /dev/null +++ b/mail-infra/helm/smtp-server/.helmignore @@ -0,0 +1,9 @@ +.DS_Store +.git/ +.gitignore +.idea/ +.vscode/ +*.tmproj +*.swp +*.bak +*.tgz diff --git a/mail-infra/helm/smtp-server/Chart.yaml b/mail-infra/helm/smtp-server/Chart.yaml new file mode 100644 index 0000000..4b19901 --- /dev/null +++ b/mail-infra/helm/smtp-server/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: silver-smtp +description: | + Helm chart for the Postfix-based SMTP server (ghcr.io/lsflk/silver-smtp) + used by the Pingmailer / Silver Mail stack. Renders main.cf / master.cf / + recipient_access from values, wires Postfix to opendkim-server, + raven-sasl, and (optionally) rspamd-server, mounts TLS certs from a + cert-manager Secret, and persists the mail queue on a PVC. +type: application +version: 0.1.0 +appVersion: "main" +keywords: + - postfix + - smtp + - email + - pingmailer +maintainers: + - name: LSFLK + url: https://github.com/lsflk +sources: + - https://github.com/LSFLK/silver diff --git a/mail-infra/helm/smtp-server/README.md b/mail-infra/helm/smtp-server/README.md new file mode 100644 index 0000000..840798a --- /dev/null +++ b/mail-infra/helm/smtp-server/README.md @@ -0,0 +1,155 @@ +# silver-smtp Helm chart + +Helm chart for the Postfix-based SMTP server (`ghcr.io/lsflk/silver-smtp`) — +the Kubernetes counterpart of the `smtp-server` block in this repo's +[docker-compose.yml](../../../docker-compose.yml). + +This is the third of four Pingmailer mail-plane charts. Install order: + +1. [silver-certificates](../certbot-server/) — issues TLS certs via cert-manager. +2. [silver-opendkim](../opendkim-server/) — DKIM milter on `:8891`. +3. [silver-raven-sasl](../raven-sasl-server/) — SASL auth daemon on `:12345`. +4. **silver-smtp** (this chart) — Postfix on `:25` (+ optional submission `:587`). + +## What this chart deploys + +- A `ConfigMap` that templates `main.cf`, `master.cf`, `silver.yaml`, and + `recipient_access` from your values. The docker-compose flow renders + these via a host-side `gen-postfix-conf.sh`; the chart does it in Helm. +- A `Deployment` whose **init container**: + - seeds an `emptyDir` with the image's stock `/etc/postfix/` contents + (`postfix-script`, `postfix-files.d`, `dynamicmaps.cf.d`, …), + - overlays the four rendered configs, + - runs `postmap recipient_access` so Postfix can read the compiled + lookup table (this is the step the docker-compose flow does *after* + container start via `docker exec` — we move it pre-start where it + belongs). +- A main container that runs the stock image entrypoint (`service postfix + start && sleep infinity`). +- A `Service` on port 25 (SMTP). Port 587 (submission, STARTTLS-enforced) + is added when `postfix.submission.enabled=true` (the default). +- A `PersistentVolumeClaim` for `/var/spool/postfix` — the mail queue + must survive pod restarts so in-flight messages aren't dropped. +- An optional TLS `Secret` mount: when `tlsSecret.name` is set, the chart + remaps a cert-manager-managed Secret's `tls.crt` / `tls.key` keys to + `fullchain.pem` / `privkey.pem` and mounts them at + `/etc/letsencrypt/live//` — exactly where Postfix's `main.cf` + expects to find them. + +What this chart **does not** do vs. docker-compose: + +- `/var/log` is not a persistent volume. Logs are redirected to + `/dev/stdout` (`maillog_file = /dev/stdout` in `main.cf`) so + `kubectl logs` works. +- The "shared SQLite database at `/app/data/databases/shared.db`" referenced + in the image's entrypoint is not actually shared between containers in + the original docker-compose either (no volume mount on the `smtp-server` + service), so the chart does not provision one. Mail flow does not depend + on it; the entrypoint simply warns. + +## Install + +Sensitive values stay out of `values.yaml`: + +```yaml +# my-smtp-values.yaml +domain: mail.example.com +tlsSecret: + name: mail-example-com-tls # produced by the silver-certificates chart +``` + +```bash +helm upgrade --install smtp-server ./mail-infra/helm/smtp-server \ + --namespace pingmailer --create-namespace \ + -f my-smtp-values.yaml +``` + +The chart `fail`s with a clear message if `domain` is missing, or if +`postfix.submission.enabled=true` but no `tlsSecret.name` is provided (port +587 enforces STARTTLS — running submission without certs would be broken). + +## Exposing to the internet + +This chart defaults to **`service.type: ClusterIP`** because: + +- LoadBalancers may incur cloud costs / require a provider. +- Mail can be fronted by an external TCP LB or HAProxy you already manage. +- ClusterIP installs cleanly in any cluster without extra requirements. + +> **A Route / Ingress will not work for SMTP.** OpenShift Routes and k8s +> Ingress only carry HTTP/HTTPS/TLS-SNI on 80/443. Submission (587) starts in +> plaintext and upgrades with STARTTLS — there is no SNI for the router to key +> on — so raw 25/587 must be exposed with a `LoadBalancer` or `NodePort`. + +**Option A — LoadBalancer** (real `:25`/`:587` on a dedicated IP; needs a cloud +L4 LB or MetalLB — stays `` on bare clusters with no provider): + +```yaml +service: + type: LoadBalancer + loadBalancerIP: 203.0.113.5 # optional, depends on provider + loadBalancerSourceRanges: [] # optional CIDR allow-list + externalTrafficPolicy: Local # preserve client IP for rate limits + annotations: {} # e.g. GKE / EKS / AKS L4 LB annotations +``` + +**Option B — NodePort** (fallback when no LB provider exists; exposes high +ports on every node IP — have the platform team open the firewall / NAT +`587 -> nodePort`): + +```yaml +service: + type: NodePort + externalTrafficPolicy: Local + nodePorts: + smtp: 30025 + submission: 30587 +``` + +Then publish DNS (point at the LB IP, or a node/edge IP for NodePort): + +``` +mail.example.com. A 203.0.113.5 +example.com. MX 10 mail.example.com. +``` + +Test the external listener: + +```bash +openssl s_client -starttls smtp -connect :587 +``` + +Outbound port 25 is blocked by most cloud providers and ISPs by default — +plan to use a relay (Mailgun, SES, SendGrid) for outbound, or open a +support case to unblock 25. + +## Values + +| Key | Default | Notes | +|----------------------------------------|-------------------------------------------|--------------------------------------------------------| +| `domain` | `""` (**required**) | Primary mail domain | +| `hostname` | `""` → `mail.` | `myhostname` in `main.cf` | +| `tlsSecret.name` | `""` (required when submission enabled) | cert-manager Secret name | +| `tlsSecret.alreadyRenamed` | `false` | `true` if Secret already uses fullchain.pem/privkey.pem | +| `postfix.submission.enabled` | `true` | Enables `:587` STARTTLS submission | +| `postfix.milters` | `["inet:opendkim-server:8891"]` | Inbound smtpd milters | +| `postfix.submissionMilters` | `["inet:opendkim-server:8891"]` | Submission milters (add rspamd here when deployed) | +| `postfix.sasl.path` | `inet:raven-sasl:12345` | Where Postfix reaches the SASL daemon | +| `postfix.tls.securityLevel` | `may` | `may` opportunistic / `encrypt` required | +| `postfix.recipientAccess.rejectAtDomain` | `true` | Mirrors docker-compose: REJECT mail to `@` | +| `postfix.maillogFile` | `/dev/stdout` | Set to `/var/log/mail.log` for legacy file logging | +| `persistence.spool.size` | `1Gi` | Postfix queue | +| `replicaCount` | `1` (**do not change**) | Singleton — PVC is RWO and the queue is single-writer | +| `service.type` | `ClusterIP` | `LoadBalancer` or `NodePort` to expose externally | +| `service.loadBalancerSourceRanges` | `[]` | CIDR allow-list for a LoadBalancer (empty = open) | +| `service.externalTrafficPolicy` | `""` | `Local` preserves client source IP (LB/NodePort) | +| `service.nodePorts.smtp` / `.submission` | `""` | Pin NodePort numbers (30000-32767) when `type=NodePort`| + +## Uninstall + +```bash +helm uninstall smtp-server -n pingmailer +``` + +The spool PVC is preserved (in-flight mail). Delete it explicitly to +discard queued messages. diff --git a/mail-infra/helm/smtp-server/templates/NOTES.txt b/mail-infra/helm/smtp-server/templates/NOTES.txt new file mode 100644 index 0000000..d1ea5e5 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/NOTES.txt @@ -0,0 +1,79 @@ +silver-smtp has been installed. + +Release: {{ .Release.Name }} +Namespace: {{ .Release.Namespace }} +Hostname: {{ include "silver-smtp.hostname" . }} +Domain: {{ .Values.domain }} + +Service: {{ include "silver-smtp.fullname" . }} + SMTP: :{{ .Values.service.smtpPort }} + {{- if .Values.postfix.submission.enabled }} + Submission: :{{ .Values.service.submissionPort }} + {{- end }} + +----- 1. Check the pod is up ----- + + kubectl -n {{ .Release.Namespace }} logs -f deploy/{{ include "silver-smtp.fullname" . }} + kubectl -n {{ .Release.Namespace }} get pod -l app.kubernetes.io/instance={{ .Release.Name }} + +----- 2. Service exposure ----- + +Type: {{ .Values.service.type }} + + NOTE: an OpenShift Route / k8s Ingress cannot expose SMTP — they only carry + HTTP/HTTPS/TLS-SNI on 80/443. External 587 access requires LoadBalancer or + NodePort (below). +{{- if eq .Values.service.type "ClusterIP" }} + + ClusterIP only — reachable from inside the cluster but NOT from the + public internet. To accept external mail, either: + a) Switch service.type to LoadBalancer (if your cloud provides L4 LB), OR + b) Switch service.type to NodePort and open the firewall to a node IP, OR + c) Front this ClusterIP with an external TCP load balancer / firewall NAT. + +{{- else if eq .Values.service.type "LoadBalancer" }} + + Watch the LoadBalancer get its address (stays if the cluster has + no L4 LB provider — fall back to NodePort in that case): + kubectl -n {{ .Release.Namespace }} get svc {{ include "silver-smtp.fullname" . }} -w + + Then publish DNS: + MX {{ .Values.domain }}. 10 {{ include "silver-smtp.hostname" . }}. + A {{ include "silver-smtp.hostname" . }}. + + Test from outside: + openssl s_client -starttls smtp -connect :{{ .Values.service.submissionPort }} + +{{- else if eq .Values.service.type "NodePort" }} + + Find the assigned node ports: + kubectl -n {{ .Release.Namespace }} get svc {{ include "silver-smtp.fullname" . }} \ + -o jsonpath='{range .spec.ports[*]}{.name}={.nodePort}{"\n"}{end}' + + Connect from outside to :. Have your + platform team open the firewall (and optionally NAT 587 -> the nodePort). + Test: + openssl s_client -starttls smtp -connect : +{{- end }} + +----- 3. Dependencies (charts you should already have installed) ----- + + silver-opendkim (Service: opendkim-server:8891) + silver-raven-sasl (Service: raven-sasl:12345) + silver-certificates (TLS Secret: {{ .Values.tlsSecret.name | default "(not set)" }}) + +----- 4. Mail queue ----- + +The Postfix mail queue lives on the PVC `{{ include "silver-smtp.spoolClaim" . }}` +({{ .Values.persistence.spool.size }}). Survives `helm uninstall`; delete +explicitly to discard in-flight mail: + + kubectl -n {{ .Release.Namespace }} delete pvc {{ include "silver-smtp.spoolClaim" . }} + +----- 5. Smoke-test SMTP ----- + + kubectl -n {{ .Release.Namespace }} run -it --rm tmp --image=busybox --restart=Never -- \ + sh -c 'nc -v {{ include "silver-smtp.fullname" . }} {{ .Values.service.smtpPort }} <`. +*/}} +{{- define "silver-smtp.hostname" -}} +{{- if .Values.hostname -}} +{{ .Values.hostname }} +{{- else -}} +mail.{{ required "domain is required" .Values.domain }} +{{- end -}} +{{- end }} + +{{/* +PVC claim name for the postfix spool. +*/}} +{{- define "silver-smtp.spoolClaim" -}} +{{- if .Values.persistence.spool.existingClaim }} +{{- .Values.persistence.spool.existingClaim }} +{{- else }} +{{- printf "%s-spool" (include "silver-smtp.fullname" .) }} +{{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/templates/configmap.yaml b/mail-infra/helm/smtp-server/templates/configmap.yaml new file mode 100644 index 0000000..ce9c640 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/configmap.yaml @@ -0,0 +1,135 @@ +{{- if not .Values.domain }} +{{- fail "domain is required — pass via -f my-values.yaml or --set domain=example.com" }} +{{- end }} +{{- if and .Values.postfix.submission.enabled (not .Values.tlsSecret.name) }} +{{- fail "tlsSecret.name is required when postfix.submission.enabled=true (submission/587 enforces STARTTLS). Point this at a cert-manager Secret produced by the silver-certificates chart." }} +{{- end }} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "silver-smtp.fullname" . }}-conf + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +data: + silver.yaml: | + domains: + - domain: {{ .Values.domain }} + + main.cf: | + # =========================================================== + # main.cf — rendered by the silver-smtp Helm chart + # =========================================================== + + # --- Identity --- + myhostname = {{ include "silver-smtp.hostname" . }} + mydomain = {{ .Values.domain }} + myorigin = $mydomain + mydestination = localhost.localdomain, localhost + mynetworks = {{ join " " .Values.postfix.myNetworks }} + inet_interfaces = all + inet_protocols = ipv4 + + # --- Logging --- + maillog_file = {{ .Values.postfix.maillogFile }} + + # --- TLS --- + smtpd_tls_cert_file = /etc/letsencrypt/live/{{ .Values.domain }}/fullchain.pem + smtpd_tls_key_file = /etc/letsencrypt/live/{{ .Values.domain }}/privkey.pem + smtpd_tls_CAfile = /etc/ssl/certs/ca-certificates.crt + smtpd_tls_security_level = {{ .Values.postfix.tls.securityLevel }} + smtpd_tls_protocols = {{ .Values.postfix.tls.protocols }} + smtpd_tls_mandatory_protocols = {{ .Values.postfix.tls.mandatoryProtocols }} + smtpd_tls_ciphers = {{ .Values.postfix.tls.ciphers }} + smtpd_tls_mandatory_ciphers = {{ .Values.postfix.tls.mandatoryCiphers }} + smtpd_tls_exclude_ciphers = {{ .Values.postfix.tls.excludeCiphers }} + smtpd_tls_loglevel = 1 + smtpd_tls_received_header = yes + + # --- SASL --- + smtpd_sasl_type = {{ .Values.postfix.sasl.type }} + smtpd_sasl_path = {{ .Values.postfix.sasl.path }} + smtpd_sasl_auth_enable = yes + broken_sasl_auth_clients = yes + smtpd_sasl_security_options = noanonymous + + # --- Milters --- + smtpd_milters = {{ join " " .Values.postfix.milters }} + non_smtpd_milters = {{ join " " .Values.postfix.milters }} + milter_protocol = {{ .Values.postfix.milterProtocol }} + milter_default_action = {{ .Values.postfix.milterDefaultAction }} + + # --- Restrictions --- + smtpd_relay_restrictions = + permit_mynetworks + permit_sasl_authenticated + defer_unauth_destination + + smtpd_recipient_restrictions = + permit_sasl_authenticated + {{- if .Values.postfix.recipientAccess.rejectAtDomain }} + check_recipient_access hash:/etc/postfix/recipient_access + {{- end }} + reject + + # --- Rate limits --- + smtpd_client_connection_rate_limit = {{ .Values.postfix.rateLimits.connectionPerClient }} + smtpd_client_message_rate_limit = {{ .Values.postfix.rateLimits.messagePerClient }} + smtpd_client_recipient_rate_limit = {{ .Values.postfix.rateLimits.recipientPerClient }} + smtpd_client_connection_count_limit = {{ .Values.postfix.rateLimits.connectionCount }} + + {{- range .Values.postfix.extraMainCf }} + {{ . }} + {{- end }} + + master.cf: | + # =========================================================== + # master.cf — rendered by the silver-smtp Helm chart + # + # chroot column is "n" for every service (chroot is redundant in a container + # and needs CAP_SYS_CHROOT). The smtp/submission listeners use HIGH ports + # ({{ .Values.postfix.listen.smtpPort }}/{{ .Values.postfix.listen.submissionPort }}) because the patched-Postfix image runs as a + # non-root UID and cannot bind <1024; the Service remaps 25/587 -> these. + # =========================================================== + {{ .Values.postfix.listen.smtpPort }} inet n - n - - smtpd + pickup unix n - n 60 1 pickup + cleanup unix n - n - 0 cleanup + qmgr unix n - n 300 1 qmgr + tlsmgr unix - - n 1000? 1 tlsmgr + rewrite unix - - n - - trivial-rewrite + bounce unix - - n - 0 bounce + defer unix - - n - 0 bounce + trace unix - - n - 0 bounce + verify unix - - n - 1 verify + flush unix n - n 1000? 0 flush + proxymap unix - - n - - proxymap + proxywrite unix - - n - 1 proxymap + smtp unix - - n - - smtp + relay unix - - n - - smtp + showq unix n - n - - showq + error unix - - n - - error + retry unix - - n - - error + discard unix - - n - - discard + local unix - n n - - local + virtual unix - n n - - virtual + lmtp unix - - n - - lmtp + anvil unix - - n - 1 anvil + scache unix - - n - 1 scache + postlog unix-dgram n - n - 1 postlogd + {{- if .Values.postfix.submission.enabled }} + + {{ .Values.postfix.listen.submissionPort }} inet n - n - - smtpd + -o syslog_name=postfix/submission + -o smtpd_tls_security_level=encrypt + -o smtpd_sasl_auth_enable=yes + -o smtpd_tls_auth_only=yes + -o smtpd_relay_restrictions=permit_sasl_authenticated,reject + -o smtpd_milters={{ join "," .Values.postfix.submissionMilters }} + {{- end }} + + recipient_access: | + # Recipient access table — Helm-rendered. + # `postmap` runs in the init container to compile this into recipient_access.db. + {{- if .Values.postfix.recipientAccess.rejectAtDomain }} + @{{ .Values.domain }} REJECT This email address does not accept incoming messages + {{- end }} diff --git a/mail-infra/helm/smtp-server/templates/deployment.yaml b/mail-infra/helm/smtp-server/templates/deployment.yaml new file mode 100644 index 0000000..ae84be9 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/deployment.yaml @@ -0,0 +1,145 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "silver-smtp.fullname" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + strategy: + type: Recreate # Postfix queue PVC is RWO — avoid rolling. + selector: + matchLabels: + {{- include "silver-smtp.selectorLabels" . | nindent 6 }} + template: + metadata: + labels: + {{- include "silver-smtp.selectorLabels" . | nindent 8 }} + annotations: + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "silver-smtp.serviceAccountName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + initContainers: + # Postfix needs /etc/postfix to be WRITABLE because `postmap` compiles + # recipient_access into recipient_access.db next to the source file. + # ConfigMap subPath mounts are read-only, so we: + # 1. Seed an emptyDir with the image's stock /etc/postfix contents + # (postfix-script, postfix-files.d, dynamicmaps.cf.d, …). + # 2. Overlay our four rendered configs. + # 3. Run `postmap` to build the lookup table. + - name: prepare-postfix + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + command: + - /bin/sh + - -c + - | + set -eu + # cp -rL (NOT -a): the pod runs as an arbitrary non-root UID and + # cannot preserve the image's root ownership/timestamps. + cp -rL /etc/postfix/. /shared-postfix/ + cp /configs/main.cf /shared-postfix/main.cf + cp /configs/master.cf /shared-postfix/master.cf + cp /configs/silver.yaml /shared-postfix/silver.yaml + cp /configs/recipient_access /shared-postfix/recipient_access + postmap /shared-postfix/recipient_access + echo "✓ /etc/postfix prepared, recipient_access compiled." + volumeMounts: + - name: postfix-etc + mountPath: /shared-postfix + - name: configs + mountPath: /configs + readOnly: true + containers: + - name: smtp + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: smtp + containerPort: {{ .Values.postfix.listen.smtpPort }} + protocol: TCP + {{- if .Values.postfix.submission.enabled }} + - name: submission + containerPort: {{ .Values.postfix.listen.submissionPort }} + protocol: TCP + {{- end }} + volumeMounts: + - name: postfix-etc + mountPath: /etc/postfix + - name: spool + mountPath: /var/spool/postfix + {{- if .Values.tlsSecret.name }} + - name: tls + mountPath: /etc/letsencrypt/live/{{ .Values.domain }} + readOnly: true + {{- end }} + readinessProbe: + tcpSocket: + port: smtp + initialDelaySeconds: 10 + periodSeconds: 10 + livenessProbe: + tcpSocket: + port: smtp + initialDelaySeconds: 30 + periodSeconds: 30 + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: configs + configMap: + name: {{ include "silver-smtp.fullname" . }}-conf + - name: postfix-etc + emptyDir: {} + - name: spool + {{- if .Values.persistence.spool.enabled }} + persistentVolumeClaim: + claimName: {{ include "silver-smtp.spoolClaim" . }} + {{- else }} + emptyDir: {} + {{- end }} + {{- if .Values.tlsSecret.name }} + - name: tls + secret: + secretName: {{ .Values.tlsSecret.name }} + {{- if not .Values.tlsSecret.alreadyRenamed }} + items: + - key: tls.crt + path: fullchain.pem + - key: tls.key + path: privkey.pem + {{- end }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/mail-infra/helm/smtp-server/templates/pvc.yaml b/mail-infra/helm/smtp-server/templates/pvc.yaml new file mode 100644 index 0000000..f58e16f --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/pvc.yaml @@ -0,0 +1,17 @@ +{{- if and .Values.persistence.spool.enabled (not .Values.persistence.spool.existingClaim) -}} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "silver-smtp.spoolClaim" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} +spec: + accessModes: + {{- toYaml .Values.persistence.spool.accessModes | nindent 4 }} + resources: + requests: + storage: {{ .Values.persistence.spool.size }} + {{- if .Values.persistence.spool.storageClass }} + storageClassName: {{ .Values.persistence.spool.storageClass | quote }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/templates/service.yaml b/mail-infra/helm/smtp-server/templates/service.yaml new file mode 100644 index 0000000..453aa68 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/service.yaml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "silver-smtp.fullname" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- if and (eq .Values.service.type "LoadBalancer") .Values.service.loadBalancerIP }} + loadBalancerIP: {{ .Values.service.loadBalancerIP }} + {{- end }} + {{- with .Values.service.loadBalancerSourceRanges }} + loadBalancerSourceRanges: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- if and (or (eq .Values.service.type "LoadBalancer") (eq .Values.service.type "NodePort")) .Values.service.externalTrafficPolicy }} + externalTrafficPolicy: {{ .Values.service.externalTrafficPolicy }} + {{- end }} + ports: + - port: {{ .Values.service.smtpPort }} + targetPort: smtp + protocol: TCP + name: smtp + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePorts.smtp }} + nodePort: {{ .Values.service.nodePorts.smtp }} + {{- end }} + {{- if .Values.postfix.submission.enabled }} + - port: {{ .Values.service.submissionPort }} + targetPort: submission + protocol: TCP + name: submission + {{- if and (eq .Values.service.type "NodePort") .Values.service.nodePorts.submission }} + nodePort: {{ .Values.service.nodePorts.submission }} + {{- end }} + {{- end }} + selector: + {{- include "silver-smtp.selectorLabels" . | nindent 4 }} diff --git a/mail-infra/helm/smtp-server/templates/serviceaccount.yaml b/mail-infra/helm/smtp-server/templates/serviceaccount.yaml new file mode 100644 index 0000000..5973533 --- /dev/null +++ b/mail-infra/helm/smtp-server/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "silver-smtp.serviceAccountName" . }} + labels: + {{- include "silver-smtp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/mail-infra/helm/smtp-server/values.yaml b/mail-infra/helm/smtp-server/values.yaml new file mode 100644 index 0000000..309ccb2 --- /dev/null +++ b/mail-infra/helm/smtp-server/values.yaml @@ -0,0 +1,182 @@ +# --------------------------------------------------------------------------- +# silver-smtp values +# +# Sensitive values (mail domain, TLS Secret name) are intentionally empty +# here. Supply them at install/upgrade time: +# +# helm upgrade --install smtp-server ./mail-infra/helm/smtp-server \ +# --namespace pingmailer --create-namespace \ +# -f my-smtp-values.yaml +# --------------------------------------------------------------------------- + +nameOverride: "" +# Keep the Service DNS short so other services (and external MX) can address +# this as `smtp-server:25`. +fullnameOverride: "smtp-server" + +# Postfix queue lives on a single RWO PVC — do not scale. +replicaCount: 1 + +image: + # Patched-Postfix (rootless) image built from mail-infra/images/silver-smtp-rootless. + # The postfix/master/postsuper getuid()==0 checks are removed and the + # set_eugid/set_ugid/chroot_uid privilege-drops are no-op'd for non-root, so + # Postfix runs single-UID as the arbitrary non-root UID OpenShift assigns + # under restricted-v2 — no custom SCC / anyuid grant required. + repository: ghcr.io/silver-mail-platform/silver-smtp + tag: rootless + pullPolicy: Always + +imagePullSecrets: [] + +# Primary mail domain. REQUIRED — chart fail()s if empty. +domain: "" + +# Postfix `myhostname` (default: `mail.`). +hostname: "" + +# --------------------------------------------------------------------------- +# Postfix configuration knobs (rendered into main.cf / master.cf). +# --------------------------------------------------------------------------- +postfix: + # Ports Postfix LISTENS on inside the container. The patched-Postfix image + # runs as a non-root UID and cannot bind <1024, so it listens on high ports + # and the Service remaps 25/587 -> these (service.smtpPort/submissionPort + # stay 25/587 for callers). On a root-capable platform set these to 25/587. + listen: + smtpPort: 10025 + submissionPort: 10587 + + # SMTP submission port 587 with enforced STARTTLS + SASL. + submission: + enabled: true + + # Networks allowed to relay without SASL. + myNetworks: + - "127.0.0.0/8" + + # Milters wired to the inbound smtpd (port 25). Address the other charts + # by their Service DNS — defaults match the rest of this repo's charts. + milters: + - "inet:opendkim-server:8891" + + # Milters wired to submission (port 587). Includes rspamd if you deploy it. + submissionMilters: + - "inet:opendkim-server:8891" + # - "inet:rspamd-server:11332" # uncomment once rspamd is deployed + + milterProtocol: 6 + milterDefaultAction: accept + + # SASL — points at silver-raven-sasl. + sasl: + type: dovecot + path: "inet:raven-sasl:12345" + + # TLS knobs (paths are derived from the mounted TLS Secret). + tls: + securityLevel: may # `may` = opportunistic; `encrypt` = required + protocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" + mandatoryProtocols: "!SSLv2 !SSLv3 !TLSv1 !TLSv1.1" + ciphers: high + mandatoryCiphers: high + excludeCiphers: "aNULL, DES, 3DES, MD5, DES+MD5, RC4" + + # Per-client rate limits (per Postfix smtpd_client_*). + rateLimits: + connectionPerClient: 10 + messagePerClient: 100 + recipientPerClient: 200 + connectionCount: 20 + + # Recipient access — rejects mail addressed to `@` (this domain + # only sends; it does not receive). Mirrors the docker-compose behaviour. + # Set to false to omit the REJECT rule entirely. + recipientAccess: + rejectAtDomain: true + + # Redirect mail logs to stdout so `kubectl logs` works. + maillogFile: "/dev/stdout" + + # Extra raw lines appended to main.cf. Use sparingly. + extraMainCf: [] + # - "myorigin = $mydomain" + +# --------------------------------------------------------------------------- +# TLS certificate Secret (typically produced by the silver-certificates +# chart with keys `tls.crt` + `tls.key`). The chart remaps them to +# fullchain.pem + privkey.pem under /etc/letsencrypt/live// to +# match the paths Postfix expects. +# --------------------------------------------------------------------------- +tlsSecret: + name: "" + alreadyRenamed: false + +service: + # How clients reach SMTP/submission. NOTE: an OpenShift Route / Kubernetes + # Ingress CANNOT carry SMTP — they only route HTTP/HTTPS/TLS-SNI on 80/443, + # and submission (587) begins in plaintext with STARTTLS (no SNI to route on). + # So to accept mail from OUTSIDE the cluster you must use one of: + # - LoadBalancer : real :25/:587 on a dedicated external IP (needs a cloud + # L4 LB or MetalLB; stays on bare clusters). + # - NodePort : exposes high ports (30000-32767) on every node IP; pin + # them via service.nodePorts below and have the platform + # team open the firewall / NAT 587 -> the nodePort. + # - ClusterIP : in-cluster only (default; safe to install anywhere). + type: ClusterIP + loadBalancerIP: "" + # Restrict who may reach a LoadBalancer (CIDRs). Empty = open to all. + loadBalancerSourceRanges: [] + # "Local" preserves the real client source IP (so Postfix smtpd_client_* + # rate limits and SASL logging see the sender, not a node/SNAT address). + # Applies to LoadBalancer and NodePort only. "" leaves the cluster default. + externalTrafficPolicy: "" + # Pin the NodePort numbers when service.type=NodePort (else they're random). + # Must fall within the cluster's node-port range (default 30000-32767). + nodePorts: + smtp: "" + submission: "" + annotations: {} + smtpPort: 25 + submissionPort: 587 + +# --------------------------------------------------------------------------- +# Postfix mail queue. Persist this — losing it means dropping in-flight mail. +# --------------------------------------------------------------------------- +persistence: + spool: + enabled: true + existingClaim: "" + storageClass: "" + accessModes: + - ReadWriteOnce + size: 1Gi + +# The patched-Postfix image runs single-UID as the arbitrary non-root UID that +# OpenShift assigns (restricted-v2). Nothing is pinned here so the SCC assigns +# the UID/fsGroup from the namespace range; chroot is disabled in master.cf. +podSecurityContext: {} +securityContext: + # Single-UID Postfix never setuids, so no privilege escalation is needed — + # keep it false to stay compatible with the restricted-v2 SCC. + allowPrivilegeEscalation: false + +# Required: the nsw-infra-* tenant-quota rejects any container without CPU/memory +# requests + limits. Applied to BOTH the init and main containers. +resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +nodeSelector: {} +tolerations: [] +affinity: {} +podAnnotations: {} + +serviceAccount: + create: true + annotations: {} + name: "" diff --git a/mail-infra/images/silver-smtp-rootless/Dockerfile b/mail-infra/images/silver-smtp-rootless/Dockerfile new file mode 100644 index 0000000..5e7648e --- /dev/null +++ b/mail-infra/images/silver-smtp-rootless/Dockerfile @@ -0,0 +1,109 @@ +# ============================================================================= +# silver-smtp (rootless / patched Postfix) for OpenShift restricted-v2. +# +# WHY: OpenShift policy here forbids running as root, but stock Postfix's +# `postfix`, `master`, and `postsuper` binaries hard-abort unless getuid()==0 +# (verified in Postfix 3.7 source: postfix.c, master.c, postsuper.c). We +# rebuild Debian's exact Postfix package with those THREE checks neutralized +# so the binaries run as an arbitrary non-root UID. getuid() itself is left +# honest, so master.c's `set_ugid(getuid(), getgid())` keeps the real UID and +# Postfix runs single-UID (no privilege separation, no setuid attempts). +# +# Everything else (config, sqlite/TLS/pcre maps, dynamicmaps) comes from the +# unmodified Debian package build, so module parity with the stock image holds. +# ============================================================================= + +# ---------------------------------------------------------------------------- +# Stage 1 — rebuild Postfix from Debian source with the 3 root checks removed +# ---------------------------------------------------------------------------- +FROM debian:bookworm AS builder +ENV DEBIAN_FRONTEND=noninteractive + +# Enable deb-src so `apt-get source` works (bookworm uses the deb822 format). +RUN sed -i 's/^Types: deb$/Types: deb deb-src/' /etc/apt/sources.list.d/debian.sources && \ + apt-get update && \ + apt-get install -y --no-install-recommends dpkg-dev devscripts && \ + apt-get build-dep -y postfix + +WORKDIR /build +RUN apt-get source postfix + +# Neutralize the three getuid() superuser guards. getuid() is left intact +# everywhere else (notably master.c's set_ugid call). +RUN set -eux; \ + src="$(find . -maxdepth 1 -type d -name 'postfix-*')"; \ + cd "$src"; \ + # --- (1) Root-requirement CHECKS: neutralize the getuid()==0 guards --- + # master.c : if (getuid() != 0) msg_fatal("the master command is reserved...") + sed -i 's/if (getuid() != 0)/if (0)/' src/master/master.c; \ + # postfix.c : if (getuid() != 0) { ... msg_fatal("the postfix command is reserved...") } + sed -i 's/if (getuid() != 0)/if (0)/' src/postfix/postfix.c; \ + # postsuper.c : if (getuid()) msg_fatal("use of this command is reserved...") + sed -i 's/if (getuid())/if (0)/' src/postsuper/postsuper.c; \ + grep -q 'if (0)' src/master/master.c; \ + grep -q 'if (0)' src/postfix/postfix.c; \ + grep -q 'if (0)' src/postsuper/postsuper.c; \ + # --- (2) Privilege-SWITCHING: set_eugid()/set_ugid() begin with seteuid(0) + # to regain root before switching users. A non-root process can't do that + # and aborts. We run single-UID (== mail_owner), so no switch is needed: + # make both no-op when the real UID is already non-root. --- + # Insert an early-return guard just before each function's existing + # `if (geteuid() != 0)` line (unique per file, indented with tabs). This + # makes the function a no-op when the real UID is non-root, BEFORE any + # seteuid/setgid/setuid is attempted. + for f in set_eugid set_ugid; do \ + sed -i 's@^\([[:space:]]*\)if (geteuid() != 0)@\1if (getuid() != 0)\n\1 return;\n\1if (geteuid() != 0)@' "src/util/$f.c"; \ + grep -q 'if (getuid() != 0)' "src/util/$f.c"; \ + done; \ + # --- (3) Daemon privilege-drop: chroot_uid() does a direct setgid/setuid to + # the mail user when spawning each daemon. Guard both with getuid()==0 so a + # non-root single-UID run skips them (chroot itself is untouched; root_dir is + # null because every master.cf service has chroot=n). --- + sed -i 's/if (user_name != 0)/if (user_name != 0 \&\& getuid() == 0)/' src/util/chroot_uid.c; \ + test "$(grep -c 'getuid() == 0' src/util/chroot_uid.c)" = "2"; \ + dpkg-buildpackage -us -uc -b + +# ---------------------------------------------------------------------------- +# Stage 2 — slim runtime, mirrors the stock silver-smtp image +# ---------------------------------------------------------------------------- +FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive + +COPY --from=builder /build/postfix_*.deb /build/postfix-sqlite_*.deb /tmp/debs/ + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates \ + gnupg \ + wget \ + sqlite3 \ + libsqlite3-0 \ + mailutils \ + iputils-ping \ + /tmp/debs/postfix_*.deb \ + /tmp/debs/postfix-sqlite_*.deb && \ + if ! grep -q "^sqlite" /etc/postfix/dynamicmaps.cf; then \ + echo "sqlite postfix-sqlite.so dict_sqlite_open" >> /etc/postfix/dynamicmaps.cf; \ + fi && \ + wget -O /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64" && \ + chmod +x /usr/local/bin/yq && \ + rm -rf /tmp/debs && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /app/data /etc/postfix/sqlite /var/spool/postfix /var/lib/postfix + +COPY ./entrypoint.sh /entrypoint.sh +RUN chmod 0755 /entrypoint.sh + +# OpenShift arbitrary-UID support: the pod runs as a random non-root UID with +# GID 0. Group-own everything Postfix reads/writes by GID 0 with group perms +# mirroring owner, and make /etc/passwd + /etc/group writable so the entrypoint +# can register the runtime UID as Postfix's mail_owner. +RUN chgrp -R 0 /etc/postfix /var/spool/postfix /var/lib/postfix /app /etc/passwd /etc/group && \ + chmod -R g=u /etc/postfix /var/spool/postfix /var/lib/postfix /app && \ + chmod g=u /etc/passwd /etc/group && \ + touch /etc/mailname && chgrp 0 /etc/mailname && chmod g=u /etc/mailname + +WORKDIR /app +ENTRYPOINT ["/entrypoint.sh"] diff --git a/mail-infra/images/silver-smtp-rootless/entrypoint.sh b/mail-infra/images/silver-smtp-rootless/entrypoint.sh new file mode 100755 index 0000000..eab63cc --- /dev/null +++ b/mail-infra/images/silver-smtp-rootless/entrypoint.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +CONFIG_FILE="/etc/postfix/silver.yaml" + +# --- domain from silver.yaml ------------------------------------------------- +MAIL_DOMAIN=$(awk '/^[[:space:]]*-[[:space:]]*domain:/ {sub(/^[[:space:]]*-[[:space:]]*domain:[[:space:]]*/, ""); print; exit}' "$CONFIG_FILE") +if [ -z "$MAIL_DOMAIN" ] || [ "$MAIL_DOMAIN" = "null" ]; then + echo "⚠️ Could not extract domain from $CONFIG_FILE; using example.org" + MAIL_DOMAIN="example.org" +fi +export MAIL_DOMAIN +MAIL_HOSTNAME=${MAIL_HOSTNAME:-mail.$MAIL_DOMAIN} +echo "Using domain: $MAIL_DOMAIN" +echo "$MAIL_DOMAIN" > /etc/mailname 2>/dev/null || true + +# --- single-UID rootless setup ---------------------------------------------- +# OpenShift runs us as a random non-root UID (GID 0) with no /etc/passwd entry. +# Postfix's mail_owner must resolve to a real user whose UID == our UID, and +# whose primary group has no OTHER members (Postfix rejects shared groups). +# We register both in the (group-writable) /etc/passwd + /etc/group. +UID0="$(id -u)" +OWNER_GID=$((UID0 % 60000 + 2000)) # a deterministic, almost-certainly-unique gid +if ! getent group "$OWNER_GID" >/dev/null 2>&1; then + echo "postfixown:x:${OWNER_GID}:" >> /etc/group +fi +if ! getent passwd "$UID0" >/dev/null 2>&1; then + echo "postfixown:x:${UID0}:${OWNER_GID}:Postfix owner:/var/spool/postfix:/usr/sbin/nologin" >> /etc/passwd +fi +RUN_USER="$(getent passwd "$UID0" | cut -d: -f1)" +echo "Running as uid=$UID0 user=$RUN_USER gid=$(id -g) groups=$(id -G)" + +# mail_owner = our UID (its private group has no other members, which Postfix +# requires). setgid_group = the package's dedicated `postdrop` group: Postfix +# rejects a privileged (low) GID like root/0 here. The setgid maildrop/postdrop +# path is unused (mail arrives via smtpd on the network), so we don't need to +# actually belong to it — it only has to pass Postfix's startup validation. +postconf -e "myhostname = ${MAIL_HOSTNAME}" +postconf -e "mail_owner = ${RUN_USER}" +postconf -e "setgid_group = postdrop" +postconf -e "queue_directory = /var/spool/postfix" +postconf -e "data_directory = /var/lib/postfix" + +# Recreate the queue tree (the PVC mounts empty over /var/spool/postfix). +for d in active bounce corrupt defer deferred flush hold incoming saved trace \ + private public maildrop pid; do + mkdir -p "/var/spool/postfix/$d" +done + +# set-permissions now works: chown targets are us / a group we're in. Errors on +# the setgid postdrop/postqueue binaries (need real root) are non-fatal — the +# maildrop/local-submission path is unused; mail arrives via smtpd on :25. +postfix set-permissions 2>&1 || true +postfix check 2>&1 || true + +echo "=== Starting Postfix (foreground, rootless single-UID, no chroot) ===" +exec postfix start-fg