diff --git a/.github/workflows/docker-release.yaml b/.github/workflows/docker-release.yaml new file mode 100644 index 0000000..d15208f --- /dev/null +++ b/.github/workflows/docker-release.yaml @@ -0,0 +1,159 @@ +name: Publish container images + +# Builds every image this repo owns and pushes it to GHCR on release, tagged +# with both `latest` and the release version. +# +# Release flow: +# git tag v0.2.0 && git push origin v0.2.0 +# ...then publish a GitHub Release for that tag (or use workflow_dispatch). +# +# raven-sasl is deliberately absent: it is built in a different repository +# (ghcr.io/lsflk/raven-sasl) and is only consumed here. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version tag to publish (e.g. 0.2.0)" + required: true + type: string + # Build-only check on PRs that touch image sources. Never pushes. + pull_request: + paths: + - "api-server/**" + - "mail-infra/images/**" + - ".github/workflows/docker-release.yaml" + +env: + REGISTRY: ghcr.io + +jobs: + build: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + strategy: + fail-fast: false + matrix: + include: + - name: pingmailer-api-server + context: ./api-server + dockerfile: ./api-server/Dockerfile + # Static Go binary — cross-compiles cheaply, so build wide. + platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/ppc64le,linux/s390x + - name: silver-dkim + context: ./mail-infra/images/silver-dkim + dockerfile: ./mail-infra/images/silver-dkim/Dockerfile + platforms: linux/amd64,linux/arm64 + - name: silver-smtp + context: ./mail-infra/images/silver-smtp-rootless + dockerfile: ./mail-infra/images/silver-smtp-rootless/Dockerfile + # This image rebuilds Postfix from source; every extra platform is + # a full compile under QEMU emulation. Keep the list tight. + platforms: linux/amd64,linux/arm64 + # The smtp-server chart defaults to `tag: rootless`, so keep that + # tag moving or a chart install would pin an ageing image. + extra_tags: rootless + + name: ${{ matrix.name }} + steps: + - uses: actions/checkout@v4 + + - name: Resolve version + id: v + run: | + case "${{ github.event_name }}" in + release) version="${GITHUB_REF_NAME#v}" ;; + workflow_dispatch) version="${{ inputs.version }}" ;; + *) version="pr-${{ github.event.number }}" ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Publishing version: $version" + + - name: Build tag list + id: tags + run: | + # GHCR paths must be lowercase; the org name may not be. + owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + image="${{ env.REGISTRY }}/$owner/${{ matrix.name }}" + v="${{ steps.v.outputs.version }}" + + # Always brace-delimit before a literal ':' — bare "$image:latest" is + # a lowercase modifier in some shells and silently mangles the tag. + tags="${image}:${v}" + # `latest` only ever moves on a real release — never from a PR or a + # manual dispatch, so a dispatch can't silently redirect consumers. + if [ "${{ github.event_name }}" = "release" ]; then + tags="${tags},${image}:latest" + for t in $(echo "${{ matrix.extra_tags }}" | tr ',' ' '); do + [ -n "$t" ] && tags="${tags},${image}:${t}" + done + fi + + echo "image=$image" >> "$GITHUB_OUTPUT" + echo "tags=$tags" >> "$GITHUB_OUTPUT" + echo "Tags: $tags" + + # QEMU lets one amd64 runner emit every listed architecture. + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: ${{ matrix.platforms }} + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.v.outputs.version }} + provenance: false # keeps the index free of non-platform entries + cache-from: type=gha,scope=${{ matrix.name }} + cache-to: type=gha,mode=max,scope=${{ matrix.name }} + + # Guards a real regression: a tag was once published arm64-only, and the + # cluster failed the pull with "no image found in image index for + # architecture amd64". Fail here rather than at deploy time. + - name: Verify every requested platform is in the manifest + if: github.event_name != 'pull_request' + run: | + ref="${{ steps.tags.outputs.image }}:${{ steps.v.outputs.version }}" # literal, not shell-expanded + echo "Inspecting $ref" + manifest=$(docker buildx imagetools inspect "$ref" --raw) + missing=0 + for p in $(echo "${{ matrix.platforms }}" | tr ',' ' '); do + os=${p%%/*}; rest=${p#*/}; arch=${rest%%/*} + if echo "$manifest" | grep -q "\"architecture\":\"$arch\""; then + echo " ok $p" + else + echo " MISSING $p"; missing=1 + fi + done + [ "$missing" -eq 0 ] || { echo "::error::$ref is missing platforms."; exit 1; } + + - name: Summary + if: github.event_name != 'pull_request' + run: | + { + echo "### \`${{ matrix.name }}\` ${{ steps.v.outputs.version }}" + echo + echo "Platforms: \`${{ matrix.platforms }}\`" + echo + echo "Tags pushed:" + echo "${{ steps.tags.outputs.tags }}" | tr ',' '\n' | sed 's/^/- `/; s/$/`/' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/helm-release.yaml b/.github/workflows/helm-release.yaml new file mode 100644 index 0000000..82bc27f --- /dev/null +++ b/.github/workflows/helm-release.yaml @@ -0,0 +1,131 @@ +name: Publish Helm chart + +# Packages the pingmailer umbrella chart and pushes it to GHCR as an OCI +# artifact, using the same release that publishes the container images. +# +# Release flow: +# git tag v0.2.0 && git push origin v0.2.0 +# ...then publish a GitHub Release for that tag (or use workflow_dispatch). +# +# The chart is versioned from the release tag rather than from Chart.yaml, so +# the chart, its appVersion and the image tags always agree for a given +# release. Chart.yaml's own version is the development placeholder. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 0.2.0)" + required: true + type: string + # Lint and package on PRs touching the chart. Never pushes. + pull_request: + paths: + - "helm/**" + - ".github/workflows/helm-release.yaml" + +env: + REGISTRY: ghcr.io + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: azure/setup-helm@v4 + with: + version: v3.19.4 + + - name: Resolve version + id: v + run: | + case "${{ github.event_name }}" in + release) version="${GITHUB_REF_NAME#v}" ;; + workflow_dispatch) version="${{ inputs.version }}" ;; + *) version="0.0.0-pr.${{ github.event.number }}" ;; + esac + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Publishing chart version: $version" + + - name: Lint + run: | + helm lint ./helm -f helm/values.example.yaml + # Each subchart must also stand alone — it can be installed by itself. + for c in helm/charts/*/; do helm lint "$c"; done + + - name: Render smoke test + run: | + # The example values must produce a complete, parseable manifest. + helm template pingmailer ./helm -f helm/values.example.yaml > /tmp/rendered.yaml + count=$(grep -c '^kind:' /tmp/rendered.yaml) + echo "Rendered $count resources" + if [ "$count" -lt 15 ]; then + echo "::error::Only $count resources rendered — expected the full stack." + exit 1 + fi + # The required-value guards must still fire when values are absent, + # otherwise a misconfigured install would fail in-cluster instead of + # at render time. + if helm template pingmailer ./helm >/dev/null 2>&1; then + echo "::error::Chart rendered with no values — required-value guards are broken." + exit 1 + fi + echo "Required-value guards fire as expected" + + - name: Package + run: | + v="${{ steps.v.outputs.version }}" + helm package ./helm -d dist --version "$v" --app-version "$v" + ls -lh dist/ + + - name: Log in to GHCR + if: github.event_name != 'pull_request' + run: | + echo "${{ secrets.GITHUB_TOKEN }}" \ + | helm registry login "${{ env.REGISTRY }}" \ + --username "${{ github.actor }}" --password-stdin + + - name: Push chart + if: github.event_name != 'pull_request' + run: | + # Charts go in their own namespace so they don't collide with the + # image packages in the same org. + owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + helm push "dist/pingmailer-${{ steps.v.outputs.version }}.tgz" \ + "oci://${{ env.REGISTRY }}/${owner}/charts" + + - name: Summary + run: | + owner=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + v="${{ steps.v.outputs.version }}" + { + echo "### Helm chart \`pingmailer\` $v" + echo + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "Dry run — lint, render and package passed. Nothing pushed." + else + echo "Published to \`oci://${{ env.REGISTRY }}/${owner}/charts/pingmailer\`" + echo + echo '```bash' + echo "helm install pingmailer \\" + echo " oci://${{ env.REGISTRY }}/${owner}/charts/pingmailer \\" + echo " --version $v -n -f my-values.yaml" + echo '```' + echo + echo "> New GHCR packages are **private** by default. Make the package" + echo "> public under Packages → pingmailer → Package settings, or" + echo "> consumers must \`helm registry login ghcr.io\` first." + fi + } >> "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@v4 + with: + name: pingmailer-chart-${{ steps.v.outputs.version }} + path: dist/*.tgz diff --git a/.gitignore b/.gitignore index a6d7ecd..80cc35c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ temp/ +my-values.yaml +my-*-values.yaml diff --git a/helm/.helmignore b/helm/.helmignore new file mode 100644 index 0000000..2dc95db --- /dev/null +++ b/helm/.helmignore @@ -0,0 +1,12 @@ +.DS_Store +.git/ +.gitignore +*.tmproj +.idea/ +.vscode/ +*.swp +*.bak +*.tgz +# Never package a filled-in values file. +my-values.yaml +my-*.yaml diff --git a/helm/Chart.yaml b/helm/Chart.yaml new file mode 100644 index 0000000..f795ad2 --- /dev/null +++ b/helm/Chart.yaml @@ -0,0 +1,55 @@ +apiVersion: v2 +name: pingmailer +description: | + Umbrella Helm chart for the Pingmailer / Silver Mail stack. Installs the + four services that make up a working outbound mail platform as a single + release: + + * api-server — Go HTTP relay (POST /notify) on :8000 + * opendkim-server — OpenDKIM milter on :8891, keys persisted on a PVC + * raven-sasl-server — Dovecot-compatible SASL/OAUTHBEARER daemon on :12345 + * smtp-server — Postfix (rootless) on :25 / :587 + + Each service remains a self-contained subchart under charts/ and can still be + installed on its own. Every subchart is disabled/enabled independently via + `.enabled` so you can roll out the stack piecemeal. + + TLS certificates are NOT part of this chart — deploy the certbot-server chart + (mail-infra/helm/certbot-server) or cert-manager first and point + `smtp-server.tlsSecret.name` / `raven-sasl-server.tlsSecret.name` at the + resulting Secret. +type: application +version: 0.1.0 +appVersion: "0.1.0" +keywords: + - pingmailer + - silver + - mail + - smtp + - postfix + - opendkim + - sasl + - kubernetes + - openshift +maintainers: + - name: LSFLK + url: https://github.com/lsflk +sources: + - https://github.com/LSFLK/silver +dependencies: + - name: api-server + version: 0.1.0 + repository: "" + condition: api-server.enabled + - name: opendkim-server + version: 0.1.0 + repository: "" + condition: opendkim-server.enabled + - name: raven-sasl-server + version: 0.1.0 + repository: "" + condition: raven-sasl-server.enabled + - name: smtp-server + version: 0.1.0 + repository: "" + condition: smtp-server.enabled diff --git a/helm/README.md b/helm/README.md new file mode 100644 index 0000000..3f4c417 --- /dev/null +++ b/helm/README.md @@ -0,0 +1,528 @@ +# Pingmailer — Helm deployment guide + +`helm/` is a single umbrella chart that installs the whole outbound-mail stack +as one release. Each service is still a self-contained subchart under +`charts/`, so you can install the stack as a unit or roll out one service at a +time. + +``` +helm/ +├── Chart.yaml # umbrella chart, declares the 4 subcharts +├── values.yaml # defaults + the values you MUST fill in +├── values.example.yaml # a complete, realistic install to copy from +├── templates/NOTES.txt # post-install instructions +└── charts/ + ├── api-server/ # Go HTTP relay :8000 + ├── opendkim-server/ # OpenDKIM milter :8891 + ├── raven-sasl-server/ # SASL / OAUTHBEARER :12345 (Service: raven-sasl) + └── smtp-server/ # Postfix (rootless) :25 / :587 +``` + +## How the pieces fit together + +``` + external caller + │ HTTPS (Ingress on Kubernetes, Route on OpenShift) + ▼ + api-server :8000 ──── SMTP+XOAUTH2 ────┐ + ▼ + smtp-server :587 / :25 + │ + SASL auth ───────────┼─────────── DKIM signing + │ │ + ▼ ▼ + raven-sasl :12345 opendkim-server :8891 + │ + ▼ + thunder-server :8090 (not in this chart) +``` + +Postfix addresses the other services by fixed Service DNS names — +`inet:opendkim-server:8891` and `inet:raven-sasl:12345`. Those names are pinned +via `fullnameOverride` in each subchart, so **install all components into the +same namespace** and don't rename them unless you update +`smtp-server.postfix.milters` and `smtp-server.postfix.sasl.path` to match. + +## Installing from the chart registry + +Released versions are published to GHCR as OCI artifacts, so you can install +without cloning this repo: + +```bash +helm show values oci://ghcr.io/silver-mail-platform/charts/pingmailer --version > my-values.yaml +# edit my-values.yaml, then: +helm install pingmailer oci://ghcr.io/silver-mail-platform/charts/pingmailer \ + --version -n -f my-values.yaml +``` + +`helm search` does not work against OCI registries. List available versions with: + +```bash +helm show chart oci://ghcr.io/silver-mail-platform/charts/pingmailer --version +``` + +If the package is private, authenticate first with a token that has +`read:packages`: + +```bash +echo $GITHUB_TOKEN | helm registry login ghcr.io -u --password-stdin +``` + +All four subcharts are vendored into the package, so there are no dependencies +to pull. Everything below applies equally to a registry install and a local +`./helm` install — substitute the OCI URL for the path. + +## Prerequisites + +| Requirement | Notes | +|---|---| +| Helm 3.8+ and `kubectl` | `helm version` | +| A namespace you can deploy into | any namespace you can create workloads in | +| A TLS Secret for the mail domain | keys `tls.crt` + `tls.key`. Deploy `mail-infra/helm/certbot-server` first, or use cert-manager. Required by `smtp-server` while submission/587 is enabled. | +| Thunder auth server reachable in-cluster | `raven-sasl-server.config.authServerUrl` defaults to `https://thunder-server:8090/...`. Not deployed by this chart. | +| A registry the cluster can pull from | images default to `ghcr.io/silver-mail-platform/*` and `ghcr.io/lsflk/*`. Add `imagePullSecrets` per subchart for a private registry. | +| Two RWO PersistentVolumes | 256Mi for DKIM keys, 1Gi for the Postfix mail queue. | + +TLS certificates are deliberately **not** part of this chart — certificate +issuance has its own lifecycle. Deploy `certbot-server` (still under +`mail-infra/helm/certbot-server`) or cert-manager separately, then point +`smtp-server.tlsSecret.name` and `raven-sasl-server.tlsSecret.name` at the +Secret it produces. + +## Deploy + +### 1. Create your values file + +Never edit `values.yaml` in-tree — it holds defaults and no secrets. Copy the +example and fill it in: + +```bash +cp helm/values.example.yaml my-values.yaml # add my-values.yaml to .gitignore +``` + +Replace every `example.com` and `your-mail-app-client-id` placeholder. These +values are **required** — the charts fail at render time (with a readable +message) if any is missing: + +| Value | What it is | +|---|---| +| `smtp-server.domain` | primary mail domain, e.g. `example.com` | +| `smtp-server.tlsSecret.name` | Secret with `tls.crt` / `tls.key` | +| `opendkim-server.domains[]` | one entry per signing domain (`domain`, optional `selector`, `keySize`) | +| `raven-sasl-server.config.domain` | same mail domain | +| `raven-sasl-server.config.oauth.audience[]` | OAuth client IDs whose tokens are accepted | +| `raven-sasl-server.oauthEmailAuthorization` | client_id → addresses that client may send as | + +There is intentionally no `global.domain`: each subchart owns its domain field +so it stays installable on its own. Set the same domain in all three places — +they are marked `### MAIL DOMAIN` in `values.yaml`. + +`raven-sasl-server.oauthEmailAuthorization` is sensitive. Keep it in your +uncommitted values file, or supply it separately with a second `-f`. + +### 2. Preview + +```bash +helm lint ./helm -f my-values.yaml +helm template pingmailer ./helm -n -f my-values.yaml | less +``` + +### 3. Install + +```bash +helm upgrade --install pingmailer ./helm \ + --namespace --create-namespace \ + -f my-values.yaml +``` + +See [OpenShift deployment](#openshift-deployment) below for the OpenShift form +of this command and the four constraints that namespace enforces. + +### 4. Verify + +```bash +kubectl -n get pods,svc,pvc -l app.kubernetes.io/instance=pingmailer +kubectl -n rollout status deploy/smtp-server +kubectl -n logs deploy/smtp-server -f +``` + +### 5. Publish DNS + +OpenDKIM generates the keypair on first start, so this can only happen after +the install. Read the public record out of the pod: + +```bash +kubectl -n exec deploy/opendkim-server -- \ + cat /etc/dkimkeys/example.com/mail.txt +``` + +Then publish: + +| Record | Value | +|---|---| +| `mail._domainkey.example.com` TXT | the key material printed above | +| `example.com` TXT | `v=spf1 mx a ~all` (adjust to your senders) | +| `_dmarc.example.com` TXT | `v=DMARC1; p=quarantine; rua=mailto:...` | +| `example.com` MX | `10 mail.example.com.` (only if this host receives mail) | + +Mail signed before the DKIM TXT record propagates will fail verification at the +recipient. + +## OpenShift deployment + +Everything below is a real constraint an OpenShift tenant namespace enforces — +each one was hit during an actual install. Substitute your own namespace, +cluster, and domain for the placeholders. + +### Log in and select the project + +```bash +oc login --server=https://:6443 +oc project +oc whoami --show-context # confirm before touching a prod namespace +``` + +### Install + +```bash +helm upgrade --install pingmailer ./helm \ + --namespace \ + -f my-values.yaml +``` + +### The four constraints + +**1. Route instead of Ingress.** There is typically no default Ingress +controller, so the api-server must publish a Route: + +```yaml +api-server: + ingress: + enabled: false + route: + enabled: true + host: "" # OpenShift generates -.apps. + tls: + termination: edge # router terminates TLS, plain HTTP to the pod + insecureEdgeTerminationPolicy: Redirect +``` + +**2. `restricted-v2` assigns the UID — never pin one.** The namespace only +permits UIDs from its own allocated range. A chart that pins `runAsUser` is +rejected at *ReplicaSet* level, so you get **no pod at all** and nothing useful +from `get events` for a pod that never existed. Look at the ReplicaSet: + +```bash +oc -n describe rs -l app.kubernetes.io/name=raven-sasl-server +# Error creating: ... is forbidden: unable to validate against any security +# context constraint: ... runAsUser: Invalid value: 1001: must be in the +# ranges: [] +``` + +`raven-sasl-server` is the one subchart that pins `1001` by default (its image +declares `ravenuser`). Clear it — and note that **`podSecurityContext: {}` does +not work**: Helm coalesces an empty map into the subchart's populated map, so +the `1001`s survive. Each key must be nulled individually: + +```yaml +raven-sasl-server: + podSecurityContext: + runAsNonRoot: true + runAsUser: null + runAsGroup: null + fsGroup: null +``` + +Confirm before installing — the rendered pod `securityContext` should carry no +UID: + +```bash +helm template pingmailer ./helm -f my-values.yaml \ + | awk '/name: raven-sasl$/,0' | grep -A6 'securityContext:' +``` + +The other three subcharts already leave the UID unset and need no override. + +**3. The tenant quota rejects containers without resources.** Every container +needs CPU *and* memory requests *and* limits. This includes throwaway debug +pods, which is easy to forget: + +```bash +# fails: must specify limits.cpu, limits.memory, requests.cpu, requests.memory +oc -n run probe --image=curlimages/curl --restart=Never -- curl -s http://api-server:8000/healthcheck + +# works +oc -n run probe --restart=Never --image=curlimages/curl \ + --overrides='{"spec":{"containers":[{"name":"probe","image":"curlimages/curl", + "command":["curl","-s","http://api-server:8000/healthcheck"], + "resources":{"requests":{"cpu":"50m","memory":"64Mi"}, + "limits":{"cpu":"200m","memory":"128Mi"}}}]}}' +``` + +A related trap: debug pods run as an arbitrary UID, so `apk add` fails with +`ERROR: Unable to open log: Permission denied`. Pick an image that already has +what you need instead of installing at runtime. + +**4. SMTP needs a NodePort — a Route cannot carry it.** Routes handle +HTTP/HTTPS/TLS-SNI on 80/443 only, and submission starts in plaintext before +STARTTLS, so there is no SNI to route on: + +```yaml +smtp-server: + service: + type: NodePort + smtpPort: 25 # what callers use + submissionPort: 587 + nodePorts: + smtp: 30025 # what the node actually listens on + submission: 30587 + externalTrafficPolicy: Local # preserve the real client IP +``` + +Ask the platform team to NAT `587 -> 30587` for external senders. + +**Bonus trap: the api-server needs a `hostAlias` to reach SMTP.** Callers pass +`smtp_host` in the `/notify` body and the api-server verifies the SMTP TLS +certificate against that name. The Service name `smtp-server` is not on the +cert, and the public `mail.` is unreachable from inside the cluster, so +map the cert-matching name to the SMTP ClusterIP: + +```yaml +api-server: + hostAliases: + - ip: + hostnames: [mail.example.com] +``` + +Callers then use `smtp_host: mail.example.com`. Without this, every send fails +while `/notify` still returns 202. Re-read the IP if the Service is recreated: + +```bash +oc -n get svc smtp-server -o jsonpath='{.spec.clusterIP}' +``` + +### Verify on OpenShift + +```bash +oc -n get pods,svc,route,pvc -l app.kubernetes.io/instance=pingmailer +HOST=$(oc -n get route api-server -o jsonpath='{.spec.host}') +curl -sk -o /dev/null -w 'HTTP %{http_code}\n' https://$HOST/healthcheck +``` + +Postfix logs should show raven answering the SASL handshake: + +```bash +oc -n logs deploy/raven-sasl --tail=20 +# SASL sent: MECH OAUTHBEARER plaintext +# SASL sent: MECH XOAUTH2 plaintext +``` + +### Image gotchas + +Tags get re-pushed, and not every tag is multi-arch. Check before pinning — +observed on the api-server repository: + +- `latest` — amd64, serves plain HTTP, correct for an edge-terminated Route. +- `0.1.0` — exits with `ERROR: HTTPS is required. Set CERT_FILE and KEY_FILE.` + The chart mounts no cert volume and hardcodes `scheme: HTTP` on both probes, + so this tag needs chart changes plus a `reencrypt` Route. +- `0.1.1` — arm64 only; the pull fails with + `no image found in image index for architecture "amd64"`. + +Leaving `image.tag: ""` resolves to the chart's `appVersion`, which may not be +the tag you want. Set it explicitly, or pin a digest. + +## Testing mail delivery in-cluster + +The end-to-end path is: obtain an OAuth token from Thunder, then authenticate to +Postfix submission with XOAUTH2 as an address the client is authorized to send +as. + +### 1. Get a token + +```bash +TOKEN=$(curl -sk -X POST https:///oauth2/token \ + -u ":" \ + -d grant_type=client_credentials | jq -r .access_token) +``` + +Thunder rejects credentials sent in the POST body (`unauthorized_client: Client +is not allowed to use the specified authentication method`) — they must go in +the HTTP Basic header, i.e. `curl -u`. Decode the token and check `aud` matches +`raven-sasl-server.config.oauth.audience` and `iss` matches +`config.oauth.issuerUrl`. + +### 2. Connect using a hostname the certificate covers + +The TLS Secret is issued for your mail domain and its wildcard, so connecting to +the Kubernetes Service name `smtp-server` fails verification: + +``` +tls: failed to verify certificate: x509: certificate is valid for +*.example.com, example.com, not smtp-server +``` + +Connecting to the public `mail.` from inside the cluster can fail too — +a cluster generally cannot hairpin out to its own public IP +(`dial tcp :587: i/o timeout`). So map the cert-matching name to the +ClusterIP with a `hostAlias`: + +```bash +SMTP_IP=$(oc -n get svc smtp-server -o jsonpath='{.spec.clusterIP}') +``` + +```yaml +spec: + hostAliases: + - ip: + hostnames: [mail.example.com] +``` + +### 3. Send + +Use a `python:3.12-alpine` pod — stdlib `smtplib` needs no package install, +which matters because `apk` cannot write under the assigned UID: + +```python +import smtplib, ssl, base64, os +from email.message import EmailMessage +tok, user, to = os.environ["TOK"], "contact@example.com", "you@elsewhere.test" +m = EmailMessage(); m["From"], m["To"] = user, to +m["Subject"] = "Pingmailer smoke test" +m.set_content("test") +s = smtplib.SMTP("mail.example.com", 587, timeout=40) +s.ehlo(); s.starttls(context=ssl.create_default_context()); s.ehlo() +xo = base64.b64encode(f"user={user}\x01auth=Bearer {tok}\x01\x01".encode()).decode() +print(s.docmd("AUTH", "XOAUTH2 " + xo)) # expect (235, b'2.7.0 Authentication successful') +s.send_message(m); s.quit() +``` + +Remember the pod needs `resources` and the `hostAliases` from step 2. + +### 3b. Or send through the api-server + +This exercises the whole chain the way real callers do. It requires +`api-server.hostAliases` to be set (see the OpenShift section above): + +```bash +curl -sk -X POST https://$HOST/notify \ + -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \ + -d '{"smtp_host":"mail.example.com","smtp_port":587, + "smtp_username":"contact@example.com","smtp_sender":"contact@example.com", + "recipient_name":"Test","recipient_email":"you@elsewhere.test", + "app_name":"smoke test"}' +``` + +### 4. Confirm it actually left + +A `250 ... queued as ` only means Postfix accepted it. Follow the queue ID +to a terminal status: + +```bash +oc -n logs deploy/smtp-server -c smtp --tail=200 | grep -E '|status=' +# : client=..., sasl_method=XOAUTH2, sasl_username=contact@example.com +# : to=<...>, relay=...:25, dsn=2.0.0, status=sent (250 2.0.0 OK ...) +oc -n exec deploy/smtp-server -c smtp -- postqueue -p # "Mail queue is empty" +``` + +A `452 ... first encounter` from the receiving MX on the first attempt is normal +greylisting — Postfix retries and the retry succeeds. + +Note that the api-server's `POST /notify` returns **`202 Email queued +successfully` even when the SMTP send subsequently fails**. Never treat that 202 +as proof of delivery; check the api-server log for the real outcome: + +```bash +oc -n logs deploy/api-server --tail=50 | grep -i "failed to send" +``` + +OpenDKIM logs signing to syslog inside its own container, so per-message +signature lines do **not** appear in `oc logs deploy/opendkim-server`. Absence of +a `milter-reject` in the Postfix log means the milter accepted the message; to +actually confirm a signature, inspect the `DKIM-Signature` header on a received +message. + +## Installing a subset + +Every subchart has an `enabled` flag, so you can roll out incrementally: + +```bash +# infrastructure first +helm upgrade --install pingmailer ./helm -n -f my-values.yaml \ + --set smtp-server.enabled=false --set api-server.enabled=false + +# then the rest +helm upgrade --install pingmailer ./helm -n -f my-values.yaml +``` + +A subchart can also be installed entirely on its own — it keeps its own +`values.yaml` and README: + +```bash +helm upgrade --install smtp-server ./helm/charts/smtp-server \ + -n -f my-smtp-values.yaml +``` + +Note the flattening: standalone, the keys are top-level (`domain: example.com`); +under the umbrella they are nested (`smtp-server.domain`). Each subchart's own +`values.yaml` documents every option in full — this README only covers the ones +you must set. + +## Exposing SMTP outside the cluster + +An Ingress or an OpenShift Route **cannot** carry SMTP. They route HTTP/HTTPS +and TLS-SNI on 80/443 only, and submission (587) starts in plaintext and +upgrades via STARTTLS, so there is no SNI to route on. Pick one: + +| `smtp-server.service.type` | Behaviour | +|---|---| +| `ClusterIP` (default) | in-cluster only; safe to install anywhere | +| `LoadBalancer` | real `:25` / `:587` on an external IP; needs a cloud L4 LB or MetalLB, otherwise stays `` | +| `NodePort` | high ports (30000–32767) on every node IP; pin them with `service.nodePorts` and ask the platform team to NAT 587 → the nodePort | + +For `LoadBalancer` / `NodePort`, also set +`smtp-server.service.externalTrafficPolicy: Local` so Postfix sees the real +client IP — otherwise the per-client rate limits and SASL logs all see a single +SNAT address. + +The api-server is different: it is plain HTTP and is meant to sit behind the +Ingress or Route, which terminates TLS for it. + +## Upgrading and rolling back + +```bash +helm upgrade pingmailer ./helm -n -f my-values.yaml +helm history pingmailer -n +helm rollback pingmailer -n +``` + +Scaling constraints: `opendkim-server` and `smtp-server` must stay at +`replicaCount: 1` — both write to a single RWO PVC (the DKIM keystore and the +Postfix queue). Only `api-server` is stateless and safe to scale. + +## Uninstalling + +```bash +helm uninstall pingmailer -n +``` + +PVCs are **not** removed by `helm uninstall`. That is deliberate — deleting +them destroys your DKIM private keys (every previously signed message stops +verifying, and you must republish a new DNS record) and any mail still sitting +in the Postfix queue. Back both up before removing them: + +```bash +kubectl -n get pvc opendkim-server-keys smtp-server-spool +``` + +## Troubleshooting + +| Symptom | Cause / fix | +|---|---| +| `Error: ... domain is required` at install | a required value is unset — see the table in step 1 | +| Postfix logs `milter-reject 4.7.1` | opendkim can't read its key. Confirm `opendkim-server` is Running and `opendkim.requireSafeKeys: "no"` is still set (needed because the PVC is group-writable). | +| SASL auth fails on 587 | `raven-sasl` pod down, or the sender's address is missing from `oauthEmailAuthorization` for that client ID | +| Pod stuck `CreateContainerConfigError` | the TLS Secret named in `tlsSecret.name` doesn't exist in the namespace yet | +| PVC stuck `Pending` | no default StorageClass — set `persistence.storageClass` explicitly | +| Pod rejected by the quota | a container lacks CPU/memory requests+limits; every `resources:` block in your values file needs both | +| Recipients mark mail as spam | DKIM/SPF/DMARC not published or not propagated — recheck step 5 | diff --git a/mail-infra/helm/api-server/.helmignore b/helm/charts/api-server/.helmignore similarity index 100% rename from mail-infra/helm/api-server/.helmignore rename to helm/charts/api-server/.helmignore diff --git a/mail-infra/helm/api-server/Chart.yaml b/helm/charts/api-server/Chart.yaml similarity index 96% rename from mail-infra/helm/api-server/Chart.yaml rename to helm/charts/api-server/Chart.yaml index fbe1861..af8a568 100644 --- a/mail-infra/helm/api-server/Chart.yaml +++ b/helm/charts/api-server/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: silver-api-server +name: api-server description: | Portable Helm chart for the Pingmailer api-server (Go service that accepts `POST /notify` and relays the request to SMTP using the caller's Bearer token diff --git a/mail-infra/helm/api-server/README.md b/helm/charts/api-server/README.md similarity index 98% rename from mail-infra/helm/api-server/README.md rename to helm/charts/api-server/README.md index c8d9936..cd9447d 100644 --- a/mail-infra/helm/api-server/README.md +++ b/helm/charts/api-server/README.md @@ -92,7 +92,7 @@ oauth2IntrospectUrl: "https://thunder.example.com/oauth2/introspect" ``` ```bash -helm upgrade --install api-server ./mail-infra/helm/api-server \ +helm upgrade --install api-server ./helm/charts/api-server \ --namespace pingmailer --create-namespace \ -f my-api-values.yaml ``` @@ -121,7 +121,7 @@ oauth2IntrospectUrl: "https://thunder.example.com/oauth2/introspect" ``` ```bash -helm upgrade --install api-server ./mail-infra/helm/api-server \ +helm upgrade --install api-server ./helm/charts/api-server \ --namespace pingmailer --create-namespace \ -f my-api-values-openshift.yaml ``` diff --git a/mail-infra/helm/api-server/templates/NOTES.txt b/helm/charts/api-server/templates/NOTES.txt similarity index 96% rename from mail-infra/helm/api-server/templates/NOTES.txt rename to helm/charts/api-server/templates/NOTES.txt index 509401b..31879b8 100644 --- a/mail-infra/helm/api-server/templates/NOTES.txt +++ b/helm/charts/api-server/templates/NOTES.txt @@ -68,5 +68,5 @@ Service: {{ include "silver-api-server.fullname" . }}:{{ .Values.service.port ----- 4. Upgrade ----- - helm upgrade --install {{ .Release.Name }} ./mail-infra/helm/api-server \ + helm upgrade --install {{ .Release.Name }} ./helm/charts/api-server \ --namespace {{ .Release.Namespace }} -f my-api-values.yaml diff --git a/mail-infra/helm/api-server/templates/_helpers.tpl b/helm/charts/api-server/templates/_helpers.tpl similarity index 100% rename from mail-infra/helm/api-server/templates/_helpers.tpl rename to helm/charts/api-server/templates/_helpers.tpl diff --git a/mail-infra/helm/api-server/templates/deployment.yaml b/helm/charts/api-server/templates/deployment.yaml similarity index 92% rename from mail-infra/helm/api-server/templates/deployment.yaml rename to helm/charts/api-server/templates/deployment.yaml index 2c06594..520c30c 100644 --- a/mail-infra/helm/api-server/templates/deployment.yaml +++ b/helm/charts/api-server/templates/deployment.yaml @@ -30,6 +30,12 @@ spec: securityContext: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.hostAliases }} + # Lets the api-server reach the SMTP Service under a name the mail TLS + # certificate actually covers. See values.yaml for why this is needed. + hostAliases: + {{- toYaml . | nindent 8 }} + {{- end }} containers: - name: api-server image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" diff --git a/mail-infra/helm/api-server/templates/ingress.yaml b/helm/charts/api-server/templates/ingress.yaml similarity index 100% rename from mail-infra/helm/api-server/templates/ingress.yaml rename to helm/charts/api-server/templates/ingress.yaml diff --git a/mail-infra/helm/api-server/templates/pdb.yaml b/helm/charts/api-server/templates/pdb.yaml similarity index 100% rename from mail-infra/helm/api-server/templates/pdb.yaml rename to helm/charts/api-server/templates/pdb.yaml diff --git a/mail-infra/helm/api-server/templates/route.yaml b/helm/charts/api-server/templates/route.yaml similarity index 100% rename from mail-infra/helm/api-server/templates/route.yaml rename to helm/charts/api-server/templates/route.yaml diff --git a/mail-infra/helm/api-server/templates/service.yaml b/helm/charts/api-server/templates/service.yaml similarity index 100% rename from mail-infra/helm/api-server/templates/service.yaml rename to helm/charts/api-server/templates/service.yaml diff --git a/mail-infra/helm/api-server/templates/serviceaccount.yaml b/helm/charts/api-server/templates/serviceaccount.yaml similarity index 100% rename from mail-infra/helm/api-server/templates/serviceaccount.yaml rename to helm/charts/api-server/templates/serviceaccount.yaml diff --git a/mail-infra/helm/api-server/values.example.yaml b/helm/charts/api-server/values.example.yaml similarity index 97% rename from mail-infra/helm/api-server/values.example.yaml rename to helm/charts/api-server/values.example.yaml index 8e30ef6..10373c1 100644 --- a/mail-infra/helm/api-server/values.example.yaml +++ b/helm/charts/api-server/values.example.yaml @@ -1,7 +1,7 @@ # Minimal overrides for the silver-api-server chart. # Copy to a private file (e.g. my-api-values.yaml) and DO NOT commit. # -# helm upgrade --install api-server ./mail-infra/helm/api-server \ +# helm upgrade --install api-server ./helm/charts/api-server \ # --namespace pingmailer --create-namespace \ # -f my-api-values.yaml diff --git a/mail-infra/helm/api-server/values.yaml b/helm/charts/api-server/values.yaml similarity index 74% rename from mail-infra/helm/api-server/values.yaml rename to helm/charts/api-server/values.yaml index 00d85c5..ba166b6 100644 --- a/mail-infra/helm/api-server/values.yaml +++ b/helm/charts/api-server/values.yaml @@ -10,8 +10,8 @@ # - Kubernetes: use Ingress (NGINX, Traefik, etc.) — enabled by default # - OpenShift: use Route (optional) — disabled by default # -# helm upgrade --install api-server ./mail-infra/helm/api-server \ -# --namespace nsw-infra-prod -f my-api-values.yaml +# helm upgrade --install api-server ./helm/charts/api-server \ +# --namespace -f my-api-values.yaml # --------------------------------------------------------------------------- nameOverride: "" @@ -89,7 +89,7 @@ oauth2IntrospectUrl: "" extraEnv: [] existingSecret: "" -# Required by the nsw-infra-* tenant-quota (rejects containers without both). +# Required by tenant ResourceQuotas that reject containers without both. resources: requests: cpu: 50m @@ -98,6 +98,30 @@ resources: cpu: 500m memory: 256Mi +# --------------------------------------------------------------------------- +# Extra /etc/hosts entries for the pod. +# +# Why this exists: callers pass `smtp_host` in the /notify body, and the +# api-server verifies the SMTP server's TLS certificate against that name. The +# mail cert is issued for the mail domain (e.g. `mail.example.com`), NOT for +# the Kubernetes Service name `smtp-server` — so `smtp_host: smtp-server` fails +# with `x509: certificate is valid for *.example.com, example.com, not +# smtp-server`. Pointing callers at the public `mail.example.com` fails too, +# because a cluster usually cannot hairpin out to its own public IP +# (`dial tcp :587: i/o timeout`). +# +# Mapping the cert-matching name to the SMTP Service ClusterIP fixes both: +# callers use `smtp_host: mail.example.com` and TLS verification passes. +# +# NOTE: this pins a ClusterIP. If the smtp-server Service is deleted and +# recreated it gets a new address and this must be updated. Read it with: +# kubectl -n get svc smtp-server -o jsonpath='{.spec.clusterIP}' +# --------------------------------------------------------------------------- +hostAliases: [] +# - ip: 10.96.0.42 # the smtp-server Service ClusterIP +# hostnames: +# - mail.example.com + # Nothing is pinned: OpenShift's restricted-v2 SCC assigns an arbitrary # non-root UID/fsGroup from the namespace range (the image's USER 1000 would # otherwise be rejected). The Go static binary runs fine under any UID. diff --git a/mail-infra/helm/opendkim-server/.helmignore b/helm/charts/opendkim-server/.helmignore similarity index 100% rename from mail-infra/helm/opendkim-server/.helmignore rename to helm/charts/opendkim-server/.helmignore diff --git a/mail-infra/helm/opendkim-server/Chart.yaml b/helm/charts/opendkim-server/Chart.yaml similarity index 95% rename from mail-infra/helm/opendkim-server/Chart.yaml rename to helm/charts/opendkim-server/Chart.yaml index 7bab1e3..351e059 100644 --- a/mail-infra/helm/opendkim-server/Chart.yaml +++ b/helm/charts/opendkim-server/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: silver-opendkim +name: opendkim-server description: | Helm chart for the OpenDKIM milter (ghcr.io/lsflk/silver-dkim) used by the Pingmailer / Silver Mail stack. Generates per-domain DKIM signing keys on diff --git a/mail-infra/helm/opendkim-server/README.md b/helm/charts/opendkim-server/README.md similarity index 97% rename from mail-infra/helm/opendkim-server/README.md rename to helm/charts/opendkim-server/README.md index 790fd1d..8116eee 100644 --- a/mail-infra/helm/opendkim-server/README.md +++ b/helm/charts/opendkim-server/README.md @@ -49,7 +49,7 @@ domains: ``` ```bash -helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ +helm upgrade --install opendkim-server ./helm/charts/opendkim-server \ --namespace pingmailer --create-namespace \ -f my-domains.yaml ``` @@ -57,7 +57,7 @@ helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ Or inline: ```bash -helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ +helm upgrade --install opendkim-server ./helm/charts/opendkim-server \ --namespace pingmailer --create-namespace \ --set 'domains[0].domain=example.com' ``` diff --git a/mail-infra/helm/opendkim-server/templates/NOTES.txt b/helm/charts/opendkim-server/templates/NOTES.txt similarity index 100% rename from mail-infra/helm/opendkim-server/templates/NOTES.txt rename to helm/charts/opendkim-server/templates/NOTES.txt diff --git a/mail-infra/helm/opendkim-server/templates/_helpers.tpl b/helm/charts/opendkim-server/templates/_helpers.tpl similarity index 100% rename from mail-infra/helm/opendkim-server/templates/_helpers.tpl rename to helm/charts/opendkim-server/templates/_helpers.tpl diff --git a/mail-infra/helm/opendkim-server/templates/configmap.yaml b/helm/charts/opendkim-server/templates/configmap.yaml similarity index 100% rename from mail-infra/helm/opendkim-server/templates/configmap.yaml rename to helm/charts/opendkim-server/templates/configmap.yaml diff --git a/mail-infra/helm/opendkim-server/templates/deployment.yaml b/helm/charts/opendkim-server/templates/deployment.yaml similarity index 100% rename from mail-infra/helm/opendkim-server/templates/deployment.yaml rename to helm/charts/opendkim-server/templates/deployment.yaml diff --git a/mail-infra/helm/opendkim-server/templates/pvc.yaml b/helm/charts/opendkim-server/templates/pvc.yaml similarity index 100% rename from mail-infra/helm/opendkim-server/templates/pvc.yaml rename to helm/charts/opendkim-server/templates/pvc.yaml diff --git a/mail-infra/helm/opendkim-server/templates/service.yaml b/helm/charts/opendkim-server/templates/service.yaml similarity index 100% rename from mail-infra/helm/opendkim-server/templates/service.yaml rename to helm/charts/opendkim-server/templates/service.yaml diff --git a/mail-infra/helm/opendkim-server/templates/serviceaccount.yaml b/helm/charts/opendkim-server/templates/serviceaccount.yaml similarity index 100% rename from mail-infra/helm/opendkim-server/templates/serviceaccount.yaml rename to helm/charts/opendkim-server/templates/serviceaccount.yaml diff --git a/mail-infra/helm/opendkim-server/values.yaml b/helm/charts/opendkim-server/values.yaml similarity index 97% rename from mail-infra/helm/opendkim-server/values.yaml rename to helm/charts/opendkim-server/values.yaml index ab263ef..1f350ce 100644 --- a/mail-infra/helm/opendkim-server/values.yaml +++ b/helm/charts/opendkim-server/values.yaml @@ -4,7 +4,7 @@ # Sensitive values (the domain list) are intentionally empty here. Provide # them at install/upgrade time, e.g.: # -# helm upgrade --install opendkim-server ./mail-infra/helm/opendkim-server \ +# helm upgrade --install opendkim-server ./helm/charts/opendkim-server \ # --namespace pingmailer --create-namespace \ # -f my-domains.yaml # @@ -117,7 +117,7 @@ securityContext: drop: - ALL -# Required: the nsw-infra-* namespaces enforce a ResourceQuota that rejects any +# Required: tenant namespaces commonly enforce a ResourceQuota that rejects any # container without CPU/memory requests and limits. opendkim is tiny. resources: requests: diff --git a/mail-infra/helm/raven-sasl-server/.helmignore b/helm/charts/raven-sasl-server/.helmignore similarity index 100% rename from mail-infra/helm/raven-sasl-server/.helmignore rename to helm/charts/raven-sasl-server/.helmignore diff --git a/mail-infra/helm/raven-sasl-server/Chart.yaml b/helm/charts/raven-sasl-server/Chart.yaml similarity index 96% rename from mail-infra/helm/raven-sasl-server/Chart.yaml rename to helm/charts/raven-sasl-server/Chart.yaml index edf4782..600742e 100644 --- a/mail-infra/helm/raven-sasl-server/Chart.yaml +++ b/helm/charts/raven-sasl-server/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: silver-raven-sasl +name: raven-sasl-server description: | Helm chart for the raven-sasl service (ghcr.io/lsflk/raven-sasl) used by the Pingmailer / Silver Mail stack. Provides a Dovecot-compatible SASL diff --git a/mail-infra/helm/raven-sasl-server/README.md b/helm/charts/raven-sasl-server/README.md similarity index 98% rename from mail-infra/helm/raven-sasl-server/README.md rename to helm/charts/raven-sasl-server/README.md index 20ddd5c..3ed890b 100644 --- a/mail-infra/helm/raven-sasl-server/README.md +++ b/helm/charts/raven-sasl-server/README.md @@ -61,7 +61,7 @@ tlsSecret: ``` ```bash -helm upgrade --install raven-sasl ./mail-infra/helm/raven-sasl-server \ +helm upgrade --install raven-sasl ./helm/charts/raven-sasl-server \ --namespace pingmailer --create-namespace \ -f my-raven-values.yaml ``` diff --git a/mail-infra/helm/raven-sasl-server/templates/NOTES.txt b/helm/charts/raven-sasl-server/templates/NOTES.txt similarity index 95% rename from mail-infra/helm/raven-sasl-server/templates/NOTES.txt rename to helm/charts/raven-sasl-server/templates/NOTES.txt index daf5d93..687407b 100644 --- a/mail-infra/helm/raven-sasl-server/templates/NOTES.txt +++ b/helm/charts/raven-sasl-server/templates/NOTES.txt @@ -32,7 +32,7 @@ master.cf string working when both charts share a namespace.) ----- Upgrade with new sensitive values ----- - helm upgrade --install {{ .Release.Name }} ./mail-infra/helm/raven-sasl-server \ + helm upgrade --install {{ .Release.Name }} ./helm/charts/raven-sasl-server \ --namespace {{ .Release.Namespace }} -f my-raven-values.yaml ----- TLS ----- diff --git a/mail-infra/helm/raven-sasl-server/templates/_helpers.tpl b/helm/charts/raven-sasl-server/templates/_helpers.tpl similarity index 100% rename from mail-infra/helm/raven-sasl-server/templates/_helpers.tpl rename to helm/charts/raven-sasl-server/templates/_helpers.tpl diff --git a/mail-infra/helm/raven-sasl-server/templates/configmap.yaml b/helm/charts/raven-sasl-server/templates/configmap.yaml similarity index 100% rename from mail-infra/helm/raven-sasl-server/templates/configmap.yaml rename to helm/charts/raven-sasl-server/templates/configmap.yaml diff --git a/mail-infra/helm/raven-sasl-server/templates/deployment.yaml b/helm/charts/raven-sasl-server/templates/deployment.yaml similarity index 100% rename from mail-infra/helm/raven-sasl-server/templates/deployment.yaml rename to helm/charts/raven-sasl-server/templates/deployment.yaml diff --git a/mail-infra/helm/raven-sasl-server/templates/service.yaml b/helm/charts/raven-sasl-server/templates/service.yaml similarity index 100% rename from mail-infra/helm/raven-sasl-server/templates/service.yaml rename to helm/charts/raven-sasl-server/templates/service.yaml diff --git a/mail-infra/helm/raven-sasl-server/templates/serviceaccount.yaml b/helm/charts/raven-sasl-server/templates/serviceaccount.yaml similarity index 100% rename from mail-infra/helm/raven-sasl-server/templates/serviceaccount.yaml rename to helm/charts/raven-sasl-server/templates/serviceaccount.yaml diff --git a/mail-infra/helm/raven-sasl-server/values.yaml b/helm/charts/raven-sasl-server/values.yaml similarity index 97% rename from mail-infra/helm/raven-sasl-server/values.yaml rename to helm/charts/raven-sasl-server/values.yaml index b583cc8..5151c57 100644 --- a/mail-infra/helm/raven-sasl-server/values.yaml +++ b/helm/charts/raven-sasl-server/values.yaml @@ -4,7 +4,7 @@ # Sensitive values (mail domain, OAuth audience, email allowlist, TLS secret # name) are intentionally empty here. Supply them at install/upgrade time: # -# helm upgrade --install raven-sasl ./mail-infra/helm/raven-sasl-server \ +# helm upgrade --install raven-sasl ./helm/charts/raven-sasl-server \ # --namespace pingmailer --create-namespace \ # -f my-raven-values.yaml # diff --git a/mail-infra/helm/smtp-server/.helmignore b/helm/charts/smtp-server/.helmignore similarity index 100% rename from mail-infra/helm/smtp-server/.helmignore rename to helm/charts/smtp-server/.helmignore diff --git a/mail-infra/helm/smtp-server/Chart.yaml b/helm/charts/smtp-server/Chart.yaml similarity index 97% rename from mail-infra/helm/smtp-server/Chart.yaml rename to helm/charts/smtp-server/Chart.yaml index 4b19901..494aaa6 100644 --- a/mail-infra/helm/smtp-server/Chart.yaml +++ b/helm/charts/smtp-server/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 -name: silver-smtp +name: smtp-server 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 / diff --git a/mail-infra/helm/smtp-server/README.md b/helm/charts/smtp-server/README.md similarity index 99% rename from mail-infra/helm/smtp-server/README.md rename to helm/charts/smtp-server/README.md index 840798a..af8f37a 100644 --- a/mail-infra/helm/smtp-server/README.md +++ b/helm/charts/smtp-server/README.md @@ -59,7 +59,7 @@ tlsSecret: ``` ```bash -helm upgrade --install smtp-server ./mail-infra/helm/smtp-server \ +helm upgrade --install smtp-server ./helm/charts/smtp-server \ --namespace pingmailer --create-namespace \ -f my-smtp-values.yaml ``` diff --git a/mail-infra/helm/smtp-server/templates/NOTES.txt b/helm/charts/smtp-server/templates/NOTES.txt similarity index 100% rename from mail-infra/helm/smtp-server/templates/NOTES.txt rename to helm/charts/smtp-server/templates/NOTES.txt diff --git a/mail-infra/helm/smtp-server/templates/_helpers.tpl b/helm/charts/smtp-server/templates/_helpers.tpl similarity index 100% rename from mail-infra/helm/smtp-server/templates/_helpers.tpl rename to helm/charts/smtp-server/templates/_helpers.tpl diff --git a/mail-infra/helm/smtp-server/templates/configmap.yaml b/helm/charts/smtp-server/templates/configmap.yaml similarity index 100% rename from mail-infra/helm/smtp-server/templates/configmap.yaml rename to helm/charts/smtp-server/templates/configmap.yaml diff --git a/mail-infra/helm/smtp-server/templates/deployment.yaml b/helm/charts/smtp-server/templates/deployment.yaml similarity index 100% rename from mail-infra/helm/smtp-server/templates/deployment.yaml rename to helm/charts/smtp-server/templates/deployment.yaml diff --git a/mail-infra/helm/smtp-server/templates/pvc.yaml b/helm/charts/smtp-server/templates/pvc.yaml similarity index 100% rename from mail-infra/helm/smtp-server/templates/pvc.yaml rename to helm/charts/smtp-server/templates/pvc.yaml diff --git a/mail-infra/helm/smtp-server/templates/service.yaml b/helm/charts/smtp-server/templates/service.yaml similarity index 100% rename from mail-infra/helm/smtp-server/templates/service.yaml rename to helm/charts/smtp-server/templates/service.yaml diff --git a/mail-infra/helm/smtp-server/templates/serviceaccount.yaml b/helm/charts/smtp-server/templates/serviceaccount.yaml similarity index 100% rename from mail-infra/helm/smtp-server/templates/serviceaccount.yaml rename to helm/charts/smtp-server/templates/serviceaccount.yaml diff --git a/mail-infra/helm/smtp-server/values.yaml b/helm/charts/smtp-server/values.yaml similarity index 97% rename from mail-infra/helm/smtp-server/values.yaml rename to helm/charts/smtp-server/values.yaml index 309ccb2..bc10759 100644 --- a/mail-infra/helm/smtp-server/values.yaml +++ b/helm/charts/smtp-server/values.yaml @@ -4,7 +4,7 @@ # 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 \ +# helm upgrade --install smtp-server ./helm/charts/smtp-server \ # --namespace pingmailer --create-namespace \ # -f my-smtp-values.yaml # --------------------------------------------------------------------------- @@ -161,7 +161,7 @@ securityContext: # 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 +# Required: tenant ResourceQuotas reject any container without CPU/memory # requests + limits. Applied to BOTH the init and main containers. resources: requests: diff --git a/helm/templates/NOTES.txt b/helm/templates/NOTES.txt new file mode 100644 index 0000000..9e081c2 --- /dev/null +++ b/helm/templates/NOTES.txt @@ -0,0 +1,34 @@ +pingmailer {{ .Chart.Version }} installed as release "{{ .Release.Name }}" in namespace "{{ .Release.Namespace }}". + +Components in this release: +{{ if index .Values "api-server" "enabled" }} [x]{{ else }} [ ]{{ end }} api-server http :8000 (POST /notify, GET /healthcheck) +{{ if index .Values "opendkim-server" "enabled" }} [x]{{ else }} [ ]{{ end }} opendkim-server milter :8891 +{{ if index .Values "raven-sasl-server" "enabled" }} [x]{{ else }} [ ]{{ end }} raven-sasl-server sasl :12345 (Service DNS: raven-sasl) +{{ if index .Values "smtp-server" "enabled" }} [x]{{ else }} [ ]{{ end }} smtp-server smtp :{{ (index .Values "smtp-server" "service").smtpPort | default 25 }} / submission :{{ (index .Values "smtp-server" "service").submissionPort | default 587 }} + +Check rollout: + kubectl -n {{ .Release.Namespace }} get pods,svc,pvc -l app.kubernetes.io/instance={{ .Release.Name }} + kubectl -n {{ .Release.Namespace }} rollout status deploy/smtp-server + +{{- if index .Values "opendkim-server" "enabled" }} + +Publish DKIM before sending — opendkim generates the keypair on first start. +Read the public record out of the pod and add it to DNS: +{{- range (index .Values "opendkim-server" "domains") }} + kubectl -n {{ $.Release.Namespace }} exec deploy/opendkim-server -- \ + cat /etc/dkimkeys/{{ .domain }}/{{ .selector | default "mail" }}.txt + # -> TXT {{ .selector | default "mail" }}._domainkey.{{ .domain }} +{{- end }} +{{- end }} + +{{- if index .Values "smtp-server" "enabled" }} + +Also publish SPF and DMARC for {{ (index .Values "smtp-server").domain }}, and point MX at +{{ (index .Values "smtp-server").hostname | default (printf "mail.%s" (index .Values "smtp-server").domain) }} if this host should receive mail. + +Send a test message from inside the cluster: + kubectl -n {{ .Release.Namespace }} run smtp-test --rm -it --restart=Never --image=alpine -- \ + sh -c 'apk add --no-cache swaks >/dev/null && swaks --server smtp-server:{{ (index .Values "smtp-server" "service").submissionPort | default 587 }} --tls ...' +{{- end }} + +Full deployment guide: helm/README.md diff --git a/helm/values.example.yaml b/helm/values.example.yaml new file mode 100644 index 0000000..400c6a0 --- /dev/null +++ b/helm/values.example.yaml @@ -0,0 +1,75 @@ +# =========================================================================== +# Example umbrella values for a real install. Copy to my-values.yaml, replace +# every example.com / client-id placeholder, and DO NOT COMMIT the result: +# +# cp helm/values.example.yaml my-values.yaml +# helm upgrade --install pingmailer ./helm -n pingmailer -f my-values.yaml +# +# This file assumes the mail domain is `example.com` and that a TLS Secret +# named `example-com-tls` (keys tls.crt / tls.key) already exists in the +# namespace — produced by the certbot-server chart or cert-manager. +# =========================================================================== + +api-server: + enabled: true + replicaCount: 2 + image: + repository: ghcr.io/silver-mail-platform/pingmailer-api-server + tag: "0.1.0" + ingress: + enabled: true + className: nginx + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod + hosts: + - host: api.example.com + paths: + - path: / + pathType: Prefix + tls: + - secretName: api-example-com-tls + hosts: + - api.example.com + # On OpenShift, swap the two blocks above/below: + # ingress: { enabled: false } + # route: { enabled: true, host: "" } + +opendkim-server: + enabled: true + domains: + - domain: example.com + selector: mail + keySize: 2048 + persistence: + enabled: true + size: 256Mi + +raven-sasl-server: + enabled: true + config: + domain: example.com + authServerUrl: https://thunder-server:8090/auth/credentials/authenticate + oauth: + audience: + - your-mail-app-client-id + oauthEmailAuthorization: + your-mail-app-client-id: + - alerts@example.com + - noreply@example.com + tlsSecret: + name: example-com-tls + +smtp-server: + enabled: true + domain: example.com + hostname: mail.example.com + tlsSecret: + name: example-com-tls + service: + # ClusterIP = in-cluster only. Use LoadBalancer (cloud / MetalLB) or + # NodePort to receive submission traffic from outside the cluster. + type: ClusterIP + persistence: + spool: + enabled: true + size: 1Gi diff --git a/helm/values.yaml b/helm/values.yaml new file mode 100644 index 0000000..8bd925d --- /dev/null +++ b/helm/values.yaml @@ -0,0 +1,159 @@ +# =========================================================================== +# pingmailer — umbrella chart values +# +# Every top-level key below matches a subchart name under charts/. Anything +# nested under that key is passed straight through to the subchart, so the +# subchart's own values.yaml is the authoritative reference for each option: +# +# charts/api-server/values.yaml +# charts/opendkim-server/values.yaml +# charts/raven-sasl-server/values.yaml +# charts/smtp-server/values.yaml +# +# REQUIRED VALUES — the charts fail() at render time if these are missing: +# opendkim-server.domains (at least one entry) +# raven-sasl-server.config.domain +# raven-sasl-server.config.oauth.audience +# raven-sasl-server.oauthEmailAuthorization +# smtp-server.domain +# smtp-server.tlsSecret.name (when submission/587 is enabled) +# +# NOTE ON THE MAIL DOMAIN: there is deliberately no `global.domain`. Each +# subchart owns its own domain field so it stays installable standalone. When +# installing the umbrella, set the SAME domain in all three places marked +# "### MAIL DOMAIN" below. +# +# Copy values.example.yaml, fill it in, and keep it out of git: +# helm upgrade --install pingmailer ./helm -n -f my-values.yaml +# =========================================================================== + +# --------------------------------------------------------------------------- +# api-server — stateless Go relay. POST /notify, GET /healthcheck on :8000. +# External TLS terminates at the Ingress (Kubernetes) or Route (OpenShift). +# --------------------------------------------------------------------------- +api-server: + enabled: true + + replicaCount: 2 + + image: + repository: ghcr.io/silver-mail-platform/pingmailer-api-server + tag: "" + pullPolicy: Always + + # Kubernetes Ingress — on by default. Replace the host with your own. + ingress: + enabled: true + className: "" + annotations: {} + hosts: + - host: api.example.com ### MAIL DOMAIN (api hostname) + paths: + - path: / + pathType: Prefix + tls: + - secretName: api-tls + hosts: + - api.example.com + + # OpenShift Route — set enabled=true (and ingress.enabled=false) on OpenShift. + route: + enabled: false + host: "" + +# --------------------------------------------------------------------------- +# opendkim-server — DKIM signing milter on :8891. Keys are generated on first +# start and persisted on a PVC; keep replicaCount at 1 (single writer). +# --------------------------------------------------------------------------- +opendkim-server: + enabled: true + + image: + repository: ghcr.io/silver-mail-platform/silver-dkim + tag: latest + + # REQUIRED. One entry per signing domain. + domains: [] ### MAIL DOMAIN + # - domain: example.com + # selector: mail + # keySize: 2048 + + persistence: + enabled: true + storageClass: "" + size: 256Mi + +# --------------------------------------------------------------------------- +# raven-sasl-server — SASL / OAUTHBEARER daemon on :12345. Postfix reaches it +# at inet:raven-sasl:12345 (the subchart pins that Service name). +# --------------------------------------------------------------------------- +raven-sasl-server: + enabled: true + + image: + repository: ghcr.io/lsflk/raven-sasl + tag: latest + + config: + domain: "" ### MAIL DOMAIN — REQUIRED + # Thunder credential-auth endpoint (same namespace by default). + authServerUrl: "https://thunder-server:8090/auth/credentials/authenticate" + oauth: + # Left empty, these derive from config.domain. + issuerUrl: "" + jwksUrl: "" + # REQUIRED — the OAuth client IDs whose tokens Raven will accept. + audience: [] + # - your-mail-app-client-id + + # REQUIRED — OAuth client_id -> the addresses that client may send as. + # Treat as a secret; supply from your own values file, do not commit. + oauthEmailAuthorization: {} + # your-mail-app-client-id: + # - alerts@example.com + + # TLS Secret (tls.crt / tls.key) from certbot-server or cert-manager. + # Empty skips the /certs mount. + tlsSecret: + name: "" + + # Tenant ResourceQuotas reject containers without requests+limits. + resources: + requests: + cpu: 50m + memory: 64Mi + limits: + cpu: 200m + memory: 256Mi + +# --------------------------------------------------------------------------- +# smtp-server — rootless Postfix on :25 / :587. Wired to opendkim-server and +# raven-sasl by their Service DNS names, which the subcharts pin. +# --------------------------------------------------------------------------- +smtp-server: + enabled: true + + image: + repository: ghcr.io/silver-mail-platform/silver-smtp + tag: rootless + + domain: "" ### MAIL DOMAIN — REQUIRED + hostname: "" # defaults to mail. + + # REQUIRED while submission/587 is enabled — Secret holding tls.crt/tls.key. + tlsSecret: + name: "" + + service: + # ClusterIP is in-cluster only. Use LoadBalancer or NodePort to accept + # mail from outside — an Ingress/Route cannot carry SMTP. See + # charts/smtp-server/values.yaml for the full trade-off. + type: ClusterIP + smtpPort: 25 + submissionPort: 587 + + persistence: + spool: + enabled: true + storageClass: "" + size: 1Gi