infra/docs/runbooks/mailserver-proxy-protocol.md
Viktor Barzin e6e5fc5f17 [docs] Mailserver architecture — richer diagrams + steady-state accuracy [ci skip]
## Context

After code-yiu Phases 1a–6 landed, `docs/architecture/mailserver.md` still
carried the pre-HAProxy Mermaid diagram, a retired Dovecot-exporter
component row, stale PVC names (`-proxmox` suffixes that were renamed
`-encrypted` during the LUKS migration), a wrong probe schedule
(claimed 10 min, actually 20 min), and a Mailgun-API claim for the
probe (it's been on Brevo since code-n5l). The two-path architecture
(external-via-HAProxy + intra-cluster-via-ClusterIP) that defines the
current design wasn't visualised at all.

## This change

Rewrote the Architecture Diagram section to show **both ingress paths
in one Mermaid flowchart**, colour-coded:

- External (orange): Sender → pfSense NAT → HAProxy → NodePort →
  **alt PROXY listeners** (2525/4465/5587/10993).
- Intra-cluster (blue): Roundcube / probe → ClusterIP Service →
  **stock listeners** (25/465/587/993), no PROXY.
- The pod subgraph shows both listener sets feeding the same Postfix /
  Rspamd / Dovecot / Maildir pipeline.
- Security dotted edges: Postfix log stream → CrowdSec agent →
  LAPI → pfSense bouncer decisions.
- Monitoring dotted edges: probe → Brevo HTTP → MX → pod → IMAP →
  Pushgateway/Uptime Kuma.

Added a **sequenceDiagram** for the external SMTP roundtrip — walks
through the wire-level handshake from external MTA → pfSense NAT →
HAProxy TCP connect → PROXY v2 header write → kube-proxy SNAT → pod
postscreen parse → smtpd banner. Makes the "how does the pod see the
real IP despite SNAT?" question self-answering.

Added a **Port mapping table** listing all 8 container listeners (4
stock + 4 alt) with their Service, NodePort, PROXY-required flag, and
who uses each path. Replaces the ambiguous prose about "alt ports".

Fixed stale bits:
- Removed Dovecot Exporter row from Components (retired in code-1ik).
- Added pfSense HAProxy row.
- Probe schedule: every 10 min → **every 20 min** (`*/20 * * * *`).
- Probe API: Mailgun → **Brevo HTTP**.
- PVC names: `-proxmox` → **`-encrypted`** (all three); storage class
  `proxmox-lvm` → **`proxmox-lvm-encrypted`**.
- Added `mailserver-backup-host` + `roundcube-backup-host` RWX NFS
  PVCs to the Storage table with backup flow pointer.
- Expanded Troubleshooting → Inbound to include HAProxy health check
  + container-listener verification steps.
- Secrets table: `brevo_api_key` now marked as used by both relay +
  probe; `mailgun_api_key` marked historical.

Added a prominent **UPDATE 2026-04-19** header to
`docs/runbooks/mailserver-proxy-protocol.md` pointing future readers
at the implemented state in `mailserver-pfsense-haproxy.md`. Research
doc preserved as a decision record — it's the canonical "why not just
pin the pod?" reference.

## What is NOT in this change

- No Terraform changes; this is docs-only.
- No changes to the runbook (`mailserver-pfsense-haproxy.md`) — it was
  already rewritten during Phase 6.

## Test Plan

### Automated
```
$ awk '/^```mermaid/ {c++} END{print c}' docs/architecture/mailserver.md
2
$ grep -c '\-encrypted' docs/architecture/mailserver.md
5  # PVC references normalised
$ grep -c '\-proxmox' docs/architecture/mailserver.md
0  # no stale names left
```

### Manual Verification
Render `docs/architecture/mailserver.md` on GitHub or any Mermaid-
capable viewer:
1. Top Architecture Diagram should show two labelled paths into the
   pod, colour-coded (orange = external, blue = intra-cluster).
2. Sequence diagram should show 10 numbered steps ending at Rspamd +
   Dovecot delivery.
3. Port Mapping table should make it obvious that the 4 alt container
   ports are only reachable via `mailserver-proxy` NodePort and require
   PROXY v2.
2026-04-19 12:40:53 +00:00

8.8 KiB
Raw Blame History

Mailserver PROXY protocol — research & decision

Last updated: 2026-04-18 (original research). Outcome implemented 2026-04-19 — see UPDATE below.

UPDATE (2026-04-19)

This doc describes the research that led to the Phase-6 rollout. Option C (pfSense HAProxy + PROXY v2) was chosen and is now live. Operational state, cutover history, bootstrap, and rollback procedures live in mailserver-pfsense-haproxy.md.

This file is retained as a decision record — it explains why Option A (pod-pinning via nodeSelector) was rejected mid-session in favour of Option C, and documents the MetalLB upstream limitation (PROXY injection is explicitly won't-implement). Future debates of "why don't we just pin the pod?" should land here first.

TL;DR

MetalLB does not and will not inject PROXY protocol headers. The original plan (/home/wizard/.claude/plans/let-s-work-on-linking-temporal-valiant.md, task code-rtb) assumed MetalLB could be configured to emit PROXY v1/v2 on behalf of the mailserver LoadBalancer Service. That assumption is wrong at the product level. MetalLB is a control-plane-only announcer (ARP/NDP for L2 mode, BGP for L3 mode); it never touches the L4 payload.

As a result, there is no single Terraform change that can flip externalTrafficPolicy: LocalCluster on the mailserver Service while preserving the real client IP for Postfix/postscreen and Dovecot. Three alternative paths exist (see below); none is trivial.

Environment (verified 2026-04-18)

  • MetalLB version: quay.io/metallb/controller:v0.15.3 / quay.io/metallb/speaker:v0.15.3 (5 speakers).
  • Advertisement type: L2Advertisement default bound to IPAddressPool default (10.0.20.20010.0.20.220). No BGPAdvertisements.
  • Service: mailserver/mailserver — type LoadBalancer, loadBalancerIPs: 10.0.20.202, externalTrafficPolicy: Local, healthCheckNodePort: 30234, 5 ports (25, 465, 587, 993, 9166/dovecot-metrics).
  • Pod: single replica today, RWO PVCs prevent horizontal scale without further work (mailserver-data-encrypted, mailserver-letsencrypt-encrypted).

Why the original plan fails

MetalLB never touches packets

"MetalLB is controlplane only, making it part of the dataplane means we would be responsible for the performance of the system, so more bugs to fight, I personally don't see that happening." — MetalLB maintainer champtar, 2021-01-06 (issue #797 — Feature Request: Supporting Proxy Protocol v2)

Issue #797 is closed as "won't implement". Repeat asks in 20222023 got the same answer. The v0.15.3 API surface confirms this: no proxyProtocol/haproxy/protocol: proxy field exists on IPAddressPool, L2Advertisement, BGPAdvertisement, or as a Service annotation.

Only managed-cloud LBs (AWS NLB, Azure LB, OCI, DO, OVH, Scaleway, etc.) offer PROXY protocol as a tick-box. MetalLB's equivalents are:

MetalLB feature Does it preserve client IP? Comment
externalTrafficPolicy: Local (current) Yes, via iptables DNAT on the speaker node Forces pod↔speaker colocation on L2 mode. This is the pain we wanted to avoid.
externalTrafficPolicy: Cluster No — kube-proxy SNATs to the node IP The problem we would re-introduce if we flipped without PROXY injection.
PROXY protocol injection N/A — not implemented Dead end.

The Local trap is real, but narrower than it seems

Today's Local policy means the ARP announcer node must also host the mailserver pod. MetalLB always picks a single speaker to advertise the VIP (leader election per IP), so in practice exactly one node matters at any moment. A pod rescheduled to a different node silently drops inbound SMTP/IMAP until a GARP flip or node cordon.

The only pods on our cluster that see this same class of risk are Traefik (3 replicas + PDB minAvailable=2, so 2 of 3 nodes always have a pod) and mailserver (1 replica). Traefik survives because the pods outnumber the nodes that could be the speaker at once; the mailserver cannot.

Alternative paths (ranked by effort)

Option A — Pin the mailserver pod to a specific node (SIMPLEST)

Add nodeSelector on the mailserver Deployment pointing at a label that's also stamped on the MetalLB speaker we want to advertise the VIP from, and use MetalLB's node selector on L2Advertisement.spec.nodeSelectors to pin the announcer to the same node.

Trade-offs:

  • Zero changes to Postfix/Dovecot configs.
  • Keeps externalTrafficPolicy: Local — real client IP keeps arriving.
  • Loses HA (the whole point of the MetalLB layer) but reflects reality — one replica, one PVC, no HA today anyway.
  • Drain of that node requires a planned cutover, but that's no worse than today's silent failure mode.

Implementation (~10 lines of Terraform):

# In stacks/mailserver/modules/mailserver/main.tf, on the Deployment:
node_selector = { "viktorbarzin.me/mailserver-anchor" = "true" }

# In stacks/platform (or wherever the MetalLB CRs live):
resource "kubernetes_manifest" "mailserver_l2ad" {
  manifest = {
    apiVersion = "metallb.io/v1beta1"
    kind       = "L2Advertisement"
    metadata   = { name = "mailserver", namespace = "metallb-system" }
    spec = {
      ipAddressPools = ["default"]
      nodeSelectors  = [{ matchLabels = { "viktorbarzin.me/mailserver-anchor" = "true" } }]
    }
  }
}

Plus a node label via kubectl label node k8s-node3 viktorbarzin.me/mailserver-anchor=true.

Recommendation: this is the shortest path to eliminating the silent-drop failure mode without taking on a new proxy tier.

Option B — Put a HAProxy sidecar in front of Postfix/Dovecot

Stand up an in-cluster HAProxy with PROXY v2 enabled on the frontend and send-proxy-v2 on the backend to mailserver:25/465/587/993. Expose HAProxy via a new MetalLB Service with externalTrafficPolicy: Cluster + kube-proxy DSR workaround (still loses client IP at that layer), or run HAProxy on the host-network of the same node (back to Option A's colocation).

Trade-offs:

  • Introduces one more network hop and TLS-termination decision for every SMTP connect.
  • HAProxy needs its own cert rotation (or tls-passthrough) — adds moving parts to an already crowded mailserver module.
  • Doesn't actually solve the colocation problem on its own — HAProxy itself needs to receive the client IP, so we are back to externalTrafficPolicy constraints for HAProxy.

Recommendation: avoid unless we also get HA for mailserver itself, which needs RWX storage + DB split-brain work — out of scope.

Option C — Replace MetalLB with a different LB for this Service

Candidates: kube-vip (supports eBPF-based DSR but not PROXY injection either), Cilium LB (preserves client IP via DSR in hybrid mode), or a dedicated HAProxy running on pfSense and NAT-forwarding 25/465/587/993 with PROXY headers to a ClusterIP-exposed mailserver. Cilium requires a CNI migration (we run Calico today); pfSense HAProxy is genuinely feasible but belongs in a different bd task.

Recommendation: track as P3 follow-up under a new bd task if Option A proves insufficient.

Decision

Do nothing in this session beyond this runbook + the bd note. The code-rtb task as written is not executable — MetalLB cannot inject PROXY headers, and the Postfix/Dovecot config changes the plan proposed would not receive the header they expect, they would hang waiting for it and then timeout (5s per connection).

Follow-up work filed as bd child tasks (if user wants to pursue):

  • Option A — pin mailserver + L2Advertisement nodeSelectors (new bd task)
  • Option C — HAProxy on pfSense with PROXY v2 to a ClusterIP (new bd task)

References