2026-03-17 21:34:11 +00:00
|
|
|
variable "tls_secret_name" {}
|
|
|
|
|
variable "tier" { type = string }
|
|
|
|
|
variable "mailserver_accounts" {}
|
|
|
|
|
variable "postfix_account_aliases" {}
|
|
|
|
|
variable "opendkim_key" {}
|
|
|
|
|
variable "sasl_passwd" {} # For sendgrid i.e relayhost
|
|
|
|
|
variable "nfs_server" { type = string }
|
[mailserver] Filter redundant local→local aliases to fix Dovecot 'exists more than once'
## Context
Dovecot auth logs have been steadily spamming
`passwd-file /etc/dovecot/userdb: User r730-idrac@viktorbarzin.me exists more
than once` (and the same for vaultwarden@) at ~31 occurrences per 500 log
lines. Under load this flakes IMAP auth for the e2e email-roundtrip probe
(spam@viktorbarzin.me uses the catch-all), which was masquerading as "Brevo
or probe timing" noise.
## Root cause
docker-mailserver builds Dovecot's `/etc/dovecot/userdb` from two sources:
real accounts (`postfix-accounts.cf`) AND virtual-alias entries whose
*target* resolves to a local mailbox (`postfix-virtual.cf`). When the same
address appears as BOTH a real mailbox AND an alias whose target is another
local mailbox, the generated userdb has two lines for that username pointing
to different home directories — e.g.:
r730-idrac@viktorbarzin.me:...:/var/mail/.../r730-idrac/home
r730-idrac@viktorbarzin.me:...:/var/mail/.../spam/home ← from alias
Dovecot's passwd-file driver rejects the duplicate, and every subsequent
auth lookup logs the error.
This affected exactly two addresses:
- r730-idrac@viktorbarzin.me (real account + alias → spam@)
- vaultwarden@viktorbarzin.me (real account + alias → me@)
Other aliases are fine: they either forward to external addresses (gmail
etc.) — no local userdb entry generated — or map an address to itself
(me@ → me@) which docker-mailserver dedups internally.
Note: removing the real accounts is not an option because Vaultwarden uses
`vaultwarden@viktorbarzin.me` as its live SMTP_USERNAME
(stacks/vaultwarden/modules/vaultwarden/main.tf:121).
## This change
Introduces a `local.postfix_virtual` that concatenates the Vault-sourced
aliases with `extra/aliases.txt`, then filters out any line matching the
exact "LHS RHS" shape where both sides are in `var.mailserver_accounts` and
LHS != RHS. That is, only the pure local→local redundant entries are
dropped; all forwarding aliases and the catch-all are preserved.
The filter is self-healing: if a future alias ever collides with a real
account, it gets silently suppressed instead of breaking Dovecot auth.
```
Vault mailserver_aliases ─┐
├─ concat ─ split \n ─ filter ─ join \n ─► postfix-virtual.cf
extra/aliases.txt ─────────┘ │
└── drop if LHS+RHS both in
mailserver_accounts and
LHS != RHS
```
Filtered entries (confirmed via locally-simulated filter on live data):
- r730-idrac@viktorbarzin.me spam@viktorbarzin.me
- vaultwarden@viktorbarzin.me me@viktorbarzin.me
Preserved (sample): postmaster→me, contact→me, alarm-valchedrym→self+3 ext,
lubohristov→gmail, yoana→gmail, @viktorbarzin.me→spam (catch-all), all four
disposable `*-generated@` aliases.
## What is NOT in this change
- Real accounts in Vault (`secret/platform.mailserver_accounts`) are
untouched — vaultwarden SMTP auth keeps working.
- Postfix postscreen btree lock contention (separate commit).
- Email-roundtrip probe IMAP window (separate commit).
## Test Plan
### Automated
`terraform validate` — passes (docker-mailserver module):
```
Success! The configuration is valid, but there were some validation warnings as shown above.
```
`scripts/tg plan -target=module.mailserver.kubernetes_config_map.mailserver_config`:
```
# module.mailserver.kubernetes_config_map.mailserver_config will be updated in-place
~ resource "kubernetes_config_map" "mailserver_config" {
~ data = {
~ "postfix-virtual.cf" = (sensitive value)
# (9 unchanged elements hidden)
}
id = "mailserver/mailserver.config"
}
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply` — applied:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
Post-apply configmap content (the two lines are gone):
```
$ kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'
postmaster@viktorbarzin.me me@viktorbarzin.me
contact@viktorbarzin.me me@viktorbarzin.me
me@viktorbarzin.me me@viktorbarzin.me
lubohristov@viktorbarzin.me lyubomir.hristov3@gmail.com
alarm-valchedrym@viktorbarzin.me alarm-valchedrym@...,vbarzin@...,emil.barzin@...,me@...
yoana@viktorbarzin.me divcheva.yoana@gmail.com
@viktorbarzin.me spam@viktorbarzin.me
firmly-gerardo-generated@viktorbarzin.me me@viktorbarzin.me
closely-keith-generated@viktorbarzin.me vbarzin@gmail.com
literally-paolo-generated@viktorbarzin.me viktorbarzin@fb.com
hastily-stefanie-generated@viktorbarzin.me elliestamenova@gmail.com
```
Reloader triggers a pod rollout; once new pod is Ready:
- `kubectl -n mailserver exec <pod> -c docker-mailserver -- cut -d: -f1 /etc/dovecot/userdb | sort | uniq -d`
expected output: empty (no duplicate usernames)
- `kubectl -n mailserver logs <pod> -c docker-mailserver --tail=500 | grep -c "exists more than once"`
expected output: 0 (baseline was 31/500 lines)
## Reproduce locally
1. `kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'`
2. Expect: no `r730-idrac@viktorbarzin.me spam@viktorbarzin.me` line and no
`vaultwarden@viktorbarzin.me me@viktorbarzin.me` line.
3. After pod restart: `kubectl -n mailserver logs -l app=mailserver -c docker-mailserver --tail=500 | grep -c "exists more than once"` → 0.
Closes: code-27l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:29:02 +00:00
|
|
|
# Build the virtual-alias map, dropping aliases where BOTH the source and
|
|
|
|
|
# target are real mailboxes in var.mailserver_accounts (and are different).
|
|
|
|
|
# Without this filter, docker-mailserver emits two passwd-file userdb lines
|
|
|
|
|
# for the source address — its own mailbox home plus the alias target's home
|
|
|
|
|
# — and Dovecot logs 'exists more than once' on every auth lookup. Aliases
|
|
|
|
|
# that forward to external addresses (gmail etc.) or to self are safe.
|
|
|
|
|
locals {
|
[mailserver] Add daily backup CronJob for mailserver PVC
## Context
The mailserver stack holds everything valuable and hard to recreate:
243M of maildirs, dovecot/rspamd state, and the DKIM private key that
signs outbound mail. Today the only defense is the LVM thin-pool
snapshots on the PVE host (7-day retention, storage-class scope only)
— there is no app-level backup. Infra/.claude/CLAUDE.md mandates that
every proxmox-lvm(-encrypted) app ship a NFS-backed backup CronJob,
and the mailserver stack was the only one still out of compliance.
Loss of mailserver-data-encrypted without backups = total loss of all
stored mail plus a DKIM key rotation (which requires a DNS update and
breaks signature verification on every message in transit for the TTL
window). Unacceptable for a service people actually use.
Trade-offs considered:
- mysqldump-style single-file dump vs rsync snapshot — maildirs are
millions of small files, not a DB export. rsync --link-dest gives
incremental weekly snapshots for ~10% of the cost of a full copy.
- RWO PVC read-only mount — the underlying PVC is ReadWriteOnce, so
the backup Job has to co-locate with the mailserver pod. vaultwarden
solves this with pod_affinity; mirrored here.
- Image choice — alpine + apk add rsync matches vaultwarden's pattern
and keeps the container image small.
## This change
Adds `kubernetes_cron_job_v1.mailserver-backup` + NFS PV/PVC to the
mailserver module. Runs daily at 03:00 (avoids the 00:30 mysql-backup
and 00:45 per-db windows, and the */20 email-roundtrip cadence). The
job rsyncs /var/mail, /var/mail-state, /var/log/mail into
/srv/nfs/mailserver-backup/<YYYY-WW>/ with --link-dest against the
previous week for space-efficient incrementals. 8-week retention.
Data layout (flowed through from the deployment's subPath mounts so
the rsync tree matches the mailserver's own on-disk layout):
PVC mailserver-data-encrypted (RWO, 2Gi)
├─ data/ (subPath) → pod's /var/mail → backup/<week>/data/
├─ state/ (subPath) → pod's /var/mail-state → backup/<week>/state/
└─ log/ (subPath) → pod's /var/log/mail → backup/<week>/log/
Safety:
- PVC mounted read-only (volume.persistent_volume_claim.read_only
AND all three volume_mounts set read_only=true) so a backup-script
bug cannot corrupt maildirs.
- pod_affinity on app=mailserver + topology_key=hostname forces the
Job pod onto the same node holding the RWO PVC attachment.
- set -euxo pipefail + per-directory existence guard so a missing
subPath short-circuits cleanly instead of silently no-op'ing.
Metrics pushed to Pushgateway match the mysql-backup/vaultwarden-backup
convention (job="mailserver-backup"):
backup_duration_seconds, backup_read_bytes, backup_written_bytes,
backup_output_bytes, backup_last_success_timestamp.
Alert rules added in monitoring stack, mirroring Mysql/Vaultwarden:
- MailserverBackupStale — 36h threshold, critical, 30m for:
- MailserverBackupNeverSucceeded — critical, 1h for:
## Reproduce locally
1. cd infra/stacks/mailserver && ../../scripts/tg plan
Expected: 3 to add (cronjob + NFS PV + PVC), unrelated drift on
deployment/service is pre-existing.
2. ../../scripts/tg apply --non-interactive \
-target=module.mailserver.module.nfs_mailserver_backup_host \
-target=module.mailserver.kubernetes_cron_job_v1.mailserver-backup
3. cd ../monitoring && ../../scripts/tg apply --non-interactive
4. kubectl create job --from=cronjob/mailserver-backup \
mailserver-backup-test -n mailserver
5. kubectl wait --for=condition=complete --timeout=300s \
job/mailserver-backup-test -n mailserver
6. Expected: test pod co-locates with mailserver on same node
(k8s-node2 today), rsync writes ~950M to
/srv/nfs/mailserver-backup/<YYYY-WW>/, Pushgateway exposes
backup_output_bytes{job="mailserver-backup"}.
## Test Plan
### Automated
$ kubectl get cronjob -n mailserver mailserver-backup
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
mailserver-backup 0 3 * * * <none> False 0 <none> 3s
$ kubectl create job --from=cronjob/mailserver-backup \
mailserver-backup-test -n mailserver
job.batch/mailserver-backup-test created
$ kubectl wait --for=condition=complete --timeout=300s \
job/mailserver-backup-test -n mailserver
job.batch/mailserver-backup-test condition met
$ kubectl logs -n mailserver job/mailserver-backup-test | tail -5
=== Backup IO Stats ===
duration: 80s
read: 1120 MiB
written: 1186 MiB
output: 947.0M
$ kubectl run nfs-verify --rm --image=alpine --restart=Never \
--overrides='{...nfs mount /srv/nfs...}' \
-n mailserver --attach -- ls -la /nfs/mailserver-backup/
947.0M /nfs/mailserver-backup/2026-15
$ curl http://prometheus-prometheus-pushgateway.monitoring:9091/metrics \
| grep mailserver-backup
backup_duration_seconds{instance="",job="mailserver-backup"} 80
backup_last_success_timestamp{instance="",job="mailserver-backup"} 1.776554641e+09
backup_output_bytes{instance="",job="mailserver-backup"} 9.92315701e+08
backup_read_bytes{instance="",job="mailserver-backup"} 1.175027712e+09
backup_written_bytes{instance="",job="mailserver-backup"} 1.244254208e+09
$ curl -s http://prometheus-server/api/v1/rules \
| jq '.data.groups[].rules[] | select(.name | test("Mailserver"))'
MailserverBackupStale: (time() - kube_cronjob_status_last_successful_time{cronjob="mailserver-backup",namespace="mailserver"}) > 129600
MailserverBackupNeverSucceeded: kube_cronjob_status_last_successful_time{cronjob="mailserver-backup",namespace="mailserver"} == 0
### Manual Verification
1. Wait for the scheduled 03:00 run tonight; verify
`kubectl get job -n mailserver` shows a new completed job.
2. Check that `backup_last_success_timestamp` advances past today.
3. Confirm `MailserverBackupNeverSucceeded` did not fire.
4. Next week (week 16), confirm `--link-dest` builds hardlinks vs
2026-15 (size delta should drop from ~950M to ~the actual churn).
## Deviations from mysql-backup pattern
- Image: alpine + rsync (mirrors vaultwarden — mysql's `mysql:8.0`
base is not applicable for a filesystem rsync).
- pod_affinity: required for RWO PVC co-location (mysql uses its own
MySQL service for network access; mailserver must mount the PVC).
- Metric push via wget (mirrors vaultwarden; alpine has wget, not curl).
- Week-folder layout with --link-dest rotation: rsync pattern, closer
to the PVE daily-backup script than mysql's single-file gzip dumps.
[ci skip]
Closes: code-z26
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:26:08 +00:00
|
|
|
_account_set = keys(var.mailserver_accounts)
|
[mailserver] Filter redundant local→local aliases to fix Dovecot 'exists more than once'
## Context
Dovecot auth logs have been steadily spamming
`passwd-file /etc/dovecot/userdb: User r730-idrac@viktorbarzin.me exists more
than once` (and the same for vaultwarden@) at ~31 occurrences per 500 log
lines. Under load this flakes IMAP auth for the e2e email-roundtrip probe
(spam@viktorbarzin.me uses the catch-all), which was masquerading as "Brevo
or probe timing" noise.
## Root cause
docker-mailserver builds Dovecot's `/etc/dovecot/userdb` from two sources:
real accounts (`postfix-accounts.cf`) AND virtual-alias entries whose
*target* resolves to a local mailbox (`postfix-virtual.cf`). When the same
address appears as BOTH a real mailbox AND an alias whose target is another
local mailbox, the generated userdb has two lines for that username pointing
to different home directories — e.g.:
r730-idrac@viktorbarzin.me:...:/var/mail/.../r730-idrac/home
r730-idrac@viktorbarzin.me:...:/var/mail/.../spam/home ← from alias
Dovecot's passwd-file driver rejects the duplicate, and every subsequent
auth lookup logs the error.
This affected exactly two addresses:
- r730-idrac@viktorbarzin.me (real account + alias → spam@)
- vaultwarden@viktorbarzin.me (real account + alias → me@)
Other aliases are fine: they either forward to external addresses (gmail
etc.) — no local userdb entry generated — or map an address to itself
(me@ → me@) which docker-mailserver dedups internally.
Note: removing the real accounts is not an option because Vaultwarden uses
`vaultwarden@viktorbarzin.me` as its live SMTP_USERNAME
(stacks/vaultwarden/modules/vaultwarden/main.tf:121).
## This change
Introduces a `local.postfix_virtual` that concatenates the Vault-sourced
aliases with `extra/aliases.txt`, then filters out any line matching the
exact "LHS RHS" shape where both sides are in `var.mailserver_accounts` and
LHS != RHS. That is, only the pure local→local redundant entries are
dropped; all forwarding aliases and the catch-all are preserved.
The filter is self-healing: if a future alias ever collides with a real
account, it gets silently suppressed instead of breaking Dovecot auth.
```
Vault mailserver_aliases ─┐
├─ concat ─ split \n ─ filter ─ join \n ─► postfix-virtual.cf
extra/aliases.txt ─────────┘ │
└── drop if LHS+RHS both in
mailserver_accounts and
LHS != RHS
```
Filtered entries (confirmed via locally-simulated filter on live data):
- r730-idrac@viktorbarzin.me spam@viktorbarzin.me
- vaultwarden@viktorbarzin.me me@viktorbarzin.me
Preserved (sample): postmaster→me, contact→me, alarm-valchedrym→self+3 ext,
lubohristov→gmail, yoana→gmail, @viktorbarzin.me→spam (catch-all), all four
disposable `*-generated@` aliases.
## What is NOT in this change
- Real accounts in Vault (`secret/platform.mailserver_accounts`) are
untouched — vaultwarden SMTP auth keeps working.
- Postfix postscreen btree lock contention (separate commit).
- Email-roundtrip probe IMAP window (separate commit).
## Test Plan
### Automated
`terraform validate` — passes (docker-mailserver module):
```
Success! The configuration is valid, but there were some validation warnings as shown above.
```
`scripts/tg plan -target=module.mailserver.kubernetes_config_map.mailserver_config`:
```
# module.mailserver.kubernetes_config_map.mailserver_config will be updated in-place
~ resource "kubernetes_config_map" "mailserver_config" {
~ data = {
~ "postfix-virtual.cf" = (sensitive value)
# (9 unchanged elements hidden)
}
id = "mailserver/mailserver.config"
}
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply` — applied:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
Post-apply configmap content (the two lines are gone):
```
$ kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'
postmaster@viktorbarzin.me me@viktorbarzin.me
contact@viktorbarzin.me me@viktorbarzin.me
me@viktorbarzin.me me@viktorbarzin.me
lubohristov@viktorbarzin.me lyubomir.hristov3@gmail.com
alarm-valchedrym@viktorbarzin.me alarm-valchedrym@...,vbarzin@...,emil.barzin@...,me@...
yoana@viktorbarzin.me divcheva.yoana@gmail.com
@viktorbarzin.me spam@viktorbarzin.me
firmly-gerardo-generated@viktorbarzin.me me@viktorbarzin.me
closely-keith-generated@viktorbarzin.me vbarzin@gmail.com
literally-paolo-generated@viktorbarzin.me viktorbarzin@fb.com
hastily-stefanie-generated@viktorbarzin.me elliestamenova@gmail.com
```
Reloader triggers a pod rollout; once new pod is Ready:
- `kubectl -n mailserver exec <pod> -c docker-mailserver -- cut -d: -f1 /etc/dovecot/userdb | sort | uniq -d`
expected output: empty (no duplicate usernames)
- `kubectl -n mailserver logs <pod> -c docker-mailserver --tail=500 | grep -c "exists more than once"`
expected output: 0 (baseline was 31/500 lines)
## Reproduce locally
1. `kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'`
2. Expect: no `r730-idrac@viktorbarzin.me spam@viktorbarzin.me` line and no
`vaultwarden@viktorbarzin.me me@viktorbarzin.me` line.
3. After pod restart: `kubectl -n mailserver logs -l app=mailserver -c docker-mailserver --tail=500 | grep -c "exists more than once"` → 0.
Closes: code-27l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:29:02 +00:00
|
|
|
_virtual_lines = split("\n", format("%s%s", var.postfix_account_aliases, file("${path.module}/extra/aliases.txt")))
|
|
|
|
|
postfix_virtual = join("\n", [
|
|
|
|
|
for line in local._virtual_lines : line
|
|
|
|
|
if !(
|
|
|
|
|
length(split(" ", line)) == 2 &&
|
|
|
|
|
contains(local._account_set, split(" ", line)[0]) &&
|
|
|
|
|
contains(local._account_set, split(" ", line)[1]) &&
|
|
|
|
|
split(" ", line)[0] != split(" ", line)[1]
|
|
|
|
|
)
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:34:11 +00:00
|
|
|
resource "kubernetes_namespace" "mailserver" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver"
|
|
|
|
|
labels = {
|
keel: enroll 15 critical-path namespaces for digest-only auto-update
Per user decision today: monitoring, mailserver, vault, descheduler,
metrics-server, traefik, technitium, crowdsec, redis, reverse-proxy,
reloader, headscale, wireguard, xray, cloudflared now participate in
the same `force + match-tag` regime as the rest of the cluster — Keel
watches the deployment's CURRENT tag for digest changes only and rolls
on push, never rewriting tag strings.
Two-part change:
stacks/kyverno/modules/kyverno/keel-annotations.tf
Trim the policy-level namespace exclude list from 31 → 16. The 16
remaining exclusions are the irreducible cluster-operator + state-
coupled set: keel itself, calico-system + tigera-operator (operator
loop), authentik (2026-05-17 pgbouncer incident bite), cnpg-system +
dbaas (state-coupled), kyverno, metallb-system, external-secrets,
proxmox-csi + nfs-csi + nvidia (just stabilized today, chart-pinned),
kube-system, vpa, sealed-secrets, infra-maintenance.
stacks/<each-of-15>/.../main.tf
Add `"keel.sh/enrolled" = "true"` label to the `kubernetes_namespace`
resource so the Kyverno mutate policy can target the workloads via
its namespaceSelector matchLabels.
Note on the apply path: the live ClusterPolicy was patched via
`kubectl patch` because the hashicorp/kubernetes provider v3.1.0 panics
during state refresh on Kyverno ClusterPolicy schemas with deeply
nested optional `context.celPreconditions` / `imageRegistry` fields
(see crash dump). The TF source above has the desired state, so any
clean future apply on a fixed provider version will be a no-op against
the live cluster.
Floating-tag workloads in the newly-enrolled set (will roll on every
upstream digest update — acceptable risk per user):
- wireguard: sclevine/wg:latest (image fixed today via iptables-nft
postStart shim)
- xray: teddysun/xray
- crowdsec-web: viktorbarzin/crowdsec_web
- monitoring: prompve/prometheus-pve-exporter:latest, prom/snmp-exporter
- traefik: nginx:1-alpine, openresty/openresty:alpine,
ghcr.io/tarampampam/error-pages:3
- redis: haproxy:3.1-alpine, redis:8-alpine
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 12:13:22 +00:00
|
|
|
tier = var.tier
|
|
|
|
|
"keel.sh/enrolled" = "true"
|
2026-03-17 21:34:11 +00:00
|
|
|
}
|
|
|
|
|
# connecting via localhost does not seem to work?
|
|
|
|
|
# labels = {
|
|
|
|
|
# "istio-injection" : "enabled"
|
|
|
|
|
# }
|
|
|
|
|
}
|
[infra] Suppress Goldilocks vpa-update-mode label drift on all namespaces [ci skip]
## Context
Wave 3B-continued: the Goldilocks VPA dashboard (stacks/vpa) runs a Kyverno
ClusterPolicy `goldilocks-vpa-auto-mode` that mutates every namespace with
`metadata.labels["goldilocks.fairwinds.com/vpa-update-mode"] = "off"`. This
is intentional — Terraform owns container resource limits, and Goldilocks
should only provide recommendations, never auto-update. The label is how
Goldilocks decides per-namespace whether to run its VPA in `off` mode.
Effect on Terraform: every `kubernetes_namespace` resource shows the label
as pending-removal (`-> null`) on every `scripts/tg plan`. Dawarich survey
2026-04-18 confirmed the drift. Cluster-side count: 88 namespaces carry the
label (`kubectl get ns -o json | jq ... | wc -l`). Every TF-managed namespace
is affected.
This commit brings the intentional admission drift under the same
`# KYVERNO_LIFECYCLE_V1` discoverability marker introduced in c9d221d5 for
the ndots dns_config pattern. The marker now stands generically for any
Kyverno admission-webhook drift suppression; the inline comment records
which specific policy stamps which specific field so future grep audits
show why each suppression exists.
## This change
107 `.tf` files touched — every stack's `resource "kubernetes_namespace"`
resource gets:
```hcl
lifecycle {
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
}
```
Injection was done with a brace-depth-tracking Python pass (`/tmp/add_goldilocks_ignore.py`):
match `^resource "kubernetes_namespace" ` → track `{` / `}` until the
outermost closing brace → insert the lifecycle block before the closing
brace. The script is idempotent (skips any file that already mentions
`goldilocks.fairwinds.com/vpa-update-mode`) so re-running is safe.
Vault stack picked up 2 namespaces in the same file (k8s-users produces
one, plus a second explicit ns) — confirmed via file diff (+8 lines).
## What is NOT in this change
- `stacks/trading-bot/main.tf` — entire file is `/* … */` commented out
(paused 2026-04-06 per user decision). Reverted after the script ran.
- `stacks/_template/main.tf.example` — per-stack skeleton, intentionally
minimal. User keeps it that way. Not touched by the script (file
has no real `resource "kubernetes_namespace"` — only a placeholder
comment).
- `.terraform/` copies (e.g. `stacks/metallb/.terraform/modules/...`) —
gitignored, won't commit; the live path was edited.
- `terraform fmt` cleanup of adjacent pre-existing alignment issues in
authentik, freedify, hermes-agent, nvidia, vault, meshcentral. Reverted
to keep the commit scoped to the Goldilocks sweep. Those files will
need a separate fmt-only commit or will be cleaned up on next real
apply to that stack.
## Verification
Dawarich (one of the hundred-plus touched stacks) showed the pattern
before and after:
```
$ cd stacks/dawarich && ../../scripts/tg plan
Before:
Plan: 0 to add, 2 to change, 0 to destroy.
# kubernetes_namespace.dawarich will be updated in-place
(goldilocks.fairwinds.com/vpa-update-mode -> null)
# module.tls_secret.kubernetes_secret.tls_secret will be updated in-place
(Kyverno generate.* labels — fixed in 8d94688d)
After:
No changes. Your infrastructure matches the configuration.
```
Injection count check:
```
$ rg -c 'KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode' stacks/ | awk -F: '{s+=$2} END {print s}'
108
```
## Reproduce locally
1. `git pull`
2. Pick any stack: `cd stacks/<name> && ../../scripts/tg plan`
3. Expect: no drift on the namespace's goldilocks.fairwinds.com/vpa-update-mode label.
Closes: code-dwx
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:15:27 +00:00
|
|
|
lifecycle {
|
|
|
|
|
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
|
|
|
|
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
module "tls_secret" {
|
|
|
|
|
source = "../../../../modules/kubernetes/setup_tls_secret"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
tls_secret_name = var.tls_secret_name
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_config_map" "mailserver_env_config" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver.env.config"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
annotations = {
|
|
|
|
|
"reloader.stakater.com/match" = "true"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
DMS_DEBUG = "0"
|
|
|
|
|
# LOG_LEVEL = "debug"
|
|
|
|
|
ENABLE_CLAMAV = "0"
|
|
|
|
|
ENABLE_AMAVIS = "0"
|
|
|
|
|
ENABLE_FAIL2BAN = "0"
|
|
|
|
|
ENABLE_FETCHMAIL = "0"
|
|
|
|
|
ENABLE_POSTGREY = "0"
|
|
|
|
|
ENABLE_SASLAUTHD = "0"
|
|
|
|
|
ENABLE_SPAMASSASSIN = "0"
|
|
|
|
|
ENABLE_RSPAMD = "1"
|
|
|
|
|
ENABLE_OPENDKIM = "0"
|
|
|
|
|
ENABLE_OPENDMARC = "0"
|
2026-04-05 21:44:52 +03:00
|
|
|
ENABLE_RSPAMD_REDIS = "0"
|
2026-03-17 21:34:11 +00:00
|
|
|
RSPAMD_LEARN = "1"
|
|
|
|
|
ENABLE_SRS = "1"
|
|
|
|
|
FETCHMAIL_POLL = "120"
|
|
|
|
|
ONE_DIR = "1"
|
|
|
|
|
OVERRIDE_HOSTNAME = "mail.viktorbarzin.me"
|
|
|
|
|
POSTFIX_MESSAGE_SIZE_LIMIT = 1024 * 1024 * 200 # 200 MB
|
|
|
|
|
POSTFIX_REJECT_UNKNOWN_CLIENT_HOSTNAME = "1"
|
|
|
|
|
# TLS_LEVEL = "intermediate"
|
|
|
|
|
# DEFAULT_RELAY_HOST = "[smtp.sendgrid.net]:587"
|
2026-04-12 22:24:38 +01:00
|
|
|
DEFAULT_RELAY_HOST = "[smtp-relay.brevo.com]:587"
|
2026-03-17 21:34:11 +00:00
|
|
|
SPOOF_PROTECTION = "1"
|
|
|
|
|
SSL_TYPE = "manual"
|
|
|
|
|
SSL_CERT_PATH = "/tmp/ssl/tls.crt"
|
|
|
|
|
SSL_KEY_PATH = "/tmp/ssl/tls.key"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_config_map" "mailserver_config" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver.config"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
|
|
|
|
|
labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
annotations = {
|
|
|
|
|
"reloader.stakater.com/match" = "true"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
# Actual mail settings
|
|
|
|
|
"postfix-accounts.cf" = join("\n", [for user, pass in var.mailserver_accounts : "${user}|${bcrypt(pass, 6)}"])
|
|
|
|
|
"postfix-main.cf" = var.postfix_cf
|
[mailserver] Filter redundant local→local aliases to fix Dovecot 'exists more than once'
## Context
Dovecot auth logs have been steadily spamming
`passwd-file /etc/dovecot/userdb: User r730-idrac@viktorbarzin.me exists more
than once` (and the same for vaultwarden@) at ~31 occurrences per 500 log
lines. Under load this flakes IMAP auth for the e2e email-roundtrip probe
(spam@viktorbarzin.me uses the catch-all), which was masquerading as "Brevo
or probe timing" noise.
## Root cause
docker-mailserver builds Dovecot's `/etc/dovecot/userdb` from two sources:
real accounts (`postfix-accounts.cf`) AND virtual-alias entries whose
*target* resolves to a local mailbox (`postfix-virtual.cf`). When the same
address appears as BOTH a real mailbox AND an alias whose target is another
local mailbox, the generated userdb has two lines for that username pointing
to different home directories — e.g.:
r730-idrac@viktorbarzin.me:...:/var/mail/.../r730-idrac/home
r730-idrac@viktorbarzin.me:...:/var/mail/.../spam/home ← from alias
Dovecot's passwd-file driver rejects the duplicate, and every subsequent
auth lookup logs the error.
This affected exactly two addresses:
- r730-idrac@viktorbarzin.me (real account + alias → spam@)
- vaultwarden@viktorbarzin.me (real account + alias → me@)
Other aliases are fine: they either forward to external addresses (gmail
etc.) — no local userdb entry generated — or map an address to itself
(me@ → me@) which docker-mailserver dedups internally.
Note: removing the real accounts is not an option because Vaultwarden uses
`vaultwarden@viktorbarzin.me` as its live SMTP_USERNAME
(stacks/vaultwarden/modules/vaultwarden/main.tf:121).
## This change
Introduces a `local.postfix_virtual` that concatenates the Vault-sourced
aliases with `extra/aliases.txt`, then filters out any line matching the
exact "LHS RHS" shape where both sides are in `var.mailserver_accounts` and
LHS != RHS. That is, only the pure local→local redundant entries are
dropped; all forwarding aliases and the catch-all are preserved.
The filter is self-healing: if a future alias ever collides with a real
account, it gets silently suppressed instead of breaking Dovecot auth.
```
Vault mailserver_aliases ─┐
├─ concat ─ split \n ─ filter ─ join \n ─► postfix-virtual.cf
extra/aliases.txt ─────────┘ │
└── drop if LHS+RHS both in
mailserver_accounts and
LHS != RHS
```
Filtered entries (confirmed via locally-simulated filter on live data):
- r730-idrac@viktorbarzin.me spam@viktorbarzin.me
- vaultwarden@viktorbarzin.me me@viktorbarzin.me
Preserved (sample): postmaster→me, contact→me, alarm-valchedrym→self+3 ext,
lubohristov→gmail, yoana→gmail, @viktorbarzin.me→spam (catch-all), all four
disposable `*-generated@` aliases.
## What is NOT in this change
- Real accounts in Vault (`secret/platform.mailserver_accounts`) are
untouched — vaultwarden SMTP auth keeps working.
- Postfix postscreen btree lock contention (separate commit).
- Email-roundtrip probe IMAP window (separate commit).
## Test Plan
### Automated
`terraform validate` — passes (docker-mailserver module):
```
Success! The configuration is valid, but there were some validation warnings as shown above.
```
`scripts/tg plan -target=module.mailserver.kubernetes_config_map.mailserver_config`:
```
# module.mailserver.kubernetes_config_map.mailserver_config will be updated in-place
~ resource "kubernetes_config_map" "mailserver_config" {
~ data = {
~ "postfix-virtual.cf" = (sensitive value)
# (9 unchanged elements hidden)
}
id = "mailserver/mailserver.config"
}
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply` — applied:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
Post-apply configmap content (the two lines are gone):
```
$ kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'
postmaster@viktorbarzin.me me@viktorbarzin.me
contact@viktorbarzin.me me@viktorbarzin.me
me@viktorbarzin.me me@viktorbarzin.me
lubohristov@viktorbarzin.me lyubomir.hristov3@gmail.com
alarm-valchedrym@viktorbarzin.me alarm-valchedrym@...,vbarzin@...,emil.barzin@...,me@...
yoana@viktorbarzin.me divcheva.yoana@gmail.com
@viktorbarzin.me spam@viktorbarzin.me
firmly-gerardo-generated@viktorbarzin.me me@viktorbarzin.me
closely-keith-generated@viktorbarzin.me vbarzin@gmail.com
literally-paolo-generated@viktorbarzin.me viktorbarzin@fb.com
hastily-stefanie-generated@viktorbarzin.me elliestamenova@gmail.com
```
Reloader triggers a pod rollout; once new pod is Ready:
- `kubectl -n mailserver exec <pod> -c docker-mailserver -- cut -d: -f1 /etc/dovecot/userdb | sort | uniq -d`
expected output: empty (no duplicate usernames)
- `kubectl -n mailserver logs <pod> -c docker-mailserver --tail=500 | grep -c "exists more than once"`
expected output: 0 (baseline was 31/500 lines)
## Reproduce locally
1. `kubectl -n mailserver get cm mailserver.config -o jsonpath='{.data.postfix-virtual\.cf}'`
2. Expect: no `r730-idrac@viktorbarzin.me spam@viktorbarzin.me` line and no
`vaultwarden@viktorbarzin.me me@viktorbarzin.me` line.
3. After pod restart: `kubectl -n mailserver logs -l app=mailserver -c docker-mailserver --tail=500 | grep -c "exists more than once"` → 0.
Closes: code-27l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:29:02 +00:00
|
|
|
"postfix-virtual.cf" = local.postfix_virtual
|
2026-03-17 21:34:11 +00:00
|
|
|
|
|
|
|
|
KeyTable = "mail._domainkey.viktorbarzin.me viktorbarzin.me:mail:/etc/opendkim/keys/viktorbarzin.me-mail.key\n"
|
|
|
|
|
SigningTable = "*@viktorbarzin.me mail._domainkey.viktorbarzin.me\n"
|
|
|
|
|
TrustedHosts = "127.0.0.1\nlocalhost\n"
|
|
|
|
|
"sasl_passwd" = var.sasl_passwd
|
|
|
|
|
# Rspamd DKIM signing configuration
|
|
|
|
|
"dkim_signing.conf" = <<-EOF
|
|
|
|
|
enabled = true;
|
|
|
|
|
sign_authenticated = true;
|
|
|
|
|
sign_local = true;
|
|
|
|
|
use_domain = "header";
|
|
|
|
|
use_redis = false;
|
|
|
|
|
use_esld = true;
|
|
|
|
|
selector = "mail";
|
|
|
|
|
path = "/tmp/docker-mailserver/rspamd/dkim/viktorbarzin.me/mail.private";
|
|
|
|
|
domain {
|
|
|
|
|
viktorbarzin.me {
|
|
|
|
|
path = "/tmp/docker-mailserver/rspamd/dkim/viktorbarzin.me/mail.private";
|
|
|
|
|
selector = "mail";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
EOF
|
2026-04-05 23:02:50 +03:00
|
|
|
# Increase max IMAP connections per user+IP - all Roundcube connections come from same pod IP
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
"dovecot.cf" = <<-EOF
|
2026-04-05 23:02:50 +03:00
|
|
|
mail_max_userip_connections = 50
|
2026-04-19 10:33:05 +00:00
|
|
|
# Throttle IMAP auth brute-force. CrowdSec handles the network-level
|
|
|
|
|
# ban, this adds defense in depth at the auth layer — each failed
|
|
|
|
|
# attempt waits 5s before responding, stretching a 1000-password
|
|
|
|
|
# dictionary attack from <1s to ~85min. Addresses code-9mi.
|
|
|
|
|
auth_failure_delay = 5s
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
|
|
|
|
|
# code-yiu Phase 5: alt IMAPS listener on :10993 that REQUIRES the
|
|
|
|
|
# HAProxy PROXY v2 wire format. pfSense HAProxy injects the header
|
|
|
|
|
# on backend connects via k8s-node:30128 → kube-proxy → pod :10993.
|
|
|
|
|
# Real client IP recovered from header despite kube-proxy SNAT.
|
|
|
|
|
# The stock :993 listener stays PROXY-free for internal clients
|
|
|
|
|
# (Roundcube, email-roundtrip-monitor) on the mailserver ClusterIP.
|
|
|
|
|
# haproxy_trusted_networks = source IPs allowed to *send* PROXY v2.
|
|
|
|
|
# Post kube-proxy SNAT the source is the k8s node IP (10.0.20.101-104);
|
|
|
|
|
# allow-list the whole VLAN 20 node subnet.
|
|
|
|
|
haproxy_trusted_networks = 10.0.20.0/24
|
|
|
|
|
service imap-login {
|
|
|
|
|
inet_listener imaps_proxy {
|
|
|
|
|
port = 10993
|
|
|
|
|
ssl = yes
|
|
|
|
|
haproxy = yes
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-05 23:02:50 +03:00
|
|
|
EOF
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
fail2ban_conf = <<-EOF
|
2026-03-17 21:34:11 +00:00
|
|
|
[DEFAULT]
|
|
|
|
|
|
|
|
|
|
#logtarget = /var/log/fail2ban.log
|
|
|
|
|
logtarget = SYSOUT
|
|
|
|
|
EOF
|
|
|
|
|
}
|
2026-04-19 10:33:57 +00:00
|
|
|
# bcrypt() generates a fresh salt on every evaluation, so the hash line
|
|
|
|
|
# differs each plan run. ignore_changes is the pragmatic workaround.
|
|
|
|
|
#
|
|
|
|
|
# INVARIANT (code-7ns, decision 2026-04-19): if a password in Vault
|
|
|
|
|
# (secret/platform.mailserver_accounts) is rotated, ignore_changes WILL
|
|
|
|
|
# mask that rotation — TF will not re-render the ConfigMap and the pod
|
|
|
|
|
# will keep accepting the old password until the ConfigMap is force-
|
|
|
|
|
# taintned (`terraform taint module.mailserver.kubernetes_config_map
|
|
|
|
|
# .postfix-accounts-cf`) or the resource is addressed explicitly on
|
|
|
|
|
# apply (`-replace=...`). Currently there is NO automatic Vault
|
|
|
|
|
# rotation for mailserver_accounts, so this is acceptable. If automatic
|
|
|
|
|
# rotation is ever added, replace this ignore_changes with either:
|
|
|
|
|
# (a) deterministic hashing (bcrypt with a stable salt derived from
|
|
|
|
|
# the user string — loses per-user salt uniqueness but keeps TF
|
|
|
|
|
# convergent), or
|
|
|
|
|
# (b) render postfix-accounts.cf from a K8s Secret synced by ESO
|
|
|
|
|
# (CRD consumed by a dedicated volume mount; docker-mailserver
|
|
|
|
|
# loads it at pod start).
|
2026-03-17 21:34:11 +00:00
|
|
|
lifecycle {
|
2026-04-18 14:08:10 +00:00
|
|
|
# DRIFT_WORKAROUND: postfix-accounts.cf password hashes non-deterministic; would flap on every apply. Reviewed 2026-04-18.
|
2026-03-17 21:34:11 +00:00
|
|
|
ignore_changes = [data["postfix-accounts.cf"]]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
# code-yiu Phase 1a: user-patches.sh appends alt PROXY-speaking listeners to
|
|
|
|
|
# Postfix master.cf at container startup. docker-mailserver runs
|
|
|
|
|
# /tmp/docker-mailserver/user-patches.sh after initial config generation, so
|
|
|
|
|
# our append lands on every fresh pod. Idempotent guard prevents double-append
|
|
|
|
|
# on in-place container restarts. Dovecot extensions are in the dovecot.cf
|
|
|
|
|
# ConfigMap entry (no patches.sh entry needed).
|
|
|
|
|
resource "kubernetes_config_map" "mailserver_user_patches" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver-user-patches"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
annotations = {
|
|
|
|
|
"reloader.stakater.com/match" = "true"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data = {
|
|
|
|
|
"user-patches.sh" = <<-EOT
|
|
|
|
|
#!/bin/bash
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
# code-yiu Phase 5: append PROXY-speaking alt listeners to Postfix master.cf:
|
|
|
|
|
# :2525 postscreen (alt :25) — injected with PROXY v2 by pfSense HAProxy
|
|
|
|
|
# :4465 smtpd (alt :465 SMTPS) — ditto, wrappermode TLS
|
|
|
|
|
# :5587 smtpd (alt :587 submission) — ditto
|
|
|
|
|
# Stock :25/:465/:587 stay in parallel (no PROXY required) so internal
|
|
|
|
|
# Roundcube/probe traffic on mailserver.svc ClusterIP keeps working.
|
|
|
|
|
# Dovecot alt IMAPS listener on :10993 is configured via dovecot.cf
|
|
|
|
|
# (not here) because that's a Dovecot config, not a Postfix master.cf.
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
set -euxo pipefail
|
|
|
|
|
MASTER_CF=/etc/postfix/master.cf
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
SENTINEL='# code-yiu:alt-proxy'
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
if ! grep -qF "$SENTINEL" "$MASTER_CF"; then
|
|
|
|
|
cat >> "$MASTER_CF" <<'PFXEOF'
|
|
|
|
|
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
# code-yiu:alt-proxy — PROXY-speaking alt listeners for pfSense HAProxy backend pool.
|
|
|
|
|
# Mirrors stock docker-mailserver submission/submissions options (incl. SASL via
|
|
|
|
|
# Dovecot's /dev/shm/sasl-auth.sock) but with PROXY v2 upstream. chroot=n so the
|
|
|
|
|
# SASL path is readable from the smtpd process (sockets live outside /var/spool).
|
|
|
|
|
2525 inet n - n - 1 postscreen
|
|
|
|
|
-o syslog_name=postfix/smtpd-proxy25
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
-o postscreen_upstream_proxy_protocol=haproxy
|
|
|
|
|
-o postscreen_upstream_proxy_timeout=5s
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
4465 inet n - n - - smtpd
|
|
|
|
|
-o syslog_name=postfix/smtpd-proxy465
|
|
|
|
|
-o smtpd_tls_wrappermode=yes
|
|
|
|
|
-o smtpd_sasl_auth_enable=yes
|
|
|
|
|
-o smtpd_sasl_type=dovecot
|
|
|
|
|
-o smtpd_tls_auth_only=yes
|
|
|
|
|
-o smtpd_reject_unlisted_recipient=no
|
|
|
|
|
-o smtpd_sasl_authenticated_header=yes
|
|
|
|
|
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
|
|
|
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
|
|
|
|
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
|
|
|
|
-o smtpd_discard_ehlo_keywords=
|
|
|
|
|
-o milter_macro_daemon_name=ORIGINATING
|
|
|
|
|
-o cleanup_service_name=sender-cleanup
|
|
|
|
|
-o smtpd_upstream_proxy_protocol=haproxy
|
|
|
|
|
-o smtpd_upstream_proxy_timeout=5s
|
|
|
|
|
5587 inet n - n - - smtpd
|
|
|
|
|
-o syslog_name=postfix/smtpd-proxy587
|
|
|
|
|
-o smtpd_tls_security_level=encrypt
|
|
|
|
|
-o smtpd_sasl_auth_enable=yes
|
|
|
|
|
-o smtpd_sasl_type=dovecot
|
|
|
|
|
-o smtpd_tls_auth_only=yes
|
|
|
|
|
-o smtpd_reject_unlisted_recipient=no
|
|
|
|
|
-o smtpd_sasl_authenticated_header=yes
|
|
|
|
|
-o smtpd_client_restrictions=permit_sasl_authenticated,reject
|
|
|
|
|
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
|
|
|
|
|
-o smtpd_sender_restrictions=$mua_sender_restrictions
|
|
|
|
|
-o smtpd_discard_ehlo_keywords=
|
|
|
|
|
-o milter_macro_daemon_name=ORIGINATING
|
|
|
|
|
-o cleanup_service_name=sender-cleanup
|
|
|
|
|
-o smtpd_upstream_proxy_protocol=haproxy
|
|
|
|
|
-o smtpd_upstream_proxy_timeout=5s
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
PFXEOF
|
|
|
|
|
fi
|
|
|
|
|
EOT
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
|
|
|
|
|
resource "kubernetes_secret" "opendkim_key" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver.opendkim.key"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
"app" = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
type = "Opaque"
|
|
|
|
|
data = {
|
|
|
|
|
"viktorbarzin.me-mail.key" = var.opendkim_key
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
resource "kubernetes_persistent_volume_claim" "data_encrypted" {
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
wait_until_bound = false
|
|
|
|
|
metadata {
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
name = "mailserver-data-encrypted"
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
annotations = {
|
2026-05-10 19:56:16 +00:00
|
|
|
"resize.topolvm.io/threshold" = "10%"
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
"resize.topolvm.io/increase" = "100%"
|
2026-05-09 10:49:17 +00:00
|
|
|
"resize.topolvm.io/storage_limit" = "10Gi"
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
access_modes = ["ReadWriteOnce"]
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
storage_class_name = "proxmox-lvm-encrypted"
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
resources {
|
|
|
|
|
requests = {
|
2026-05-09 10:49:17 +00:00
|
|
|
storage = "5Gi"
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-09 10:49:17 +00:00
|
|
|
lifecycle {
|
|
|
|
|
# pvc-autoresizer expands this PVC up to storage_limit; ignore drift on
|
|
|
|
|
# requests.storage. To bump the floor manually: temporarily remove this
|
|
|
|
|
# block, apply the new size, re-add the block, apply again.
|
|
|
|
|
ignore_changes = [spec[0].resources[0].requests]
|
|
|
|
|
}
|
feat(storage): migrate 38 NFS PVCs to proxmox-lvm (Wave 2)
Add proxmox-lvm PVCs with pvc-autoresizer annotations for all
remaining single-pod app data services. Deployments updated to
use new block storage PVCs. Old NFS modules retained for rollback.
Services: affine, changedetection, diun, excalidraw, f1-stream,
hackmd, isponsorblocktv, matrix, n8n, send, grampsweb, health,
onlyoffice, owntracks, paperless-ngx, privatebin, resume,
speedtest, stirling-pdf, tandoor, rybbit (clickhouse), tor-proxy
(torrserver), whisper+piper, frigate (config), ollama (ui),
servarr (prowlarr/listenarr/qbittorrent), aiostreams, freshrss
(extensions), meshcentral (data+files), openclaw (data+home+
openlobster), technitium, mailserver (data+roundcube html+enigma),
dbaas (pgadmin).
Strategy set to Recreate where needed for RWO volumes.
2026-04-04 19:25:12 +03:00
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:34:11 +00:00
|
|
|
resource "kubernetes_deployment" "mailserver" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
"app" = "mailserver"
|
|
|
|
|
tier = var.tier
|
|
|
|
|
}
|
|
|
|
|
annotations = {
|
|
|
|
|
"reloader.stakater.com/search" = "true"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
replicas = "1"
|
|
|
|
|
strategy {
|
|
|
|
|
type = "Recreate"
|
|
|
|
|
}
|
|
|
|
|
selector {
|
|
|
|
|
match_labels = {
|
|
|
|
|
"app" = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
template {
|
|
|
|
|
metadata {
|
|
|
|
|
annotations = {
|
2026-04-19 10:26:31 +00:00
|
|
|
"diun.enable" = "true"
|
|
|
|
|
"diun.include_tags" = "^latest$"
|
2026-03-17 21:34:11 +00:00
|
|
|
}
|
|
|
|
|
labels = {
|
|
|
|
|
"app" = "mailserver"
|
|
|
|
|
"role" = "mail"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
container {
|
|
|
|
|
name = "docker-mailserver"
|
|
|
|
|
image = "docker.io/mailserver/docker-mailserver:15.0.0"
|
|
|
|
|
image_pull_policy = "IfNotPresent"
|
[mailserver] Drop unneeded NET_ADMIN capability [ci skip]
## Context
The mailserver container had `capabilities.add = ["NET_ADMIN"]`. Upstream
docker-mailserver docs say the capability is only needed by Fail2ban to
run iptables ban actions. Fail2ban is DISABLED in this stack
(`ENABLE_FAIL2BAN=0`, see line ~68) — CrowdSec owns the brute-force
policy at the LB layer. The capability was therefore unused ballast and
a minor attack-surface reduction opportunity. Addresses code-4mu.
## This change
Replaces the explicit `capabilities { add = ["NET_ADMIN"] }` block with
an empty `security_context {}`. Post-rollout verification
(`supervisorctl status`) confirms every service we actually run is
healthy — dovecot, postfix, rspamd, rsyslog, postsrsd, changedetector,
cron, mailserver. Every STOPPED entry was already disabled.
The inline comment documents the revert trigger: check
`kubectl logs -c docker-mailserver` for permission-denied patterns and
restore the capability if observed.
## Test Plan
### Automated
```
$ kubectl get pod -n mailserver -l app=mailserver -o jsonpath='{.items[0].spec.containers[?(@.name=="docker-mailserver")].securityContext}'
{"allowPrivilegeEscalation":true,"privileged":false,"readOnlyRootFilesystem":false,"runAsNonRoot":false}
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
supervisorctl status | grep RUNNING
changedetector RUNNING ...
cron RUNNING ...
dovecot RUNNING ...
mailserver RUNNING ...
postfix RUNNING ...
postsrsd RUNNING ...
rspamd RUNNING ...
rsyslog RUNNING ...
```
### Observation window
EmailRoundtripFailing + EmailRoundtripStale alerts continue to run
every 20 min. If no alert fires in the 24h post-rollout window
(through ~2026-04-20 10:40 UTC), the change is considered safe and
this commit stands. Otherwise revert this commit.
## What is NOT in this change
- readOnlyRootFilesystem (separate hardening, out of scope)
- runAsNonRoot (docker-mailserver needs root for Postfix)
- Removing privilege-escalation defaults (container needs those for
chowning mail spool at startup)
Closes: code-4mu
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:39:43 +00:00
|
|
|
# NET_ADMIN was originally required by docker-mailserver's
|
|
|
|
|
# Fail2ban (iptables ban actions). Fail2ban is DISABLED in this
|
|
|
|
|
# stack (ENABLE_FAIL2BAN=0, see above) — CrowdSec owns the
|
|
|
|
|
# brute-force policy. The capability is therefore unnecessary.
|
|
|
|
|
# Dropping it 2026-04-19 (code-4mu). If mail flow regresses,
|
|
|
|
|
# `kubectl logs -n mailserver -l app=mailserver -c docker-mailserver`
|
|
|
|
|
# will show permission-denied errors — revert if observed.
|
|
|
|
|
security_context {}
|
2026-03-17 21:34:11 +00:00
|
|
|
|
|
|
|
|
lifecycle {
|
|
|
|
|
post_start {
|
|
|
|
|
exec {
|
|
|
|
|
command = [
|
|
|
|
|
"postmap",
|
|
|
|
|
"/etc/postfix/sasl/passwd"
|
|
|
|
|
# "/bin/sh",
|
|
|
|
|
# "-c",
|
|
|
|
|
# "cp -f /tmp/user-patches.sh /tmp/docker-mailserver/user-patches.sh && chown root:root /var/log/mail && chmod 755 /var/log/mail",
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config-tls"
|
|
|
|
|
mount_path = "/tmp/ssl/tls.key"
|
|
|
|
|
sub_path = "tls.key"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config-tls"
|
|
|
|
|
mount_path = "/tmp/ssl/tls.crt"
|
|
|
|
|
sub_path = "tls.crt"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/postfix-accounts.cf"
|
|
|
|
|
sub_path = "postfix-accounts.cf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/postfix-main.cf"
|
|
|
|
|
sub_path = "postfix-main.cf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/postfix-virtual.cf"
|
|
|
|
|
sub_path = "postfix-virtual.cf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/fetchmail.cf"
|
|
|
|
|
sub_path = "fetchmail.cf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
2026-04-05 23:02:50 +03:00
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/dovecot.cf"
|
|
|
|
|
sub_path = "dovecot.cf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
# volume_mount {
|
|
|
|
|
# name = "user-patches"
|
|
|
|
|
# mount_path = "/tmp/user-patches.sh"
|
|
|
|
|
# sub_path = "user-patches.sh"
|
|
|
|
|
# read_only = true
|
|
|
|
|
# }
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/opendkim/SigningTable"
|
|
|
|
|
sub_path = "SigningTable"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/opendkim/KeyTable"
|
|
|
|
|
sub_path = "KeyTable"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/opendkim/TrustedHosts"
|
|
|
|
|
sub_path = "TrustedHosts"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "opendkim-key"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/opendkim/keys"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "opendkim-key"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/rspamd/dkim/viktorbarzin.me/mail.private"
|
|
|
|
|
sub_path = "viktorbarzin.me-mail.key"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/rspamd/override.d/dkim_signing.conf"
|
|
|
|
|
sub_path = "dkim_signing.conf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/mail"
|
|
|
|
|
sub_path = "data"
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/mail-state"
|
|
|
|
|
sub_path = "state"
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/log/mail"
|
|
|
|
|
sub_path = "log"
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "var-run-dovecot"
|
|
|
|
|
mount_path = "/var/run/dovecot"
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/etc/postfix/sasl/passwd"
|
|
|
|
|
sub_path = "sasl_passwd"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "config"
|
|
|
|
|
mount_path = "/etc/fail2ban/fail2ban.local"
|
|
|
|
|
sub_path = "fail2ban_conf"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
# code-yiu Phase 1a: user-patches.sh runs at container startup to
|
|
|
|
|
# append PROXY-speaking listeners to master.cf (see
|
|
|
|
|
# kubernetes_config_map.mailserver_user_patches).
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "user-patches"
|
|
|
|
|
mount_path = "/tmp/docker-mailserver/user-patches.sh"
|
|
|
|
|
sub_path = "user-patches.sh"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
port {
|
|
|
|
|
name = "smtp"
|
|
|
|
|
container_port = 25
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-secure"
|
|
|
|
|
container_port = 465
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-auth"
|
|
|
|
|
container_port = 587
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "imap-secure"
|
|
|
|
|
container_port = 993
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
# code-yiu Phase 5: alt PROXY-speaking listeners.
|
|
|
|
|
# Postfix: 2525 (postscreen), 4465 (smtps), 5587 (submission).
|
|
|
|
|
# Dovecot: 10993 (imaps). All require PROXY v2 from pfSense HAProxy.
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
port {
|
|
|
|
|
name = "smtp-proxy"
|
|
|
|
|
container_port = 2525
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
port {
|
|
|
|
|
name = "smtps-proxy"
|
|
|
|
|
container_port = 4465
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "sub-proxy"
|
|
|
|
|
container_port = 5587
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "imaps-proxy"
|
|
|
|
|
container_port = 10993
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
env_from {
|
|
|
|
|
config_map_ref {
|
|
|
|
|
name = "mailserver.env.config"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resources {
|
|
|
|
|
requests = {
|
|
|
|
|
cpu = "25m"
|
|
|
|
|
memory = "512Mi"
|
|
|
|
|
}
|
|
|
|
|
limits = {
|
|
|
|
|
memory = "512Mi"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[mailserver] Add liveness/readiness TCP probes [ci skip]
## Context
The mailserver container (Postfix + Dovecot in one pod) had no liveness, readiness, or startup probes declared. If either daemon deadlocked or hung on a socket, Kubernetes had no way to detect it and restart. The only external canary was the email-roundtrip-monitor CronJob which runs on a 20-minute interval, giving a detection lag of 20-60 minutes — long enough for real delivery failures before an alert fires.
Tracked as bd code-ekf out of the mailserver probe audit. Both port 25 (SMTP) and port 993 (IMAPS) are cheap, reliable up-signals — the existing e2e probe already hits IMAPS, so TCP probes on those ports are a close proxy for user-visible service health without the cost of full SMTP/IMAP handshakes every 10s.
## This change
Adds a readiness_probe (TCP :25, initial_delay=30s, period=10s) and a liveness_probe (TCP :993, initial_delay=60s, period=60s, timeout=15s) to the mailserver deployment's primary container.
Design choices:
- **TCP over exec/HTTP**: the daemons do not expose HTTP health; exec probes would require shelling into the container with auth for SMTP/IMAP banner checks, which is both costly and flaky. TCP accept is sufficient — if postfix cannot accept a TCP connection on :25 it is unambiguously broken.
- **Split ports per probe**: readiness on :25 (the public SMTP surface — if this is down, external delivery is broken) and liveness on :993 (IMAPS, the other critical daemon — catches Dovecot deadlocks independently of Postfix).
- **30s readiness delay**: Postfix needs ~20-30s to warm up including chroot setup and DKIM key loading; probing earlier would cause bogus NotReady cycles on deploy.
- **60s liveness delay + 60s period + 15s timeout**: generous so transient blips (brief CPU spike, RBL timeout, slow NFS unmount during rotation) do not trigger a restart loop. With failure_threshold=3 (default), a real deadlock is detected in ~3 minutes; false positives on transient load are suppressed.
- **No startup_probe**: the 60s liveness initial_delay is enough cover for the warmup window; adding a startup probe would be redundant machinery.
## What is NOT in this change
- No startup_probe (liveness initial_delay_seconds=60 handles warmup)
- No exec-based probes (banner-check probes are out of scope and not needed)
- No changes to the opendkim or other sidecars
- Pre-existing drift in other stacks (dawarich namespace label, owntracks dawarich-hook wiring) is deliberately left out — those are separate workstreams
## Test Plan
### Automated
Applied via `tg apply -target=kubernetes_deployment.mailserver` before this commit. Current pod state:
```
$ kubectl get pod -n mailserver -l app=mailserver
NAME READY STATUS RESTARTS AGE
mailserver-6c6bf77ffb-w7nl5 2/2 Running 0 2m26s
$ kubectl describe pod -n mailserver -l app=mailserver | grep -E "(Liveness|Readiness|Restart Count|Status:|Ready:)"
Status: Running
Ready: True
Restart Count: 0
Ready: True
Restart Count: 0
Liveness: tcp-socket :993 delay=60s timeout=15s period=60s #success=1 #failure=3
Readiness: tcp-socket :25 delay=30s timeout=1s period=10s #success=1 #failure=3
```
Pod has run >120s (two full liveness cycles) with RESTARTS=0 and Ready=True.
### Manual Verification
1. Confirm probes are declared on the live pod:
```
kubectl describe pod -n mailserver -l app=mailserver | grep -E "(Liveness|Readiness)"
```
Expected: `Liveness: tcp-socket :993 ...` and `Readiness: tcp-socket :25 ...`
2. Confirm pod stays Ready under normal load for 5+ minutes:
```
kubectl get pod -n mailserver -l app=mailserver -w
```
Expected: RESTARTS stays at 0, READY stays at 2/2.
3. (Optional) Failure-simulate by dropping :993 inside the pod and observing liveness failure + restart within ~3 minutes (3 × period_seconds).
## Reproduce locally
1. `cd infra/stacks/mailserver`
2. `tg plan -target=kubernetes_deployment.mailserver`
3. Expected: no drift (or only the probe additions if rolling forward a stale state)
4. `kubectl get pod -n mailserver -l app=mailserver` — pod Ready, RESTARTS=0
5. `kubectl describe pod -n mailserver -l app=mailserver | grep -E "(Liveness|Readiness)"` — both probes present
Closes: code-ekf
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:45:17 +00:00
|
|
|
readiness_probe {
|
|
|
|
|
tcp_socket {
|
|
|
|
|
port = 25
|
|
|
|
|
}
|
|
|
|
|
initial_delay_seconds = 30
|
|
|
|
|
period_seconds = 10
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
liveness_probe {
|
|
|
|
|
tcp_socket {
|
|
|
|
|
port = 993
|
|
|
|
|
}
|
|
|
|
|
initial_delay_seconds = 60
|
|
|
|
|
period_seconds = 60
|
|
|
|
|
timeout_seconds = 15
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 21:34:11 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
volume {
|
|
|
|
|
name = "config"
|
|
|
|
|
config_map {
|
|
|
|
|
name = "mailserver.config"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "config-tls"
|
|
|
|
|
secret {
|
|
|
|
|
secret_name = var.tls_secret_name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "opendkim-key"
|
|
|
|
|
secret {
|
|
|
|
|
secret_name = "mailserver.opendkim.key"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "data"
|
|
|
|
|
persistent_volume_claim {
|
feat(storage): migrate all sensitive services to proxmox-lvm-encrypted
Reconcile Terraform with cluster state after manual encrypted PVC migrations
and complete the remaining unfinished migrations. All services storing
sensitive data now use LUKS2-encrypted block storage via the Proxmox CSI
plugin.
## Context
Only Technitium DNS was using encrypted storage in Terraform. Many services
had been manually migrated to encrypted PVCs in the cluster, but Terraform
was never updated — creating dangerous state drift where a `tg apply` could
recreate unencrypted PVCs.
## This change
Phase 0 — Infrastructure:
- Add `proxmox-lvm-encrypted` StorageClass to Helm values (extraParameters)
- Add ExternalSecret for LUKS encryption passphrase to Terraform
- Fix CSI node plugin memory: `node.plugin.resources` (not `node.resources`)
with 1280Mi limit for LUKS2 Argon2id key derivation
Phase 1 — TF state reconciliation (zero downtime):
- Health, Matrix, N8N, Forgejo, Vaultwarden, Mailserver: state rm + import
- Redis, DBAAS MySQL, DBAAS PostgreSQL: Helm/CNPG value updates
Phase 2 — Data migration (encrypted PVCs existed but unused):
- Headscale, Frigate, MeshCentral: rsync + switchover
- Nextcloud (20Gi): rsync + chart_values update
Phase 3 — New encrypted PVCs:
- Roundcube HTML, HackMD, Affine, DBAAS pgadmin: create + rsync + switchover
Phase 4 — Cleanup:
- Deleted 5 orphaned unencrypted PVCs
## Services migrated (18 PVCs across 14 namespaces)
```
vaultwarden → vaultwarden-data-encrypted
dbaas → datadir-mysql-cluster-0, pg-cluster-{1,2}, dbaas-pgadmin-encrypted
mailserver → mailserver-data-encrypted, roundcubemail-{enigma,html}-encrypted
nextcloud → nextcloud-data-encrypted
forgejo → forgejo-data-encrypted
matrix → matrix-data-encrypted
n8n → n8n-data-encrypted
affine → affine-data-encrypted
health → health-uploads-encrypted
hackmd → hackmd-data-encrypted
redis → redis-data-redis-node-{0,1}
headscale → headscale-data-encrypted
frigate → frigate-config-encrypted
meshcentral → meshcentral-{data,files}-encrypted
```
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:15:30 +00:00
|
|
|
claim_name = kubernetes_persistent_volume_claim.data_encrypted.metadata[0].name
|
2026-03-17 21:34:11 +00:00
|
|
|
}
|
|
|
|
|
# iscsi {
|
|
|
|
|
# target_portal = "iscsi.viktorbarzin.lan:3260"
|
|
|
|
|
# iqn = "iqn.2020-12.lan.viktorbarzin:storage:mailserver"
|
|
|
|
|
# lun = 0
|
|
|
|
|
# fs_type = "ext4"
|
|
|
|
|
# }
|
|
|
|
|
}
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
# code-yiu Phase 1a
|
|
|
|
|
volume {
|
|
|
|
|
name = "user-patches"
|
|
|
|
|
config_map {
|
|
|
|
|
name = kubernetes_config_map.mailserver_user_patches.metadata[0].name
|
|
|
|
|
default_mode = "0755"
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-17 21:34:11 +00:00
|
|
|
volume {
|
|
|
|
|
name = "var-run-dovecot"
|
|
|
|
|
empty_dir {}
|
|
|
|
|
}
|
|
|
|
|
dns_config {
|
|
|
|
|
option {
|
|
|
|
|
name = "ndots"
|
|
|
|
|
value = "2"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_service" "mailserver" {
|
[mailserver] Phase 6 — decommission MetalLB LB path [ci skip]
## Context (bd code-yiu)
With Phase 4+5 proven (external mail flows through pfSense HAProxy +
PROXY v2 to the alt PROXY-speaking container listeners), the MetalLB
LoadBalancer Service + `10.0.20.202` external IP + ETP:Local policy are
obsolete. Phase 6 decommissions them and documents the steady-state
architecture.
## This change
### Terraform (stacks/mailserver/modules/mailserver/main.tf)
- `kubernetes_service.mailserver` downgraded: `LoadBalancer` → `ClusterIP`.
- Removed `metallb.io/loadBalancerIPs = "10.0.20.202"` annotation.
- Removed `external_traffic_policy = "Local"` (irrelevant for ClusterIP).
- Port set unchanged — the Service still exposes 25/465/587/993 for
intra-cluster clients (Roundcube pod, `email-roundtrip-monitor`
CronJob) that hit the stock PROXY-free container listeners.
- Inline comment documents the downgrade rationale + companion
`mailserver-proxy` NodePort Service that now carries external traffic.
### pfSense (ops, not in git)
- `mailserver` host alias (pointing at `10.0.20.202`) deleted. No NAT
rule references it post-Phase-4; keeping it would be misleading dead
metadata. Reversible via WebUI + `php /tmp/delete-mailserver-alias.php`
companion script (ad-hoc, not checked in — alias is just a
Firewall → Aliases → Hosts entry).
### Uptime Kuma (ops)
- Monitors `282` and `283` (PORT checks) retargeted from `10.0.20.202`
→ `10.0.20.1`. Renamed to `Mailserver HAProxy SMTP (pfSense :25)` /
`... IMAPS (pfSense :993)` to reflect their new purpose (HAProxy
layer liveness). History retained (edit, not delete-recreate).
### Docs
- `docs/runbooks/mailserver-pfsense-haproxy.md` — fully rewritten
"Current state" section; now reflects steady-state architecture with
two-path diagram (external via HAProxy / intra-cluster via ClusterIP).
Phase history table marks Phase 6 ✅. Rollback section updated (no
one-liner post-Phase-6; need Service-type re-upgrade + alias re-add).
- `docs/architecture/mailserver.md` — Overview, Mermaid diagram, Inbound
flow, CrowdSec section, Uptime Kuma monitors list, Decisions section
(dedicated MetalLB IP → "Client-IP Preservation via HAProxy + PROXY
v2"), Troubleshooting all updated.
- `.claude/CLAUDE.md` — mailserver monitoring + architecture paragraph
updated with new external path description; references the new runbook.
## What is NOT in this change
- Removal of `10.0.20.202` from `cloudflare_proxied_names` or any
reserved-IP tracking — wasn't there to begin with. The
`metallb-system default` IPAddressPool (10.0.20.200-220) shows 2 of
19 available after this, confirming `.202` went back to the pool.
- Phase 4 NAT-flip rollback scripts — kept on-disk, still valid if
someone re-introduces the MetalLB LB (see runbook "Rollback").
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# Service is ClusterIP with no EXTERNAL-IP
$ kubectl get svc -n mailserver mailserver
mailserver ClusterIP 10.103.108.217 <none> 25/TCP,465/TCP,587/TCP,993/TCP
# 10.0.20.202 no longer answers ARP (ping from pfSense)
$ ssh admin@10.0.20.1 'ping -c 2 -t 2 10.0.20.202'
2 packets transmitted, 0 packets received, 100.0% packet loss
# MetalLB pool released the IP
$ kubectl get ipaddresspool default -n metallb-system \
-o jsonpath='{.status.assignedIPv4} of {.status.availableIPv4}'
2 of 19 available
# E2E probe — external Brevo → WAN:25 → pfSense HAProxy → pod — STILL SUCCEEDS
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-phase6 -n mailserver
... Round-trip SUCCESS in 20.3s ...
$ kubectl delete job probe-phase6 -n mailserver
# pfSense mailserver alias removed
$ ssh admin@10.0.20.1 'php -r "..." | grep mailserver'
(no output)
```
### Manual Verification
1. Visit `https://uptime.viktorbarzin.me` — monitors 282/283 green on new
hostname `10.0.20.1`.
2. Roundcube login works (`https://mail.viktorbarzin.me/`).
3. Send test email to `smoke-test@viktorbarzin.me` from Gmail — observe
`postfix/smtpd-proxy25/postscreen: CONNECT from [<Gmail-IP>]` in
mailserver logs within ~10s.
4. CrowdSec should still see real client IPs in postfix/dovecot parsers
(verify with `cscli alerts list` on next auth-fail event).
## Phase history (bd code-yiu)
| Phase | Status | Description |
|---|---|---|
| 1a | ✅ `ef75c02f` | k8s alt :2525 listener + NodePort Service |
| 2 | ✅ 2026-04-19 | pfSense HAProxy pkg installed |
| 3 | ✅ `ba697b02` | HAProxy config persisted in pfSense XML |
| 4+5 | ✅ `9806d515` | 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ✅ **this commit** | MetalLB LB retired; 10.0.20.202 released; docs updated |
Closes: code-yiu
2026-04-19 12:36:11 +00:00
|
|
|
# code-yiu Phase 6: downgraded from LoadBalancer (MetalLB 10.0.20.202,
|
|
|
|
|
# ETP: Local) to ClusterIP on 2026-04-19. External mail now enters via
|
|
|
|
|
# pfSense HAProxy → kubernetes_service.mailserver_proxy NodePort → alt
|
|
|
|
|
# PROXY-speaking listeners. This Service exists only for intra-cluster
|
|
|
|
|
# clients (Roundcube pod, email-roundtrip-monitor CronJob) that talk to
|
|
|
|
|
# `mailserver.mailserver.svc.cluster.local:{25,465,587,993}` on the
|
|
|
|
|
# stock (PROXY-free) container listeners.
|
2026-03-17 21:34:11 +00:00
|
|
|
metadata {
|
|
|
|
|
name = "mailserver"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
|
|
|
|
|
labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
spec {
|
[mailserver] Phase 6 — decommission MetalLB LB path [ci skip]
## Context (bd code-yiu)
With Phase 4+5 proven (external mail flows through pfSense HAProxy +
PROXY v2 to the alt PROXY-speaking container listeners), the MetalLB
LoadBalancer Service + `10.0.20.202` external IP + ETP:Local policy are
obsolete. Phase 6 decommissions them and documents the steady-state
architecture.
## This change
### Terraform (stacks/mailserver/modules/mailserver/main.tf)
- `kubernetes_service.mailserver` downgraded: `LoadBalancer` → `ClusterIP`.
- Removed `metallb.io/loadBalancerIPs = "10.0.20.202"` annotation.
- Removed `external_traffic_policy = "Local"` (irrelevant for ClusterIP).
- Port set unchanged — the Service still exposes 25/465/587/993 for
intra-cluster clients (Roundcube pod, `email-roundtrip-monitor`
CronJob) that hit the stock PROXY-free container listeners.
- Inline comment documents the downgrade rationale + companion
`mailserver-proxy` NodePort Service that now carries external traffic.
### pfSense (ops, not in git)
- `mailserver` host alias (pointing at `10.0.20.202`) deleted. No NAT
rule references it post-Phase-4; keeping it would be misleading dead
metadata. Reversible via WebUI + `php /tmp/delete-mailserver-alias.php`
companion script (ad-hoc, not checked in — alias is just a
Firewall → Aliases → Hosts entry).
### Uptime Kuma (ops)
- Monitors `282` and `283` (PORT checks) retargeted from `10.0.20.202`
→ `10.0.20.1`. Renamed to `Mailserver HAProxy SMTP (pfSense :25)` /
`... IMAPS (pfSense :993)` to reflect their new purpose (HAProxy
layer liveness). History retained (edit, not delete-recreate).
### Docs
- `docs/runbooks/mailserver-pfsense-haproxy.md` — fully rewritten
"Current state" section; now reflects steady-state architecture with
two-path diagram (external via HAProxy / intra-cluster via ClusterIP).
Phase history table marks Phase 6 ✅. Rollback section updated (no
one-liner post-Phase-6; need Service-type re-upgrade + alias re-add).
- `docs/architecture/mailserver.md` — Overview, Mermaid diagram, Inbound
flow, CrowdSec section, Uptime Kuma monitors list, Decisions section
(dedicated MetalLB IP → "Client-IP Preservation via HAProxy + PROXY
v2"), Troubleshooting all updated.
- `.claude/CLAUDE.md` — mailserver monitoring + architecture paragraph
updated with new external path description; references the new runbook.
## What is NOT in this change
- Removal of `10.0.20.202` from `cloudflare_proxied_names` or any
reserved-IP tracking — wasn't there to begin with. The
`metallb-system default` IPAddressPool (10.0.20.200-220) shows 2 of
19 available after this, confirming `.202` went back to the pool.
- Phase 4 NAT-flip rollback scripts — kept on-disk, still valid if
someone re-introduces the MetalLB LB (see runbook "Rollback").
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# Service is ClusterIP with no EXTERNAL-IP
$ kubectl get svc -n mailserver mailserver
mailserver ClusterIP 10.103.108.217 <none> 25/TCP,465/TCP,587/TCP,993/TCP
# 10.0.20.202 no longer answers ARP (ping from pfSense)
$ ssh admin@10.0.20.1 'ping -c 2 -t 2 10.0.20.202'
2 packets transmitted, 0 packets received, 100.0% packet loss
# MetalLB pool released the IP
$ kubectl get ipaddresspool default -n metallb-system \
-o jsonpath='{.status.assignedIPv4} of {.status.availableIPv4}'
2 of 19 available
# E2E probe — external Brevo → WAN:25 → pfSense HAProxy → pod — STILL SUCCEEDS
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-phase6 -n mailserver
... Round-trip SUCCESS in 20.3s ...
$ kubectl delete job probe-phase6 -n mailserver
# pfSense mailserver alias removed
$ ssh admin@10.0.20.1 'php -r "..." | grep mailserver'
(no output)
```
### Manual Verification
1. Visit `https://uptime.viktorbarzin.me` — monitors 282/283 green on new
hostname `10.0.20.1`.
2. Roundcube login works (`https://mail.viktorbarzin.me/`).
3. Send test email to `smoke-test@viktorbarzin.me` from Gmail — observe
`postfix/smtpd-proxy25/postscreen: CONNECT from [<Gmail-IP>]` in
mailserver logs within ~10s.
4. CrowdSec should still see real client IPs in postfix/dovecot parsers
(verify with `cscli alerts list` on next auth-fail event).
## Phase history (bd code-yiu)
| Phase | Status | Description |
|---|---|---|
| 1a | ✅ `ef75c02f` | k8s alt :2525 listener + NodePort Service |
| 2 | ✅ 2026-04-19 | pfSense HAProxy pkg installed |
| 3 | ✅ `ba697b02` | HAProxy config persisted in pfSense XML |
| 4+5 | ✅ `9806d515` | 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ✅ **this commit** | MetalLB LB retired; 10.0.20.202 released; docs updated |
Closes: code-yiu
2026-04-19 12:36:11 +00:00
|
|
|
type = "ClusterIP"
|
2026-03-17 21:34:11 +00:00
|
|
|
selector = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 25
|
|
|
|
|
target_port = "smtp"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-secure"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 465
|
|
|
|
|
target_port = "smtp-secure"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-auth"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 587
|
|
|
|
|
target_port = "smtp-auth"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
port {
|
|
|
|
|
name = "imap-secure"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 993
|
|
|
|
|
target_port = "imap-secure"
|
|
|
|
|
}
|
[mailserver] Split Dovecot metrics port onto ClusterIP service [ci skip]
## Context
Port 9166 (`dovecot-metrics`) was exposed on the public MetalLB
LoadBalancer 10.0.20.202 alongside SMTP/IMAP. While only LAN-routable,
shipping an internal metric on the same listening IP as external mail
conflated two concerns and over-exposed the port. Prometheus was
scraping via the same LB Service. Addresses code-izl (follow-up to
code-61v which added the scrape job).
## This change
### mailserver stack
- Drops `dovecot-metrics` port from `kubernetes_service.mailserver`
(LoadBalancer stays: 25, 465, 587, 993).
- Adds new `kubernetes_service.mailserver_metrics` — ClusterIP-only,
selecting the same `app=mailserver` pod, exposing 9166.
### monitoring stack
- Updates `extraScrapeConfigs` in the Prometheus chart values to
target the new `mailserver-metrics.mailserver.svc.cluster.local:9166`
instead of `mailserver.mailserver.svc.cluster.local:9166`.
- helm_release.prometheus updated in-place; configmap-reload sidecar
picked up the new target within 10s.
```
mailserver LB mailserver-metrics ClusterIP
┌──────────────────┐ ┌──────────────────┐
│ 25 smtp │ │ 9166 dovecot- │
│ 465 smtp-secure │ │ metrics │ ← Prometheus only
│ 587 smtp-auth │ └──────────────────┘
│ 993 imap-secure │
└──────────────────┘
↑ 10.0.20.202
```
## What is NOT in this change
- Per-Service RBAC/NetworkPolicy tightening (separate task)
- Moving the metrics port to a dedicated sidecar-only Service Monitor
(ServiceMonitor CRDs not installed; extraScrapeConfigs is correct
for the prometheus-community chart in use)
## Test Plan
### Automated
```
$ kubectl get svc -n mailserver
mailserver LoadBalancer 10.0.20.202 25/TCP,465/TCP,587/TCP,993/TCP
mailserver-metrics ClusterIP 10.100.102.174 9166/TCP
$ kubectl get endpoints -n mailserver mailserver-metrics
mailserver-metrics 10.10.169.163:9166
$ # Prometheus target (after 10s configmap-reload)
$ kubectl exec -n monitoring <prom-pod> -c prometheus-server -- \
wget -qO- 'http://localhost:9090/api/v1/targets?scrapePool=mailserver-dovecot'
scrapeUrl: http://mailserver-metrics.mailserver.svc.cluster.local:9166/metrics
health: up
```
### Manual Verification
1. From a host outside the cluster: `nc -vz 10.0.20.202 9166` → connection refused
2. Prometheus UI `/targets` → `mailserver-dovecot` UP, labels show new DNS name
3. PromQL: `up{job="mailserver-dovecot"}` returns `1`
Closes: code-izl
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:37:30 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[mailserver] Retire Dovecot exporter + scrape + alerts [ci skip]
## Context
code-vnc confirmed `viktorbarzin/dovecot_exporter` cannot produce real
metrics against docker-mailserver 15.0.0's Dovecot 2.3.19 — the
exporter speaks the pre-2.3 `old_stats` FIFO protocol, which Dovecot
2.3 deprecated in favour of `service stats` + `doveadm-server` with
a different wire format. The scrape only ever returned
`dovecot_up{scope="user"} 0`.
code-1ik listed two paths: (a) switch to a Dovecot 2.3+ exporter, or
(b) retire the exporter + scrape + alerts. Picking (b) — carrying a
no-op exporter + scrape + alert group taxes cluster resources,
clutters Prometheus /targets, and tees up an alert that can never
fire correctly. If a future session needs real Dovecot stats, reach
for a known-good exporter (e.g., jtackaberry/dovecot_exporter) and
rebuild this scaffolding.
## This change
### mailserver stack
- Removes the `dovecot-exporter` container from
`kubernetes_deployment.mailserver` (was ~28 lines). Pod now
runs a single `docker-mailserver` container.
- Removes `kubernetes_service.mailserver_metrics` (ClusterIP Service
added in code-izl). The `mailserver` LoadBalancer (ports 25, 465,
587, 993) is unaffected.
- Drops the dovecot.cf comment documenting the failed code-vnc
attempt — the documentation survives here + in bd code-vnc /
code-1ik.
### monitoring stack
- Removes `job_name: 'mailserver-dovecot'` from `extraScrapeConfigs`.
- Removes the `Mailserver Dovecot` PrometheusRule group
(`DovecotConnectionsNearLimit`, `DovecotExporterDown`).
- Inline comments in both files point future work at code-1ik's
decision record.
Prometheus configmap-reload picked up the change; scrape target set
now has zero entries for `mailserver-dovecot`. Pod rolled cleanly to
1/1 Running.
## What is NOT in this change
- No replacement exporter — deliberate. The alert that was removed
was a false-signal alert; its removal returns cluster alerting to
a correct, lower-noise state.
- mailserver MetalLB Service + SMTP/IMAP ports — unchanged.
- `auth_failure_delay`, `mail_max_userip_connections` — stay; those
are unrelated to stats export.
## Test Plan
### Automated
```
$ kubectl get pod -n mailserver -l app=mailserver
NAME READY STATUS RESTARTS AGE
mailserver-78589bfd95-swz6h 1/1 Running 0 49s
$ kubectl get svc -n mailserver
NAME TYPE PORT(S)
mailserver LoadBalancer 25/TCP,465/TCP,587/TCP,993/TCP
roundcubemail ClusterIP 80/TCP
# mailserver-metrics gone
$ kubectl exec -n monitoring <prom-pod> -c prometheus-server -- \
wget -qO- 'http://localhost:9090/api/v1/targets?scrapePool=mailserver-dovecot'
{"status":"success","data":{"activeTargets":[]}}
```
### Manual Verification
1. E2E probe `email-roundtrip-monitor` keeps succeeding (20-min cadence)
2. `EmailRoundtripFailing` stays green — proves IMAP is healthy even
without the exporter signal
3. Prometheus `/alerts` page no longer shows DovecotConnectionsNearLimit
or DovecotExporterDown
Closes: code-1ik
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:01:07 +00:00
|
|
|
# The `mailserver-metrics` ClusterIP Service (formerly split from the
|
|
|
|
|
# main LB in code-izl) was retired in code-1ik when the Dovecot
|
|
|
|
|
# exporter was removed — the exporter spoke the pre-Dovecot-2.3
|
|
|
|
|
# old_stats protocol which docker-mailserver 15.0.0 no longer
|
|
|
|
|
# emits, so the scrape was a no-op. If a working exporter is ever
|
|
|
|
|
# re-introduced, add back: ClusterIP Service exposing port 9166
|
|
|
|
|
# with selector app=mailserver.
|
2026-03-17 21:34:11 +00:00
|
|
|
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
# code-yiu Phase 1a: NodePort Service for pfSense HAProxy backend connections.
|
|
|
|
|
# External SMTP flow post-cutover:
|
|
|
|
|
# Client → pfSense WAN:25 → pfSense HAProxy → k8s-node:30125 (NodePort
|
|
|
|
|
# targeting container :2525 on any node, ETP: Cluster) → pod postscreen
|
|
|
|
|
# with PROXY v2 parsing → real client IP in maillog.
|
|
|
|
|
# Internal flow (Roundcube, probe) stays on the mailserver ClusterIP Service
|
|
|
|
|
# hitting container :25 without PROXY — unchanged.
|
|
|
|
|
resource "kubernetes_service" "mailserver_proxy" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver-proxy"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
type = "NodePort"
|
|
|
|
|
external_traffic_policy = "Cluster"
|
|
|
|
|
selector = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-proxy"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 25
|
|
|
|
|
target_port = 2525
|
|
|
|
|
node_port = 30125
|
|
|
|
|
}
|
[mailserver] Phase 4+5 — pfSense HAProxy cutover for all 4 mail ports [ci skip]
## Context (bd code-yiu)
Cutover of external mail traffic from the MetalLB LB IP path (ETP:Local,
pod-speaker colocation) to pfSense HAProxy + PROXY v2 (ETP:Cluster). Real
client IP now preserved end-to-end on ports 25/465/587/993, both for
postscreen anti-spam scoring and CrowdSec auth-failure bans.
## This change
### k8s (stacks/mailserver/modules/mailserver/main.tf)
- `mailserver-user-patches` ConfigMap's `user-patches.sh` now appends 3
alt PROXY-speaking services to master.cf:
- `:2525` postscreen (alt :25)
- `:4465` smtpd (alt :465 SMTPS, wrappermode TLS)
- `:5587` smtpd (alt :587 submission)
All with `postscreen_upstream_proxy_protocol=haproxy` / `smtpd_upstream_proxy_protocol=haproxy`.
Mirror stock submission/submissions options (SASL via Dovecot, TLS,
client restrictions, mua_sender_restrictions). chroot=n so the SASL
socket path `/dev/shm/sasl-auth.sock` resolves outside the chroot.
- `dovecot.cf` ConfigMap adds:
```
haproxy_trusted_networks = 10.0.20.0/24
service imap-login { inet_listener imaps_proxy { port=10993; ssl=yes; haproxy=yes } }
```
Stock :993 stays PROXY-free for internal Roundcube/probe clients.
- Container ports: 4 new (4465, 5587, 10993, 2525 already there).
- `mailserver-proxy` NodePort Service now exposes all 4 ports:
25→2525→30125, 465→4465→30126, 587→5587→30127, 993→10993→30128
(ETP:Cluster).
### pfSense (scripts/pfsense-haproxy-bootstrap.php)
Rebuilt to declare 4 backend pools (one per NodePort) and 4 production
frontends on `10.0.20.1:{25,465,587,993}` TCP mode, plus the legacy
`:2525` test frontend. All pools: `send-proxy-v2 check inter 120000`.
Idempotent — re-runs converge on declared state.
### pfSense (scripts/pfsense-nat-mailserver-haproxy-{flip,unflip}.php)
Flip script: updates `<nat><rule>` entries for mail ports from target
`<mailserver>` alias (10.0.20.202 MetalLB) → `10.0.20.1` (pfSense
HAProxy). Runs `filter_configure()` to rebuild pf rules. Unflip is the
rollback. Both scripts are idempotent.
## What is NOT in this change
- Phase 6 (decommission MetalLB LB path, downgrade mailserver Service
from LoadBalancer to ClusterIP, free 10.0.20.202) — USER-GATED. Do
NOT run until explicit approval.
- Legacy MetalLB `mailserver` LB still live on 10.0.20.202 with stock
ETP:Local ports — functional backup path + consumed by internal
clients that hit `mailserver.mailserver.svc.cluster.local` (routes
via ClusterIP layer of the LB Service, bypassing ETP).
- Port :143 (plain IMAP) — no HAProxy frontend; stays on MetalLB via
unchanged NAT rule.
## Test Plan
### Automated (verified pre-commit 2026-04-19)
```
# k8s container listens on all 8 ports
$ kubectl exec -c docker-mailserver deployment/mailserver -n mailserver \
-- ss -ltn | grep -E ':(25|2525|465|4465|587|5587|993|10993)\b'
... all 8 listening ...
# pfSense HAProxy listens on all 5 (production + legacy test)
$ ssh admin@10.0.20.1 'sockstat -l | grep haproxy'
www haproxy 49418 5 tcp4 *:25
www haproxy 49418 6 tcp4 *:2525
www haproxy 49418 10 tcp4 *:465
www haproxy 49418 11 tcp4 *:587
www haproxy 49418 12 tcp4 *:993
# Post-flip: pf rdr rules point at pfSense, not <mailserver>
$ ssh admin@10.0.20.1 'pfctl -sn' | grep 'smtp\|sub\|imap\|:25'
rdr on vtnet0 ... port = submission -> 10.0.20.1
rdr on vtnet0 ... port = imaps -> 10.0.20.1
rdr on vtnet0 ... port = smtps -> 10.0.20.1
rdr on vtnet0 ... port = 25 -> 10.0.20.1
# 4 HAProxy frontends reachable + SMTP/IMAP banners
$ python3 <test script> → SMTP/SMTPS/Sub/IMAPS all respond correctly
# Real client IP in maillog for external delivery via Brevo → MX
postfix/smtpd-proxy25/postscreen: CONNECT from [77.32.148.26]:36334 to [10.0.20.1]:25
postfix/smtpd-proxy25/postscreen: PASS NEW [77.32.148.26]:36334
# E2E probe (Brevo HTTP → external SMTP delivery → IMAP fetch) succeeds
$ kubectl create job --from=cronjob/email-roundtrip-monitor probe-yiu-flip -n mailserver
... Round-trip SUCCESS in 20.3s ...
# Internal Roundcube path unchanged
$ curl -sI https://mail.viktorbarzin.me/ → 302 (Authentik gate intact)
# No mail alerts firing
$ kubectl exec prometheus-server ... /api/v1/alerts | grep Email → (empty)
```
### Rollback
```
scp infra/scripts/pfsense-nat-mailserver-haproxy-unflip.php admin@10.0.20.1:/tmp/
ssh admin@10.0.20.1 'php /tmp/pfsense-nat-mailserver-haproxy-unflip.php'
```
Immediate (<2s). Flips all 4 NAT rdrs back to `<mailserver>` alias.
Pre-flip config snapshot also saved at
`/tmp/config.xml.pre-yiu-flip.20260419-1222` on pfSense.
## Phase roadmap (bd code-yiu)
| Phase | Status |
|---|---|
| 1a | ✅ commit ef75c02f — alt :2525 listener + NodePort |
| 2 | ✅ 2026-04-19 — HAProxy pkg installed on pfSense |
| 3 | ✅ commit ba697b02 — HAProxy config persisted in pfSense XML |
| 4+5| ✅ **this commit** — 4-port alt listeners + HAProxy frontends + NAT flip |
| 6 | ⏸ USER-GATED — MetalLB LB decommission after 48h observation |
2026-04-19 12:24:50 +00:00
|
|
|
port {
|
|
|
|
|
name = "smtps-proxy"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 465
|
|
|
|
|
target_port = 4465
|
|
|
|
|
node_port = 30126
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "sub-proxy"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 587
|
|
|
|
|
target_port = 5587
|
|
|
|
|
node_port = 30127
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "imaps-proxy"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 993
|
|
|
|
|
target_port = 10993
|
|
|
|
|
node_port = 30128
|
|
|
|
|
}
|
2026-05-05 19:45:33 +00:00
|
|
|
# Dedicated non-PROXY healthcheck NodePorts. HAProxy on pfSense uses
|
|
|
|
|
# `option smtpchk` against these stock pod ports (25/465/587, no PROXY)
|
|
|
|
|
# so health probes don't hit the smtpd_peer_hostaddr_to_sockaddr fatal
|
|
|
|
|
# that fires on PROXY-v2 LOCAL/AF_UNSPEC frames sent during checks. The
|
|
|
|
|
# data path (30125-30128 → 2525/4465/5587/10993) still gets PROXY v2 for
|
|
|
|
|
# real client IP visibility — only the healthcheck path is split off.
|
|
|
|
|
# See infra/scripts/pfsense-haproxy-bootstrap.php (`check port` directive)
|
|
|
|
|
# and docs/runbooks/mailserver-pfsense-haproxy.md.
|
|
|
|
|
port {
|
|
|
|
|
name = "smtp-check"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 2500
|
|
|
|
|
target_port = 25
|
|
|
|
|
node_port = 30145
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "smtps-check"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 4650
|
|
|
|
|
target_port = 465
|
|
|
|
|
node_port = 30146
|
|
|
|
|
}
|
|
|
|
|
port {
|
|
|
|
|
name = "sub-check"
|
|
|
|
|
protocol = "TCP"
|
|
|
|
|
port = 5870
|
|
|
|
|
target_port = 587
|
|
|
|
|
node_port = 30147
|
|
|
|
|
}
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 22:50:22 +02:00
|
|
|
# =============================================================================
|
|
|
|
|
# E2E Email Roundtrip Monitor
|
2026-04-15 06:41:56 +00:00
|
|
|
# Sends test email via Brevo API, verifies delivery via IMAP, pushes metrics
|
2026-03-25 22:50:22 +02:00
|
|
|
# =============================================================================
|
[mailserver] Move probe secrets to ExternalSecret via ESO [ci skip]
## Context
The email-roundtrip-monitor CronJob injected `BREVO_API_KEY` and
`EMAIL_MONITOR_IMAP_PASSWORD` as inline `env { value = var.xxx }` —
Terraform read them from Vault at plan time and embedded them in the
generated CronJob spec. Anyone with `kubectl describe cronjob` (or
pod-event read) in the `mailserver` namespace could read both secrets
verbatim.
The two upstream Vault entries are not flat strings:
- `secret/viktor` → `brevo_api_key` = base64(JSON({"api_key": "..."}))
- `secret/platform` → `mailserver_accounts` = JSON({"spam@viktorbarzin.me": "<pw>", ...})
A plain ESO `remoteRef.property` can traverse one level of JSON but
cannot base64-decode the wrapper or index a map key that contains `@`.
So the ExternalSecret pulls the raw Vault values and the rendered K8s
Secret is produced via ESO's `target.template` (engineVersion v2, sprig
pipeline `b64dec | fromJson | dig`). `mergePolicy` defaults to Replace,
so only the transformed `BREVO_API_KEY` / `EMAIL_MONITOR_IMAP_PASSWORD`
keys land in the K8s Secret — the raw wrapped inputs never reach it.
## This change
1. New `kubernetes_manifest.email_roundtrip_monitor_secrets` rendering
an `external-secrets.io/v1beta1` ExternalSecret into a K8s Secret
named `mailserver-probe-secrets` via the `vault-kv` ClusterSecretStore.
2. CronJob's two `env { name=... value=var.xxx }` blocks replaced with
a single `env_from { secret_ref { name = "mailserver-probe-secrets" } }`.
3. Unused `brevo_api_key` / `email_monitor_imap_password` module
variables + their wiring in `stacks/mailserver/main.tf` removed.
`data "vault_kv_secret_v2" "viktor"` dropped (last consumer gone).
```
Before: After:
┌────────────┐ ┌────────────┐
│ Vault KV │ │ Vault KV │
└────┬───────┘ └────┬───────┘
│ (plan-time read) │ (runtime pull)
▼ ▼
┌────────────┐ ┌────────────┐
│ Terraform │ │ ESO ctrl │
│ state │ │ +template │
└────┬───────┘ └────┬───────┘
│ inline value= │ sprig b64dec | fromJson
▼ ▼
┌────────────┐ ┌────────────┐
│ CronJob │ <-- kubectl describe leaks! │ K8s Secret │
│ env[].value│ │ probe-sec │
└────────────┘ └────┬───────┘
│ env_from.secret_ref
▼
┌────────────┐
│ CronJob │
│ (no values │
│ in spec) │
└────────────┘
```
## Test Plan
### Automated
`terragrunt plan -target=...ExternalSecret -target=...CronJob`:
```
Plan: 1 to add, 1 to change, 0 to destroy.
+ kubernetes_manifest.email_roundtrip_monitor_secrets (ExternalSecret)
~ kubernetes_cron_job_v1.email_roundtrip_monitor
- env { name = "BREVO_API_KEY" ... }
- env { name = "EMAIL_MONITOR_IMAP_PASSWORD" ... }
+ env_from { secret_ref { name = "mailserver-probe-secrets" } }
```
`terragrunt apply --non-interactive` same targets:
```
Apply complete! Resources: 1 added, 1 changed, 0 destroyed.
```
`kubectl get externalsecret -n mailserver mailserver-probe-secrets`:
```
NAME STORE REFRESH INTERVAL STATUS READY
mailserver-probe-secrets vault-kv 15m SecretSynced True
```
`kubectl get secret -n mailserver mailserver-probe-secrets -o yaml`
exposes exactly two data keys (`BREVO_API_KEY`, `EMAIL_MONITOR_IMAP_PASSWORD`) —
both populated, 120 / 32 base64 chars, no raw `brevo_api_key_wrapped` /
`mailserver_accounts` keys.
`kubectl describe cronjob -n mailserver email-roundtrip-monitor`:
```
Environment Variables from:
mailserver-probe-secrets Secret Optional: false
Environment: <none>
```
(Previously the `Environment:` block listed both secrets with their raw
values.)
### Manual Verification
1. `kubectl create job --from=cronjob/email-roundtrip-monitor \
probe-test-$RANDOM -n mailserver`
2. `kubectl logs -n mailserver -l job-name=probe-test-... --tail=30`
expected:
```
Sent test email via Brevo: 201 marker=e2e-probe-...
Found test email after 1 attempts
Deleted 1 e2e probe email(s)
Round-trip SUCCESS in 20.3s
Pushed metrics to Pushgateway
Pushed to Uptime Kuma
```
3. `kubectl exec -n monitoring deploy/prometheus-prometheus-pushgateway \
-- wget -q -O- http://localhost:9091/metrics | grep email_roundtrip`
shows `email_roundtrip_success=1`, fresh timestamp, duration in range.
4. `kubectl delete job -n mailserver probe-test-...` to clean up.
Closes: code-39v
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:06 +00:00
|
|
|
# ExternalSecret syncing the probe's Vault inputs into a K8s Secret, so
|
|
|
|
|
# `kubectl describe cronjob email-roundtrip-monitor` no longer leaks the
|
|
|
|
|
# Brevo API key and IMAP password via `env[].value`. The two upstream Vault
|
|
|
|
|
# entries both wrap the effective secret:
|
|
|
|
|
# - secret/viktor → brevo_api_key = base64(JSON({"api_key": "..."}))
|
|
|
|
|
# - secret/platform → mailserver_accounts = JSON({"spam@viktorbarzin.me": "<pw>", ...})
|
|
|
|
|
# ESO's `target.template` (engineVersion v2) runs sprig on the raw remote
|
|
|
|
|
# values so the rendered K8s Secret contains ONLY the two env vars the probe
|
|
|
|
|
# actually needs, under the exact keys `BREVO_API_KEY` and
|
|
|
|
|
# `EMAIL_MONITOR_IMAP_PASSWORD` so the CronJob can consume them via a single
|
|
|
|
|
# `env_from { secret_ref {} }` block.
|
|
|
|
|
resource "kubernetes_manifest" "email_roundtrip_monitor_secrets" {
|
|
|
|
|
manifest = {
|
|
|
|
|
apiVersion = "external-secrets.io/v1beta1"
|
|
|
|
|
kind = "ExternalSecret"
|
|
|
|
|
metadata = {
|
|
|
|
|
name = "mailserver-probe-secrets"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
spec = {
|
|
|
|
|
refreshInterval = "15m"
|
|
|
|
|
secretStoreRef = {
|
|
|
|
|
name = "vault-kv"
|
|
|
|
|
kind = "ClusterSecretStore"
|
|
|
|
|
}
|
|
|
|
|
target = {
|
|
|
|
|
name = "mailserver-probe-secrets"
|
|
|
|
|
template = {
|
|
|
|
|
engineVersion = "v2"
|
|
|
|
|
data = {
|
|
|
|
|
BREVO_API_KEY = "{{ .brevo_api_key_wrapped | b64dec | fromJson | dig \"api_key\" \"\" }}"
|
|
|
|
|
EMAIL_MONITOR_IMAP_PASSWORD = "{{ .mailserver_accounts | fromJson | dig \"spam@viktorbarzin.me\" \"\" }}"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
data = [
|
|
|
|
|
{
|
|
|
|
|
secretKey = "brevo_api_key_wrapped"
|
|
|
|
|
remoteRef = {
|
|
|
|
|
key = "viktor"
|
|
|
|
|
property = "brevo_api_key"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
secretKey = "mailserver_accounts"
|
|
|
|
|
remoteRef = {
|
|
|
|
|
key = "platform"
|
|
|
|
|
property = "mailserver_accounts"
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-25 22:50:22 +02:00
|
|
|
resource "kubernetes_cron_job_v1" "email_roundtrip_monitor" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "email-roundtrip-monitor"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
concurrency_policy = "Replace"
|
|
|
|
|
failed_jobs_history_limit = 3
|
|
|
|
|
successful_jobs_history_limit = 3
|
2026-04-12 22:24:38 +01:00
|
|
|
schedule = "*/20 * * * *"
|
2026-03-25 22:50:22 +02:00
|
|
|
job_template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
backoff_limit = 1
|
|
|
|
|
ttl_seconds_after_finished = 300
|
|
|
|
|
template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
container {
|
|
|
|
|
name = "email-roundtrip"
|
|
|
|
|
image = "docker.io/library/python:3.12-alpine"
|
|
|
|
|
command = ["/bin/sh", "-c", <<-EOT
|
|
|
|
|
pip install --quiet --disable-pip-version-check requests && python3 -c '
|
2026-04-15 06:41:56 +00:00
|
|
|
import requests, imaplib, email, time, os, uuid, sys, ssl, json
|
2026-03-25 22:50:22 +02:00
|
|
|
|
2026-04-15 06:41:56 +00:00
|
|
|
BREVO_API_KEY = os.environ["BREVO_API_KEY"]
|
2026-03-25 22:50:22 +02:00
|
|
|
IMAP_USER = "spam@viktorbarzin.me"
|
|
|
|
|
IMAP_PASS = os.environ["EMAIL_MONITOR_IMAP_PASSWORD"]
|
|
|
|
|
IMAP_HOST = "mailserver.mailserver.svc.cluster.local"
|
|
|
|
|
PUSHGATEWAY = "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/email-roundtrip-monitor"
|
|
|
|
|
DOMAIN = "viktorbarzin.me"
|
|
|
|
|
|
|
|
|
|
marker = f"e2e-probe-{uuid.uuid4().hex[:12]}"
|
|
|
|
|
subject = f"[E2E Monitor] {marker}"
|
2026-05-04 07:52:09 +00:00
|
|
|
recipient = f"smoke-test@{DOMAIN}"
|
2026-03-25 22:50:22 +02:00
|
|
|
start = time.time()
|
|
|
|
|
success = 0
|
|
|
|
|
duration = 0
|
|
|
|
|
|
|
|
|
|
try:
|
2026-05-04 07:52:09 +00:00
|
|
|
# Step 0: Defensive unblock. Brevo permanently blocks a recipient after a
|
|
|
|
|
# single hardBounce — once blocked, every subsequent /smtp/email request
|
|
|
|
|
# returns 201 but the message is silently dropped (event=blocked).
|
|
|
|
|
# Single transient pod outage → permanent probe outage. Idempotent: 204 if
|
|
|
|
|
# the recipient was blocked, 404 if not blocked — both are fine.
|
|
|
|
|
# NOTE: this script is wrapped in shell single quotes (see the python3 -c
|
|
|
|
|
# invocation above). Do NOT use apostrophes anywhere here, including in
|
|
|
|
|
# comments — a stray apostrophe terminates the shell string and Python
|
|
|
|
|
# only sees the prefix, raising IndentationError on this try block.
|
|
|
|
|
try:
|
|
|
|
|
unblock = requests.delete(
|
|
|
|
|
f"https://api.brevo.com/v3/smtp/blockedContacts/{recipient}",
|
|
|
|
|
headers={"api-key": BREVO_API_KEY, "Accept": "application/json"},
|
|
|
|
|
timeout=10,
|
|
|
|
|
)
|
|
|
|
|
if unblock.status_code == 204:
|
|
|
|
|
print(f"WARN: {recipient} was blocked at Brevo, unblocked")
|
|
|
|
|
except Exception as ue:
|
|
|
|
|
print(f"Unblock attempt failed (non-critical): {ue}")
|
|
|
|
|
|
2026-04-15 06:41:56 +00:00
|
|
|
# Step 1: Send via Brevo Transactional Email API to smoke-test@ (hits catch-all -> spam@)
|
2026-03-25 22:50:22 +02:00
|
|
|
resp = requests.post(
|
2026-04-15 06:41:56 +00:00
|
|
|
"https://api.brevo.com/v3/smtp/email",
|
|
|
|
|
headers={
|
|
|
|
|
"api-key": BREVO_API_KEY,
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
},
|
|
|
|
|
json={
|
|
|
|
|
"sender": {"name": "Monitoring", "email": f"monitoring@{DOMAIN}"},
|
2026-05-04 07:52:09 +00:00
|
|
|
"to": [{"email": recipient}],
|
2026-03-25 22:50:22 +02:00
|
|
|
"subject": subject,
|
2026-04-15 06:41:56 +00:00
|
|
|
"textContent": f"E2E email monitoring probe {marker}. Auto-generated, will be deleted.",
|
2026-03-25 22:50:22 +02:00
|
|
|
},
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
2026-04-15 06:41:56 +00:00
|
|
|
print(f"Sent test email via Brevo: {resp.status_code} marker={marker}")
|
2026-03-25 22:50:22 +02:00
|
|
|
|
[mailserver] Widen email-roundtrip probe IMAP window 180s → 300s + per-attempt timeout
## Context
After fixing the two mail-server-side root causes of probe false-failures
(Dovecot userdb duplicates, postscreen btree lock contention), the probe
is expected to succeed well under 120s. This commit is defence in depth
against residual SMTP relay variance and against a future scenario where
Dovecot is transiently unresponsive during IMAP login.
The probe currently polls IMAP with `range(9) × 20s = 180s`. Brevo's
queueing, DNS variance, and general SMTP retry backoff can easily
exceed that on a bad day. Widening to 5 minutes gives plenty of headroom
while still remaining well within the CronJob's 20-minute schedule
interval.
Additionally, `imaplib.IMAP4_SSL(...)` previously had no timeout. If
Dovecot is unresponsive (e.g., mid-rollout, transient TLS handshake
hang), the connect call can block indefinitely and the probe hangs
without ever looping to the next attempt. Adding `timeout=10` caps each
connect at 10s so the retry loop keeps making forward progress.
## This change
Two edits to the embedded probe script inside the cronjob resource:
```
- # Step 2: Wait for delivery, retry IMAP up to 3 min
+ # Step 2: Wait for delivery, retry IMAP up to 5 min (15 x 20s)
...
- for attempt in range(9):
+ for attempt in range(15):
...
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
```
Flow (before):
```
send via Brevo ─► for 9 loops: sleep 20s, IMAP connect (blocks on hang) ─► 180s total
```
Flow (after):
```
send via Brevo ─► for 15 loops: sleep 20s, IMAP connect (≤10s) ─► 300s total
│
└─ timeout ─► log, continue to next loop
```
## What is NOT in this change
- Probe frequency stays at `*/20 * * * *`.
- The `EmailRoundtripStale` alert thresholds are intentionally left at
3600s + for: 10m. Those fire only on sustained multi-hour issues and
should not be loosened — they would mask future regressions. Probe
success rate is now expected to recover to ≥95% from the two upstream
fixes; if it doesn't, alert tuning gets revisited separately.
- No change to the Brevo send step, the success-metrics push, or the
cleanup of stale e2e-probe-* messages.
## Test Plan
### Automated
`scripts/tg plan -target=module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor`:
```
# module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor will be updated in-place
- for attempt in range(9):
+ for attempt in range(15):
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply`:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
1. Trigger the probe manually:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
2. Tail its logs:
`kubectl -n mailserver logs job/probe-verify-<ts> -f`
3. Expect: `Round-trip SUCCESS` within the 5-min window. Typical
successful run should still complete in < 60s now that postscreen
is no longer stalling.
4. Watch the 48-hour window on the `email_roundtrip_success` gauge in
Prometheus — expect ≥95% (was ~65% before all three fixes).
## Reproduce locally
1. `kubectl -n mailserver get cronjob email-roundtrip-monitor -o yaml | grep -E "range\(|timeout"`
2. Expect: `range(15)` and `timeout=10`
3. `kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
4. `kubectl -n mailserver logs -f job/probe-verify-<ts>`
5. Expect: eventual `Round-trip SUCCESS in <N>s` message and exit 0.
Closes: code-18e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:33:56 +00:00
|
|
|
# Step 2: Wait for delivery, retry IMAP up to 5 min (15 x 20s)
|
2026-03-25 22:50:22 +02:00
|
|
|
ctx = ssl.create_default_context()
|
|
|
|
|
ctx.check_hostname = False
|
|
|
|
|
ctx.verify_mode = ssl.CERT_NONE
|
|
|
|
|
found = False
|
[mailserver] Widen email-roundtrip probe IMAP window 180s → 300s + per-attempt timeout
## Context
After fixing the two mail-server-side root causes of probe false-failures
(Dovecot userdb duplicates, postscreen btree lock contention), the probe
is expected to succeed well under 120s. This commit is defence in depth
against residual SMTP relay variance and against a future scenario where
Dovecot is transiently unresponsive during IMAP login.
The probe currently polls IMAP with `range(9) × 20s = 180s`. Brevo's
queueing, DNS variance, and general SMTP retry backoff can easily
exceed that on a bad day. Widening to 5 minutes gives plenty of headroom
while still remaining well within the CronJob's 20-minute schedule
interval.
Additionally, `imaplib.IMAP4_SSL(...)` previously had no timeout. If
Dovecot is unresponsive (e.g., mid-rollout, transient TLS handshake
hang), the connect call can block indefinitely and the probe hangs
without ever looping to the next attempt. Adding `timeout=10` caps each
connect at 10s so the retry loop keeps making forward progress.
## This change
Two edits to the embedded probe script inside the cronjob resource:
```
- # Step 2: Wait for delivery, retry IMAP up to 3 min
+ # Step 2: Wait for delivery, retry IMAP up to 5 min (15 x 20s)
...
- for attempt in range(9):
+ for attempt in range(15):
...
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
```
Flow (before):
```
send via Brevo ─► for 9 loops: sleep 20s, IMAP connect (blocks on hang) ─► 180s total
```
Flow (after):
```
send via Brevo ─► for 15 loops: sleep 20s, IMAP connect (≤10s) ─► 300s total
│
└─ timeout ─► log, continue to next loop
```
## What is NOT in this change
- Probe frequency stays at `*/20 * * * *`.
- The `EmailRoundtripStale` alert thresholds are intentionally left at
3600s + for: 10m. Those fire only on sustained multi-hour issues and
should not be loosened — they would mask future regressions. Probe
success rate is now expected to recover to ≥95% from the two upstream
fixes; if it doesn't, alert tuning gets revisited separately.
- No change to the Brevo send step, the success-metrics push, or the
cleanup of stale e2e-probe-* messages.
## Test Plan
### Automated
`scripts/tg plan -target=module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor`:
```
# module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor will be updated in-place
- for attempt in range(9):
+ for attempt in range(15):
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply`:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
1. Trigger the probe manually:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
2. Tail its logs:
`kubectl -n mailserver logs job/probe-verify-<ts> -f`
3. Expect: `Round-trip SUCCESS` within the 5-min window. Typical
successful run should still complete in < 60s now that postscreen
is no longer stalling.
4. Watch the 48-hour window on the `email_roundtrip_success` gauge in
Prometheus — expect ≥95% (was ~65% before all three fixes).
## Reproduce locally
1. `kubectl -n mailserver get cronjob email-roundtrip-monitor -o yaml | grep -E "range\(|timeout"`
2. Expect: `range(15)` and `timeout=10`
3. `kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
4. `kubectl -n mailserver logs -f job/probe-verify-<ts>`
5. Expect: eventual `Round-trip SUCCESS in <N>s` message and exit 0.
Closes: code-18e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:33:56 +00:00
|
|
|
for attempt in range(15):
|
2026-03-25 22:50:22 +02:00
|
|
|
time.sleep(20)
|
|
|
|
|
try:
|
[mailserver] Widen email-roundtrip probe IMAP window 180s → 300s + per-attempt timeout
## Context
After fixing the two mail-server-side root causes of probe false-failures
(Dovecot userdb duplicates, postscreen btree lock contention), the probe
is expected to succeed well under 120s. This commit is defence in depth
against residual SMTP relay variance and against a future scenario where
Dovecot is transiently unresponsive during IMAP login.
The probe currently polls IMAP with `range(9) × 20s = 180s`. Brevo's
queueing, DNS variance, and general SMTP retry backoff can easily
exceed that on a bad day. Widening to 5 minutes gives plenty of headroom
while still remaining well within the CronJob's 20-minute schedule
interval.
Additionally, `imaplib.IMAP4_SSL(...)` previously had no timeout. If
Dovecot is unresponsive (e.g., mid-rollout, transient TLS handshake
hang), the connect call can block indefinitely and the probe hangs
without ever looping to the next attempt. Adding `timeout=10` caps each
connect at 10s so the retry loop keeps making forward progress.
## This change
Two edits to the embedded probe script inside the cronjob resource:
```
- # Step 2: Wait for delivery, retry IMAP up to 3 min
+ # Step 2: Wait for delivery, retry IMAP up to 5 min (15 x 20s)
...
- for attempt in range(9):
+ for attempt in range(15):
...
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
```
Flow (before):
```
send via Brevo ─► for 9 loops: sleep 20s, IMAP connect (blocks on hang) ─► 180s total
```
Flow (after):
```
send via Brevo ─► for 15 loops: sleep 20s, IMAP connect (≤10s) ─► 300s total
│
└─ timeout ─► log, continue to next loop
```
## What is NOT in this change
- Probe frequency stays at `*/20 * * * *`.
- The `EmailRoundtripStale` alert thresholds are intentionally left at
3600s + for: 10m. Those fire only on sustained multi-hour issues and
should not be loosened — they would mask future regressions. Probe
success rate is now expected to recover to ≥95% from the two upstream
fixes; if it doesn't, alert tuning gets revisited separately.
- No change to the Brevo send step, the success-metrics push, or the
cleanup of stale e2e-probe-* messages.
## Test Plan
### Automated
`scripts/tg plan -target=module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor`:
```
# module.mailserver.kubernetes_cron_job_v1.email_roundtrip_monitor will be updated in-place
- for attempt in range(9):
+ for attempt in range(15):
- imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx)
+ imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
Plan: 0 to add, 1 to change, 0 to destroy.
```
`scripts/tg apply`:
```
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.
```
### Manual Verification
1. Trigger the probe manually:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
2. Tail its logs:
`kubectl -n mailserver logs job/probe-verify-<ts> -f`
3. Expect: `Round-trip SUCCESS` within the 5-min window. Typical
successful run should still complete in < 60s now that postscreen
is no longer stalling.
4. Watch the 48-hour window on the `email_roundtrip_success` gauge in
Prometheus — expect ≥95% (was ~65% before all three fixes).
## Reproduce locally
1. `kubectl -n mailserver get cronjob email-roundtrip-monitor -o yaml | grep -E "range\(|timeout"`
2. Expect: `range(15)` and `timeout=10`
3. `kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-verify-$(date +%s)`
4. `kubectl -n mailserver logs -f job/probe-verify-<ts>`
5. Expect: eventual `Round-trip SUCCESS in <N>s` message and exit 0.
Closes: code-18e
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:33:56 +00:00
|
|
|
imap = imaplib.IMAP4_SSL(IMAP_HOST, 993, ssl_context=ctx, timeout=10)
|
2026-03-25 22:50:22 +02:00
|
|
|
imap.login(IMAP_USER, IMAP_PASS)
|
|
|
|
|
imap.select("INBOX")
|
|
|
|
|
_, msg_ids = imap.search(None, "SUBJECT", marker)
|
|
|
|
|
if msg_ids[0]:
|
|
|
|
|
found = True
|
|
|
|
|
print(f"Found test email after {attempt+1} attempts")
|
2026-04-06 13:39:47 +03:00
|
|
|
# Delete ALL e2e probe emails (current + any leftovers from previous runs)
|
|
|
|
|
if found:
|
2026-03-25 22:50:22 +02:00
|
|
|
try:
|
2026-04-06 13:39:47 +03:00
|
|
|
_, all_e2e = imap.search(None, "SUBJECT", "e2e-probe")
|
|
|
|
|
if all_e2e[0]:
|
|
|
|
|
e2e_ids = all_e2e[0].split()
|
|
|
|
|
for mid in e2e_ids:
|
|
|
|
|
imap.store(mid, "+FLAGS", "(\\Deleted)")
|
|
|
|
|
imap.expunge()
|
|
|
|
|
print(f"Deleted {len(e2e_ids)} e2e probe email(s)")
|
2026-03-25 22:50:22 +02:00
|
|
|
except Exception as de:
|
|
|
|
|
print(f"Delete failed (non-critical): {de}")
|
|
|
|
|
imap.logout()
|
|
|
|
|
if found:
|
|
|
|
|
break
|
|
|
|
|
except Exception as e:
|
|
|
|
|
print(f"IMAP attempt {attempt+1} failed: {e}")
|
|
|
|
|
|
|
|
|
|
duration = time.time() - start
|
|
|
|
|
if found:
|
|
|
|
|
success = 1
|
|
|
|
|
print(f"Round-trip SUCCESS in {duration:.1f}s")
|
|
|
|
|
else:
|
|
|
|
|
print(f"Round-trip FAILED - email not found after {duration:.1f}s")
|
|
|
|
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
duration = time.time() - start
|
|
|
|
|
print(f"ERROR: {e}")
|
|
|
|
|
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
# Push metrics to Pushgateway. On failure we omit email_roundtrip_last_success_timestamp
|
|
|
|
|
# and POST (not PUT) so the prior successful timestamp is preserved — otherwise pushing 0
|
|
|
|
|
# makes EmailRoundtripStale fire immediately alongside EmailRoundtripFailing.
|
|
|
|
|
metric_lines = [
|
|
|
|
|
"# HELP email_roundtrip_success Whether the last e2e email probe succeeded",
|
|
|
|
|
"# TYPE email_roundtrip_success gauge",
|
|
|
|
|
f"email_roundtrip_success {success}",
|
|
|
|
|
"# HELP email_roundtrip_duration_seconds Duration of the last e2e email probe",
|
|
|
|
|
"# TYPE email_roundtrip_duration_seconds gauge",
|
|
|
|
|
f"email_roundtrip_duration_seconds {duration:.2f}",
|
|
|
|
|
]
|
|
|
|
|
if success:
|
|
|
|
|
metric_lines += [
|
|
|
|
|
"# HELP email_roundtrip_last_success_timestamp Unix timestamp of last successful probe",
|
|
|
|
|
"# TYPE email_roundtrip_last_success_timestamp gauge",
|
|
|
|
|
f"email_roundtrip_last_success_timestamp {int(time.time())}",
|
|
|
|
|
]
|
|
|
|
|
metrics = "\n".join(metric_lines) + "\n"
|
[mailserver] Retry probe Pushgateway + Uptime Kuma pushes with backoff
## Context
The e2e email-roundtrip probe (CronJob `email-roundtrip-monitor`) currently
wraps `requests.put(PUSHGATEWAY, ...)` and `requests.get(UPTIME_KUMA, ...)`
in bare `try/except` that only prints "Failed to push ..." on error. If
Pushgateway is transiently unreachable (e.g., during a Prometheus Helm
upgrade / HPA scale-down / brief network blip) metrics silently drop and
downstream detection relies entirely on `EmailRoundtripStale` firing after
60 min of staleness. Single transient failures masquerade as data-plane
breakage for up to an hour.
Target task: `code-n5l` — Add retry to probe Pushgateway + Uptime Kuma pushes.
## This change
- Extracts a `push_with_retry(label, func, url)` helper that performs 3
attempts with exponential backoff (1s, 2s, 4s). Treats HTTP 2xx as
success, everything else as failure. On final failure, logs an explicit
`ERROR:` line to stderr with the URL and either the last HTTP status or
the exception repr — matches the existing `print(...)` logging style
used throughout the heredoc (no stdlib `logging` dependency added).
- Replaces the two inline `try/requests.put/except print` blocks with
calls to the helper. Pushgateway runs unconditionally; Uptime Kuma
still only runs on round-trip success (same as before).
- Makes exit code responsive to push outcome: probe exits non-zero when
the round-trip itself failed (unchanged), OR when BOTH pushes failed
all retries on the success path. Single-endpoint push failure with the
other succeeding keeps exit 0 — partial observability is preferred
over noisy pod restarts from Kubernetes' Job controller.
## Behavior matrix
```
roundtrip | pushgw | kuma | exit | rationale
----------+--------+------+------+-------------------------------
success | ok | ok | 0 | happy path (unchanged)
success | fail | ok | 0 | one endpoint still has telemetry
success | ok | fail | 0 | one endpoint still has telemetry
success | fail | fail | 1 | NEW — total observability loss
fail | ok | - | 1 | roundtrip failed (unchanged, Kuma skipped)
fail | fail | - | 1 | roundtrip failed (unchanged, Kuma skipped)
```
## What is NOT in this change
- Alert thresholds (`EmailRoundtripStale` still 60m) — explicitly out of
scope per the task description.
- `logging` stdlib adoption — rest of heredoc uses `print`, staying
consistent.
- Moving the heredoc out of `main.tf` into a sidecar Python file —
separate refactor.
## Reproduce locally
1. Point PUSHGATEWAY at a black hole:
`kubectl -n mailserver set env cronjob/email-roundtrip-monitor \`
`PUSHGATEWAY=http://nope.invalid:9091/metrics/job/test`
2. Trigger a one-shot job:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-test`
3. Expected in logs:
- 3 attempts, each ~1s/2s/4s apart
- `ERROR: Failed to push to Pushgateway after 3 attempts: url=... exception=...`
- Uptime Kuma push still succeeds (round-trip ok) → exit 0
4. Flip UPTIME_KUMA_URL to also fail (edit heredoc or DNS-poison): expect
exit 1 + two ERROR lines.
## Automated
- `python3 -c "import ast; ast.parse(open('/tmp/probe.py').read())"` → OK
(heredoc extracts cleanly).
- `terraform fmt -check -recursive modules/mailserver/` → no diff.
Closes: code-n5l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:03:54 +00:00
|
|
|
UPTIME_KUMA_URL = "http://uptime-kuma.uptime-kuma.svc.cluster.local/api/push/hLtyRKgeZO?status=up&msg=OK&ping=" + str(int(duration))
|
|
|
|
|
|
|
|
|
|
def push_with_retry(label, func, url):
|
|
|
|
|
# 3 attempts with exponential backoff (1s, 2s, 4s). Returns True on success, False otherwise.
|
|
|
|
|
# Final failure logs ERROR with URL + status code (or exception) so the pod log surfaces the drop.
|
|
|
|
|
last_status = None
|
|
|
|
|
last_exc = None
|
|
|
|
|
for attempt in range(3):
|
|
|
|
|
try:
|
|
|
|
|
resp = func()
|
|
|
|
|
last_status = resp.status_code
|
|
|
|
|
if 200 <= resp.status_code < 300:
|
|
|
|
|
print(f"Pushed to {label} (attempt {attempt+1}, status {resp.status_code})")
|
|
|
|
|
return True
|
|
|
|
|
last_exc = None
|
|
|
|
|
except Exception as e:
|
|
|
|
|
last_exc = e
|
|
|
|
|
last_status = None
|
|
|
|
|
if attempt < 2:
|
|
|
|
|
time.sleep(2 ** attempt)
|
|
|
|
|
detail = f"status={last_status}" if last_exc is None else f"exception={last_exc!r}"
|
|
|
|
|
print(f"ERROR: Failed to push to {label} after 3 attempts: url={url} {detail}", file=sys.stderr)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
pushgateway_ok = push_with_retry(
|
|
|
|
|
"Pushgateway",
|
[mailserver] Phase 1a — alt :2525 postscreen listener + NodePort [ci skip]
## Context (bd code-yiu)
Toward replacing MetalLB ETP:Local + pod-speaker colocation with pfSense
HAProxy injecting PROXY v2 → mailserver. This commit lays the k8s-side
groundwork for port 25 only. External SMTP flow post-cutover:
Client → pfSense WAN:25 → pfSense HAProxy (injects PROXY v2) → k8s-node:30125
(NodePort for mailserver-proxy Service, ETP:Cluster) → kube-proxy → pod :2525
(postscreen with postscreen_upstream_proxy_protocol=haproxy) → real client IP
recovered from PROXY header despite kube-proxy SNAT.
Internal clients (Roundcube, email-roundtrip-monitor) keep using the stock
:25 on mailserver.svc ClusterIP — no PROXY required, zero regression.
## This change
- New `kubernetes_config_map.mailserver_user_patches` with a
`user-patches.sh` script. docker-mailserver runs
`/tmp/docker-mailserver/user-patches.sh` on startup; our script appends a
`2525 postscreen` entry to `master.cf` with
`-o postscreen_upstream_proxy_protocol=haproxy` and a 5s PROXY timeout.
Sentinel-guarded for idempotency on in-place restart.
- New volume + volume_mount (`mode = 0755` via defaultMode) wires the
ConfigMap into the mailserver container.
- New container port spec for 2525 (informational; kube-proxy resolves
targetPort by number anyway).
- New Service `mailserver-proxy` — NodePort type, ETP:Cluster, selector
`app=mailserver`, port 25 → targetPort 2525 → fixed nodePort 30125.
pfSense HAProxy's backend pool will be `<all k8s node IPs>:30125 check
send-proxy-v2`.
The existing `mailserver` LoadBalancer Service (ETP:Local, 10.0.20.202,
ports 25/465/587/993) is untouched. Traffic still flows through it via the
pfSense NAT `<mailserver>` alias; this commit does not change routing.
## What is NOT in this change
- pfSense HAProxy install/config (Phase 2 — out-of-Terraform, runbook-managed)
- pfSense NAT rdr flip from `<mailserver>` → HAProxy VIP (Phase 4)
- 465/587/993 — scoped to port 25 first for proof of concept. Other ports
get the same treatment (alt listeners 4465/5587/10993 + Service ports)
once 25 is proven.
- Dovecot per-listener `haproxy = yes` — irrelevant until IMAP is migrated.
## Test Plan
### Automated (verified pre-commit)
```
$ kubectl rollout status deployment/mailserver -n mailserver
deployment "mailserver" successfully rolled out
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
postconf -M | grep '^2525'
2525 inet n - y - 1 postscreen \
-o syslog_name=postfix/smtpd-proxy \
-o postscreen_upstream_proxy_protocol=haproxy \
-o postscreen_upstream_proxy_timeout=5s
$ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \
ss -ltn | grep -E ':25\b|:2525'
LISTEN 0 100 0.0.0.0:2525 0.0.0.0:*
LISTEN 0 100 0.0.0.0:25 0.0.0.0:*
$ kubectl get svc -n mailserver mailserver-proxy
NAME TYPE CLUSTER-IP PORT(S) AGE
mailserver-proxy NodePort 10.98.213.164 25:30125/TCP 93s
# Expected-to-fail probe (no PROXY header) → postscreen rejects
$ timeout 8 nc -v 10.0.20.101 30125 </dev/null
Connection to 10.0.20.101 30125 port [tcp/*] succeeded!
421 4.3.2 No system resources
```
### Manual Verification (after Phase 2 — pfSense HAProxy)
Once HAProxy on pfSense is configured to listen on alt port :2525 (not the
real :25 yet) and targets `k8s-nodes:30125` with `send-proxy-v2`:
1. From an external host: `swaks --to smoke-test@viktorbarzin.me
--server <pfsense-ip>:2525 --body "phase 1 test"`
2. In mailserver logs: `kubectl logs -c docker-mailserver deployment/mailserver
| grep postfix/smtpd-proxy` — "connect from [<external-ip>]" with the real
public IP, NOT the k8s node IP.
3. E2E probe CronJob keeps green (uses ClusterIP path, unaffected).
## Reproduce locally
1. `kubectl get svc mailserver-proxy -n mailserver` → NodePort 30125 exists
2. `kubectl get cm mailserver-user-patches -n mailserver` → exists
3. `timeout 8 nc -v <k8s-node>:30125 </dev/null` → "421 4.3.2 No system resources"
(postscreen rejecting malformed PROXY)
2026-04-19 11:52:49 +00:00
|
|
|
lambda: requests.post(PUSHGATEWAY, data=metrics, timeout=10),
|
[mailserver] Retry probe Pushgateway + Uptime Kuma pushes with backoff
## Context
The e2e email-roundtrip probe (CronJob `email-roundtrip-monitor`) currently
wraps `requests.put(PUSHGATEWAY, ...)` and `requests.get(UPTIME_KUMA, ...)`
in bare `try/except` that only prints "Failed to push ..." on error. If
Pushgateway is transiently unreachable (e.g., during a Prometheus Helm
upgrade / HPA scale-down / brief network blip) metrics silently drop and
downstream detection relies entirely on `EmailRoundtripStale` firing after
60 min of staleness. Single transient failures masquerade as data-plane
breakage for up to an hour.
Target task: `code-n5l` — Add retry to probe Pushgateway + Uptime Kuma pushes.
## This change
- Extracts a `push_with_retry(label, func, url)` helper that performs 3
attempts with exponential backoff (1s, 2s, 4s). Treats HTTP 2xx as
success, everything else as failure. On final failure, logs an explicit
`ERROR:` line to stderr with the URL and either the last HTTP status or
the exception repr — matches the existing `print(...)` logging style
used throughout the heredoc (no stdlib `logging` dependency added).
- Replaces the two inline `try/requests.put/except print` blocks with
calls to the helper. Pushgateway runs unconditionally; Uptime Kuma
still only runs on round-trip success (same as before).
- Makes exit code responsive to push outcome: probe exits non-zero when
the round-trip itself failed (unchanged), OR when BOTH pushes failed
all retries on the success path. Single-endpoint push failure with the
other succeeding keeps exit 0 — partial observability is preferred
over noisy pod restarts from Kubernetes' Job controller.
## Behavior matrix
```
roundtrip | pushgw | kuma | exit | rationale
----------+--------+------+------+-------------------------------
success | ok | ok | 0 | happy path (unchanged)
success | fail | ok | 0 | one endpoint still has telemetry
success | ok | fail | 0 | one endpoint still has telemetry
success | fail | fail | 1 | NEW — total observability loss
fail | ok | - | 1 | roundtrip failed (unchanged, Kuma skipped)
fail | fail | - | 1 | roundtrip failed (unchanged, Kuma skipped)
```
## What is NOT in this change
- Alert thresholds (`EmailRoundtripStale` still 60m) — explicitly out of
scope per the task description.
- `logging` stdlib adoption — rest of heredoc uses `print`, staying
consistent.
- Moving the heredoc out of `main.tf` into a sidecar Python file —
separate refactor.
## Reproduce locally
1. Point PUSHGATEWAY at a black hole:
`kubectl -n mailserver set env cronjob/email-roundtrip-monitor \`
`PUSHGATEWAY=http://nope.invalid:9091/metrics/job/test`
2. Trigger a one-shot job:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-test`
3. Expected in logs:
- 3 attempts, each ~1s/2s/4s apart
- `ERROR: Failed to push to Pushgateway after 3 attempts: url=... exception=...`
- Uptime Kuma push still succeeds (round-trip ok) → exit 0
4. Flip UPTIME_KUMA_URL to also fail (edit heredoc or DNS-poison): expect
exit 1 + two ERROR lines.
## Automated
- `python3 -c "import ast; ast.parse(open('/tmp/probe.py').read())"` → OK
(heredoc extracts cleanly).
- `terraform fmt -check -recursive modules/mailserver/` → no diff.
Closes: code-n5l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:03:54 +00:00
|
|
|
PUSHGATEWAY,
|
|
|
|
|
)
|
2026-03-25 22:50:22 +02:00
|
|
|
|
|
|
|
|
# Push to Uptime Kuma on success
|
[mailserver] Retry probe Pushgateway + Uptime Kuma pushes with backoff
## Context
The e2e email-roundtrip probe (CronJob `email-roundtrip-monitor`) currently
wraps `requests.put(PUSHGATEWAY, ...)` and `requests.get(UPTIME_KUMA, ...)`
in bare `try/except` that only prints "Failed to push ..." on error. If
Pushgateway is transiently unreachable (e.g., during a Prometheus Helm
upgrade / HPA scale-down / brief network blip) metrics silently drop and
downstream detection relies entirely on `EmailRoundtripStale` firing after
60 min of staleness. Single transient failures masquerade as data-plane
breakage for up to an hour.
Target task: `code-n5l` — Add retry to probe Pushgateway + Uptime Kuma pushes.
## This change
- Extracts a `push_with_retry(label, func, url)` helper that performs 3
attempts with exponential backoff (1s, 2s, 4s). Treats HTTP 2xx as
success, everything else as failure. On final failure, logs an explicit
`ERROR:` line to stderr with the URL and either the last HTTP status or
the exception repr — matches the existing `print(...)` logging style
used throughout the heredoc (no stdlib `logging` dependency added).
- Replaces the two inline `try/requests.put/except print` blocks with
calls to the helper. Pushgateway runs unconditionally; Uptime Kuma
still only runs on round-trip success (same as before).
- Makes exit code responsive to push outcome: probe exits non-zero when
the round-trip itself failed (unchanged), OR when BOTH pushes failed
all retries on the success path. Single-endpoint push failure with the
other succeeding keeps exit 0 — partial observability is preferred
over noisy pod restarts from Kubernetes' Job controller.
## Behavior matrix
```
roundtrip | pushgw | kuma | exit | rationale
----------+--------+------+------+-------------------------------
success | ok | ok | 0 | happy path (unchanged)
success | fail | ok | 0 | one endpoint still has telemetry
success | ok | fail | 0 | one endpoint still has telemetry
success | fail | fail | 1 | NEW — total observability loss
fail | ok | - | 1 | roundtrip failed (unchanged, Kuma skipped)
fail | fail | - | 1 | roundtrip failed (unchanged, Kuma skipped)
```
## What is NOT in this change
- Alert thresholds (`EmailRoundtripStale` still 60m) — explicitly out of
scope per the task description.
- `logging` stdlib adoption — rest of heredoc uses `print`, staying
consistent.
- Moving the heredoc out of `main.tf` into a sidecar Python file —
separate refactor.
## Reproduce locally
1. Point PUSHGATEWAY at a black hole:
`kubectl -n mailserver set env cronjob/email-roundtrip-monitor \`
`PUSHGATEWAY=http://nope.invalid:9091/metrics/job/test`
2. Trigger a one-shot job:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-test`
3. Expected in logs:
- 3 attempts, each ~1s/2s/4s apart
- `ERROR: Failed to push to Pushgateway after 3 attempts: url=... exception=...`
- Uptime Kuma push still succeeds (round-trip ok) → exit 0
4. Flip UPTIME_KUMA_URL to also fail (edit heredoc or DNS-poison): expect
exit 1 + two ERROR lines.
## Automated
- `python3 -c "import ast; ast.parse(open('/tmp/probe.py').read())"` → OK
(heredoc extracts cleanly).
- `terraform fmt -check -recursive modules/mailserver/` → no diff.
Closes: code-n5l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:03:54 +00:00
|
|
|
uptime_kuma_ok = True
|
2026-03-25 22:50:22 +02:00
|
|
|
if success:
|
[mailserver] Retry probe Pushgateway + Uptime Kuma pushes with backoff
## Context
The e2e email-roundtrip probe (CronJob `email-roundtrip-monitor`) currently
wraps `requests.put(PUSHGATEWAY, ...)` and `requests.get(UPTIME_KUMA, ...)`
in bare `try/except` that only prints "Failed to push ..." on error. If
Pushgateway is transiently unreachable (e.g., during a Prometheus Helm
upgrade / HPA scale-down / brief network blip) metrics silently drop and
downstream detection relies entirely on `EmailRoundtripStale` firing after
60 min of staleness. Single transient failures masquerade as data-plane
breakage for up to an hour.
Target task: `code-n5l` — Add retry to probe Pushgateway + Uptime Kuma pushes.
## This change
- Extracts a `push_with_retry(label, func, url)` helper that performs 3
attempts with exponential backoff (1s, 2s, 4s). Treats HTTP 2xx as
success, everything else as failure. On final failure, logs an explicit
`ERROR:` line to stderr with the URL and either the last HTTP status or
the exception repr — matches the existing `print(...)` logging style
used throughout the heredoc (no stdlib `logging` dependency added).
- Replaces the two inline `try/requests.put/except print` blocks with
calls to the helper. Pushgateway runs unconditionally; Uptime Kuma
still only runs on round-trip success (same as before).
- Makes exit code responsive to push outcome: probe exits non-zero when
the round-trip itself failed (unchanged), OR when BOTH pushes failed
all retries on the success path. Single-endpoint push failure with the
other succeeding keeps exit 0 — partial observability is preferred
over noisy pod restarts from Kubernetes' Job controller.
## Behavior matrix
```
roundtrip | pushgw | kuma | exit | rationale
----------+--------+------+------+-------------------------------
success | ok | ok | 0 | happy path (unchanged)
success | fail | ok | 0 | one endpoint still has telemetry
success | ok | fail | 0 | one endpoint still has telemetry
success | fail | fail | 1 | NEW — total observability loss
fail | ok | - | 1 | roundtrip failed (unchanged, Kuma skipped)
fail | fail | - | 1 | roundtrip failed (unchanged, Kuma skipped)
```
## What is NOT in this change
- Alert thresholds (`EmailRoundtripStale` still 60m) — explicitly out of
scope per the task description.
- `logging` stdlib adoption — rest of heredoc uses `print`, staying
consistent.
- Moving the heredoc out of `main.tf` into a sidecar Python file —
separate refactor.
## Reproduce locally
1. Point PUSHGATEWAY at a black hole:
`kubectl -n mailserver set env cronjob/email-roundtrip-monitor \`
`PUSHGATEWAY=http://nope.invalid:9091/metrics/job/test`
2. Trigger a one-shot job:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-test`
3. Expected in logs:
- 3 attempts, each ~1s/2s/4s apart
- `ERROR: Failed to push to Pushgateway after 3 attempts: url=... exception=...`
- Uptime Kuma push still succeeds (round-trip ok) → exit 0
4. Flip UPTIME_KUMA_URL to also fail (edit heredoc or DNS-poison): expect
exit 1 + two ERROR lines.
## Automated
- `python3 -c "import ast; ast.parse(open('/tmp/probe.py').read())"` → OK
(heredoc extracts cleanly).
- `terraform fmt -check -recursive modules/mailserver/` → no diff.
Closes: code-n5l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:03:54 +00:00
|
|
|
uptime_kuma_ok = push_with_retry(
|
|
|
|
|
"Uptime Kuma",
|
|
|
|
|
lambda: requests.get(UPTIME_KUMA_URL, timeout=10),
|
|
|
|
|
UPTIME_KUMA_URL,
|
|
|
|
|
)
|
2026-03-25 22:50:22 +02:00
|
|
|
|
[mailserver] Retry probe Pushgateway + Uptime Kuma pushes with backoff
## Context
The e2e email-roundtrip probe (CronJob `email-roundtrip-monitor`) currently
wraps `requests.put(PUSHGATEWAY, ...)` and `requests.get(UPTIME_KUMA, ...)`
in bare `try/except` that only prints "Failed to push ..." on error. If
Pushgateway is transiently unreachable (e.g., during a Prometheus Helm
upgrade / HPA scale-down / brief network blip) metrics silently drop and
downstream detection relies entirely on `EmailRoundtripStale` firing after
60 min of staleness. Single transient failures masquerade as data-plane
breakage for up to an hour.
Target task: `code-n5l` — Add retry to probe Pushgateway + Uptime Kuma pushes.
## This change
- Extracts a `push_with_retry(label, func, url)` helper that performs 3
attempts with exponential backoff (1s, 2s, 4s). Treats HTTP 2xx as
success, everything else as failure. On final failure, logs an explicit
`ERROR:` line to stderr with the URL and either the last HTTP status or
the exception repr — matches the existing `print(...)` logging style
used throughout the heredoc (no stdlib `logging` dependency added).
- Replaces the two inline `try/requests.put/except print` blocks with
calls to the helper. Pushgateway runs unconditionally; Uptime Kuma
still only runs on round-trip success (same as before).
- Makes exit code responsive to push outcome: probe exits non-zero when
the round-trip itself failed (unchanged), OR when BOTH pushes failed
all retries on the success path. Single-endpoint push failure with the
other succeeding keeps exit 0 — partial observability is preferred
over noisy pod restarts from Kubernetes' Job controller.
## Behavior matrix
```
roundtrip | pushgw | kuma | exit | rationale
----------+--------+------+------+-------------------------------
success | ok | ok | 0 | happy path (unchanged)
success | fail | ok | 0 | one endpoint still has telemetry
success | ok | fail | 0 | one endpoint still has telemetry
success | fail | fail | 1 | NEW — total observability loss
fail | ok | - | 1 | roundtrip failed (unchanged, Kuma skipped)
fail | fail | - | 1 | roundtrip failed (unchanged, Kuma skipped)
```
## What is NOT in this change
- Alert thresholds (`EmailRoundtripStale` still 60m) — explicitly out of
scope per the task description.
- `logging` stdlib adoption — rest of heredoc uses `print`, staying
consistent.
- Moving the heredoc out of `main.tf` into a sidecar Python file —
separate refactor.
## Reproduce locally
1. Point PUSHGATEWAY at a black hole:
`kubectl -n mailserver set env cronjob/email-roundtrip-monitor \`
`PUSHGATEWAY=http://nope.invalid:9091/metrics/job/test`
2. Trigger a one-shot job:
`kubectl -n mailserver create job --from=cronjob/email-roundtrip-monitor probe-test`
3. Expected in logs:
- 3 attempts, each ~1s/2s/4s apart
- `ERROR: Failed to push to Pushgateway after 3 attempts: url=... exception=...`
- Uptime Kuma push still succeeds (round-trip ok) → exit 0
4. Flip UPTIME_KUMA_URL to also fail (edit heredoc or DNS-poison): expect
exit 1 + two ERROR lines.
## Automated
- `python3 -c "import ast; ast.parse(open('/tmp/probe.py').read())"` → OK
(heredoc extracts cleanly).
- `terraform fmt -check -recursive modules/mailserver/` → no diff.
Closes: code-n5l
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:03:54 +00:00
|
|
|
# Exit non-zero when the round-trip itself failed, OR when BOTH push endpoints
|
|
|
|
|
# failed after all retries (only possible on the success path — on failure we
|
|
|
|
|
# only attempt Pushgateway, and the round-trip failure already dominates exit).
|
|
|
|
|
both_pushes_failed = success and (not pushgateway_ok) and (not uptime_kuma_ok)
|
|
|
|
|
sys.exit(0 if (success and not both_pushes_failed) else 1)
|
2026-03-25 22:50:22 +02:00
|
|
|
'
|
|
|
|
|
EOT
|
|
|
|
|
]
|
[mailserver] Move probe secrets to ExternalSecret via ESO [ci skip]
## Context
The email-roundtrip-monitor CronJob injected `BREVO_API_KEY` and
`EMAIL_MONITOR_IMAP_PASSWORD` as inline `env { value = var.xxx }` —
Terraform read them from Vault at plan time and embedded them in the
generated CronJob spec. Anyone with `kubectl describe cronjob` (or
pod-event read) in the `mailserver` namespace could read both secrets
verbatim.
The two upstream Vault entries are not flat strings:
- `secret/viktor` → `brevo_api_key` = base64(JSON({"api_key": "..."}))
- `secret/platform` → `mailserver_accounts` = JSON({"spam@viktorbarzin.me": "<pw>", ...})
A plain ESO `remoteRef.property` can traverse one level of JSON but
cannot base64-decode the wrapper or index a map key that contains `@`.
So the ExternalSecret pulls the raw Vault values and the rendered K8s
Secret is produced via ESO's `target.template` (engineVersion v2, sprig
pipeline `b64dec | fromJson | dig`). `mergePolicy` defaults to Replace,
so only the transformed `BREVO_API_KEY` / `EMAIL_MONITOR_IMAP_PASSWORD`
keys land in the K8s Secret — the raw wrapped inputs never reach it.
## This change
1. New `kubernetes_manifest.email_roundtrip_monitor_secrets` rendering
an `external-secrets.io/v1beta1` ExternalSecret into a K8s Secret
named `mailserver-probe-secrets` via the `vault-kv` ClusterSecretStore.
2. CronJob's two `env { name=... value=var.xxx }` blocks replaced with
a single `env_from { secret_ref { name = "mailserver-probe-secrets" } }`.
3. Unused `brevo_api_key` / `email_monitor_imap_password` module
variables + their wiring in `stacks/mailserver/main.tf` removed.
`data "vault_kv_secret_v2" "viktor"` dropped (last consumer gone).
```
Before: After:
┌────────────┐ ┌────────────┐
│ Vault KV │ │ Vault KV │
└────┬───────┘ └────┬───────┘
│ (plan-time read) │ (runtime pull)
▼ ▼
┌────────────┐ ┌────────────┐
│ Terraform │ │ ESO ctrl │
│ state │ │ +template │
└────┬───────┘ └────┬───────┘
│ inline value= │ sprig b64dec | fromJson
▼ ▼
┌────────────┐ ┌────────────┐
│ CronJob │ <-- kubectl describe leaks! │ K8s Secret │
│ env[].value│ │ probe-sec │
└────────────┘ └────┬───────┘
│ env_from.secret_ref
▼
┌────────────┐
│ CronJob │
│ (no values │
│ in spec) │
└────────────┘
```
## Test Plan
### Automated
`terragrunt plan -target=...ExternalSecret -target=...CronJob`:
```
Plan: 1 to add, 1 to change, 0 to destroy.
+ kubernetes_manifest.email_roundtrip_monitor_secrets (ExternalSecret)
~ kubernetes_cron_job_v1.email_roundtrip_monitor
- env { name = "BREVO_API_KEY" ... }
- env { name = "EMAIL_MONITOR_IMAP_PASSWORD" ... }
+ env_from { secret_ref { name = "mailserver-probe-secrets" } }
```
`terragrunt apply --non-interactive` same targets:
```
Apply complete! Resources: 1 added, 1 changed, 0 destroyed.
```
`kubectl get externalsecret -n mailserver mailserver-probe-secrets`:
```
NAME STORE REFRESH INTERVAL STATUS READY
mailserver-probe-secrets vault-kv 15m SecretSynced True
```
`kubectl get secret -n mailserver mailserver-probe-secrets -o yaml`
exposes exactly two data keys (`BREVO_API_KEY`, `EMAIL_MONITOR_IMAP_PASSWORD`) —
both populated, 120 / 32 base64 chars, no raw `brevo_api_key_wrapped` /
`mailserver_accounts` keys.
`kubectl describe cronjob -n mailserver email-roundtrip-monitor`:
```
Environment Variables from:
mailserver-probe-secrets Secret Optional: false
Environment: <none>
```
(Previously the `Environment:` block listed both secrets with their raw
values.)
### Manual Verification
1. `kubectl create job --from=cronjob/email-roundtrip-monitor \
probe-test-$RANDOM -n mailserver`
2. `kubectl logs -n mailserver -l job-name=probe-test-... --tail=30`
expected:
```
Sent test email via Brevo: 201 marker=e2e-probe-...
Found test email after 1 attempts
Deleted 1 e2e probe email(s)
Round-trip SUCCESS in 20.3s
Pushed metrics to Pushgateway
Pushed to Uptime Kuma
```
3. `kubectl exec -n monitoring deploy/prometheus-prometheus-pushgateway \
-- wget -q -O- http://localhost:9091/metrics | grep email_roundtrip`
shows `email_roundtrip_success=1`, fresh timestamp, duration in range.
4. `kubectl delete job -n mailserver probe-test-...` to clean up.
Closes: code-39v
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:39:06 +00:00
|
|
|
env_from {
|
|
|
|
|
secret_ref {
|
|
|
|
|
name = "mailserver-probe-secrets"
|
|
|
|
|
}
|
2026-03-25 22:50:22 +02:00
|
|
|
}
|
|
|
|
|
resources {
|
|
|
|
|
requests = {
|
|
|
|
|
memory = "64Mi"
|
|
|
|
|
cpu = "10m"
|
|
|
|
|
}
|
|
|
|
|
limits = {
|
|
|
|
|
memory = "128Mi"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dns_config {
|
|
|
|
|
option {
|
|
|
|
|
name = "ndots"
|
|
|
|
|
value = "2"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
[infra] Sweep dns_config ignore_changes across all pod-owning resources [ci skip]
## Context
Wave 3A (commit c9d221d5) added the `# KYVERNO_LIFECYCLE_V1` marker to the
27 pre-existing `ignore_changes = [...dns_config]` sites so they could be
grepped and audited. It did NOT address pod-owning resources that were
simply missing the suppression entirely. Post-Wave-3A sampling (2026-04-18)
found that navidrome, f1-stream, frigate, servarr, monitoring, crowdsec,
and many other stacks showed perpetual `dns_config` drift every plan
because their `kubernetes_deployment` / `kubernetes_stateful_set` /
`kubernetes_cron_job_v1` resources had no `lifecycle {}` block at all.
Root cause (same as Wave 3A): Kyverno's admission webhook stamps
`dns_config { option { name = "ndots"; value = "2" } }` on every pod's
`spec.template.spec.dns_config` to prevent NxDomain search-domain flooding
(see `k8s-ndots-search-domain-nxdomain-flood` skill). Without `ignore_changes`
on every Terraform-managed pod-owner, Terraform repeatedly tries to strip
the injected field.
## This change
Extends the Wave 3A convention by sweeping EVERY `kubernetes_deployment`,
`kubernetes_stateful_set`, `kubernetes_daemon_set`, `kubernetes_cron_job_v1`,
`kubernetes_job_v1` (+ their `_v1` variants) in the repo and ensuring each
carries the right `ignore_changes` path:
- **kubernetes_deployment / stateful_set / daemon_set / job_v1**:
`spec[0].template[0].spec[0].dns_config`
- **kubernetes_cron_job_v1**:
`spec[0].job_template[0].spec[0].template[0].spec[0].dns_config`
(extra `job_template[0]` nesting — the CronJob's PodTemplateSpec is
one level deeper)
Each injection / extension is tagged `# KYVERNO_LIFECYCLE_V1: Kyverno
admission webhook mutates dns_config with ndots=2` inline so the
suppression is discoverable via `rg 'KYVERNO_LIFECYCLE_V1' stacks/`.
Two insertion paths are handled by a Python pass (`/tmp/add_dns_config_ignore.py`):
1. **No existing `lifecycle {}`**: inject a brand-new block just before the
resource's closing `}`. 108 new blocks on 93 files.
2. **Existing `lifecycle {}` (usually for `DRIFT_WORKAROUND: CI owns image tag`
from Wave 4, commit a62b43d1)**: extend its `ignore_changes` list with the
dns_config path. Handles both inline (`= [x]`) and multiline
(`= [\n x,\n]`) forms; ensures the last pre-existing list item carries
a trailing comma so the extended list is valid HCL. 34 extensions.
The script skips anything already mentioning `dns_config` inside an
`ignore_changes`, so re-running is a no-op.
## Scale
- 142 total lifecycle injections/extensions
- 93 `.tf` files touched
- 108 brand-new `lifecycle {}` blocks + 34 extensions of existing ones
- Every Tier 0 and Tier 1 stack with a pod-owning resource is covered
- Together with Wave 3A's 27 pre-existing markers → **169 greppable
`KYVERNO_LIFECYCLE_V1` dns_config sites across the repo**
## What is NOT in this change
- `stacks/trading-bot/main.tf` — entirely commented-out block (`/* … */`).
Python script touched the file, reverted manually.
- `_template/main.tf.example` skeleton — kept minimal on purpose; any
future stack created from it should either inherit the Wave 3A one-line
form or add its own on first `kubernetes_deployment`.
- `terraform fmt` fixes to pre-existing alignment issues in meshcentral,
nvidia/modules/nvidia, vault — unrelated to this commit. Left for a
separate fmt-only pass.
- Non-pod resources (`kubernetes_service`, `kubernetes_secret`,
`kubernetes_manifest`, etc.) — they don't own pods so they don't get
Kyverno dns_config mutation.
## Verification
Random sample post-commit:
```
$ cd stacks/navidrome && ../../scripts/tg plan → No changes.
$ cd stacks/f1-stream && ../../scripts/tg plan → No changes.
$ cd stacks/frigate && ../../scripts/tg plan → No changes.
$ rg -c 'KYVERNO_LIFECYCLE_V1' stacks/ --include='*.tf' --include='*.tf.example' \
| awk -F: '{s+=$2} END {print s}'
169
```
## Reproduce locally
1. `git pull`
2. `rg 'KYVERNO_LIFECYCLE_V1' stacks/ | wc -l` → 169+
3. `cd stacks/navidrome && ../../scripts/tg plan` → expect 0 drift on
the deployment's dns_config field.
Refs: code-seq (Wave 3B dns_config class closed; kubernetes_manifest
annotation class handled separately in 8d94688d for tls_secret)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 21:19:48 +00:00
|
|
|
lifecycle {
|
|
|
|
|
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
|
|
|
|
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
|
|
|
|
}
|
2026-03-25 22:50:22 +02:00
|
|
|
}
|
|
|
|
|
|
[mailserver] Add daily backup CronJob for mailserver PVC
## Context
The mailserver stack holds everything valuable and hard to recreate:
243M of maildirs, dovecot/rspamd state, and the DKIM private key that
signs outbound mail. Today the only defense is the LVM thin-pool
snapshots on the PVE host (7-day retention, storage-class scope only)
— there is no app-level backup. Infra/.claude/CLAUDE.md mandates that
every proxmox-lvm(-encrypted) app ship a NFS-backed backup CronJob,
and the mailserver stack was the only one still out of compliance.
Loss of mailserver-data-encrypted without backups = total loss of all
stored mail plus a DKIM key rotation (which requires a DNS update and
breaks signature verification on every message in transit for the TTL
window). Unacceptable for a service people actually use.
Trade-offs considered:
- mysqldump-style single-file dump vs rsync snapshot — maildirs are
millions of small files, not a DB export. rsync --link-dest gives
incremental weekly snapshots for ~10% of the cost of a full copy.
- RWO PVC read-only mount — the underlying PVC is ReadWriteOnce, so
the backup Job has to co-locate with the mailserver pod. vaultwarden
solves this with pod_affinity; mirrored here.
- Image choice — alpine + apk add rsync matches vaultwarden's pattern
and keeps the container image small.
## This change
Adds `kubernetes_cron_job_v1.mailserver-backup` + NFS PV/PVC to the
mailserver module. Runs daily at 03:00 (avoids the 00:30 mysql-backup
and 00:45 per-db windows, and the */20 email-roundtrip cadence). The
job rsyncs /var/mail, /var/mail-state, /var/log/mail into
/srv/nfs/mailserver-backup/<YYYY-WW>/ with --link-dest against the
previous week for space-efficient incrementals. 8-week retention.
Data layout (flowed through from the deployment's subPath mounts so
the rsync tree matches the mailserver's own on-disk layout):
PVC mailserver-data-encrypted (RWO, 2Gi)
├─ data/ (subPath) → pod's /var/mail → backup/<week>/data/
├─ state/ (subPath) → pod's /var/mail-state → backup/<week>/state/
└─ log/ (subPath) → pod's /var/log/mail → backup/<week>/log/
Safety:
- PVC mounted read-only (volume.persistent_volume_claim.read_only
AND all three volume_mounts set read_only=true) so a backup-script
bug cannot corrupt maildirs.
- pod_affinity on app=mailserver + topology_key=hostname forces the
Job pod onto the same node holding the RWO PVC attachment.
- set -euxo pipefail + per-directory existence guard so a missing
subPath short-circuits cleanly instead of silently no-op'ing.
Metrics pushed to Pushgateway match the mysql-backup/vaultwarden-backup
convention (job="mailserver-backup"):
backup_duration_seconds, backup_read_bytes, backup_written_bytes,
backup_output_bytes, backup_last_success_timestamp.
Alert rules added in monitoring stack, mirroring Mysql/Vaultwarden:
- MailserverBackupStale — 36h threshold, critical, 30m for:
- MailserverBackupNeverSucceeded — critical, 1h for:
## Reproduce locally
1. cd infra/stacks/mailserver && ../../scripts/tg plan
Expected: 3 to add (cronjob + NFS PV + PVC), unrelated drift on
deployment/service is pre-existing.
2. ../../scripts/tg apply --non-interactive \
-target=module.mailserver.module.nfs_mailserver_backup_host \
-target=module.mailserver.kubernetes_cron_job_v1.mailserver-backup
3. cd ../monitoring && ../../scripts/tg apply --non-interactive
4. kubectl create job --from=cronjob/mailserver-backup \
mailserver-backup-test -n mailserver
5. kubectl wait --for=condition=complete --timeout=300s \
job/mailserver-backup-test -n mailserver
6. Expected: test pod co-locates with mailserver on same node
(k8s-node2 today), rsync writes ~950M to
/srv/nfs/mailserver-backup/<YYYY-WW>/, Pushgateway exposes
backup_output_bytes{job="mailserver-backup"}.
## Test Plan
### Automated
$ kubectl get cronjob -n mailserver mailserver-backup
NAME SCHEDULE TIMEZONE SUSPEND ACTIVE LAST SCHEDULE AGE
mailserver-backup 0 3 * * * <none> False 0 <none> 3s
$ kubectl create job --from=cronjob/mailserver-backup \
mailserver-backup-test -n mailserver
job.batch/mailserver-backup-test created
$ kubectl wait --for=condition=complete --timeout=300s \
job/mailserver-backup-test -n mailserver
job.batch/mailserver-backup-test condition met
$ kubectl logs -n mailserver job/mailserver-backup-test | tail -5
=== Backup IO Stats ===
duration: 80s
read: 1120 MiB
written: 1186 MiB
output: 947.0M
$ kubectl run nfs-verify --rm --image=alpine --restart=Never \
--overrides='{...nfs mount /srv/nfs...}' \
-n mailserver --attach -- ls -la /nfs/mailserver-backup/
947.0M /nfs/mailserver-backup/2026-15
$ curl http://prometheus-prometheus-pushgateway.monitoring:9091/metrics \
| grep mailserver-backup
backup_duration_seconds{instance="",job="mailserver-backup"} 80
backup_last_success_timestamp{instance="",job="mailserver-backup"} 1.776554641e+09
backup_output_bytes{instance="",job="mailserver-backup"} 9.92315701e+08
backup_read_bytes{instance="",job="mailserver-backup"} 1.175027712e+09
backup_written_bytes{instance="",job="mailserver-backup"} 1.244254208e+09
$ curl -s http://prometheus-server/api/v1/rules \
| jq '.data.groups[].rules[] | select(.name | test("Mailserver"))'
MailserverBackupStale: (time() - kube_cronjob_status_last_successful_time{cronjob="mailserver-backup",namespace="mailserver"}) > 129600
MailserverBackupNeverSucceeded: kube_cronjob_status_last_successful_time{cronjob="mailserver-backup",namespace="mailserver"} == 0
### Manual Verification
1. Wait for the scheduled 03:00 run tonight; verify
`kubectl get job -n mailserver` shows a new completed job.
2. Check that `backup_last_success_timestamp` advances past today.
3. Confirm `MailserverBackupNeverSucceeded` did not fire.
4. Next week (week 16), confirm `--link-dest` builds hardlinks vs
2026-15 (size delta should drop from ~950M to ~the actual churn).
## Deviations from mysql-backup pattern
- Image: alpine + rsync (mirrors vaultwarden — mysql's `mysql:8.0`
base is not applicable for a filesystem rsync).
- pod_affinity: required for RWO PVC co-location (mysql uses its own
MySQL service for network access; mailserver must mount the PVC).
- Metric push via wget (mirrors vaultwarden; alpine has wget, not curl).
- Week-folder layout with --link-dest rotation: rsync pattern, closer
to the PVE daily-backup script than mysql's single-file gzip dumps.
[ci skip]
Closes: code-z26
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 23:26:08 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Mailserver Backup — Daily rsync of maildirs, mail-state, and log
|
|
|
|
|
# Pattern mirrors vaultwarden-backup (pod_affinity for RWO co-location, /backup
|
|
|
|
|
# write to NFS, Pushgateway metrics). Runs at 03:00 to avoid overlap with
|
|
|
|
|
# mysql-backup (00:30), vaultwarden-backup (*/6h), email-roundtrip (*/20m).
|
|
|
|
|
# Total loss of this PVC = all maildirs + DKIM keys gone; regenerating DKIM
|
|
|
|
|
# requires DNS changes, hence backup is critical.
|
|
|
|
|
# =============================================================================
|
|
|
|
|
module "nfs_mailserver_backup_host" {
|
|
|
|
|
source = "../../../../modules/kubernetes/nfs_volume"
|
|
|
|
|
name = "mailserver-backup-host"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
nfs_server = var.nfs_server
|
|
|
|
|
nfs_path = "/srv/nfs/mailserver-backup"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_cron_job_v1" "mailserver-backup" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "mailserver-backup"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
concurrency_policy = "Replace"
|
|
|
|
|
failed_jobs_history_limit = 5
|
|
|
|
|
schedule = "0 3 * * *"
|
|
|
|
|
starting_deadline_seconds = 10
|
|
|
|
|
successful_jobs_history_limit = 10
|
|
|
|
|
job_template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
backoff_limit = 3
|
|
|
|
|
ttl_seconds_after_finished = 10
|
|
|
|
|
template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
# RWO co-location: backup pod must land on the same node as the
|
|
|
|
|
# mailserver pod because mailserver-data-encrypted is ReadWriteOnce.
|
|
|
|
|
affinity {
|
|
|
|
|
pod_affinity {
|
|
|
|
|
required_during_scheduling_ignored_during_execution {
|
|
|
|
|
label_selector {
|
|
|
|
|
match_labels = {
|
|
|
|
|
app = "mailserver"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
topology_key = "kubernetes.io/hostname"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
container {
|
|
|
|
|
name = "mailserver-backup"
|
|
|
|
|
image = "docker.io/library/alpine"
|
|
|
|
|
command = ["/bin/sh", "-c", <<-EOT
|
|
|
|
|
set -euxo pipefail
|
|
|
|
|
apk add --no-cache rsync
|
|
|
|
|
_t0=$(date +%s)
|
|
|
|
|
_rb0=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
_wb0=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
|
|
|
|
|
week=$(date +"%Y-%W")
|
|
|
|
|
prev_week=$(date -d "-7 days" +"%Y-%W" 2>/dev/null || echo "")
|
|
|
|
|
dst=/backup/$week
|
|
|
|
|
mkdir -p "$dst"
|
|
|
|
|
|
|
|
|
|
# Use --link-dest against previous week for space-efficient
|
|
|
|
|
# incrementals (unchanged files are hardlinked, not re-copied).
|
|
|
|
|
link_dest_arg=""
|
|
|
|
|
if [ -n "$prev_week" ] && [ -d "/backup/$prev_week" ]; then
|
|
|
|
|
link_dest_arg="--link-dest=/backup/$prev_week"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Mailserver data layout (from deployment subPath mounts):
|
|
|
|
|
# /var/mail -> data (maildirs)
|
|
|
|
|
# /var/mail-state -> state (postfix, dovecot, rspamd, dkim keys)
|
|
|
|
|
# /var/log/mail -> log (mail logs)
|
|
|
|
|
for src in /var/mail /var/mail-state /var/log/mail; do
|
|
|
|
|
[ -d "$src" ] || { echo "SKIP missing $src"; continue; }
|
|
|
|
|
name=$(basename "$src")
|
|
|
|
|
rsync -aH --delete $link_dest_arg "$src/" "$dst/$name/"
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
# Rotate — keep 8 weekly snapshots (~2 months)
|
|
|
|
|
find /backup -maxdepth 1 -mindepth 1 -type d -regex '.*/[0-9]+-[0-9]+$' | sort | head -n -8 | xargs -r rm -rf
|
|
|
|
|
|
|
|
|
|
_dur=$(($(date +%s) - _t0))
|
|
|
|
|
_rb1=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
_wb1=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
echo "=== Backup IO Stats ==="
|
|
|
|
|
echo "duration: $${_dur}s"
|
|
|
|
|
echo "read: $(( (_rb1 - _rb0) / 1048576 )) MiB"
|
|
|
|
|
echo "written: $(( (_wb1 - _wb0) / 1048576 )) MiB"
|
|
|
|
|
echo "output: $(du -sh "$dst" | awk '{print $1}')"
|
|
|
|
|
|
|
|
|
|
_out_bytes=$(du -sb "$dst" | awk '{print $1}')
|
|
|
|
|
wget -qO- --post-data "backup_duration_seconds $${_dur}
|
|
|
|
|
backup_read_bytes $(( _rb1 - _rb0 ))
|
|
|
|
|
backup_written_bytes $(( _wb1 - _wb0 ))
|
|
|
|
|
backup_output_bytes $${_out_bytes}
|
|
|
|
|
backup_last_success_timestamp $(date +%s)
|
|
|
|
|
" "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/mailserver-backup" || true
|
|
|
|
|
EOT
|
|
|
|
|
]
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/mail"
|
|
|
|
|
sub_path = "data"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/mail-state"
|
|
|
|
|
sub_path = "state"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "data"
|
|
|
|
|
mount_path = "/var/log/mail"
|
|
|
|
|
sub_path = "log"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "backup"
|
|
|
|
|
mount_path = "/backup"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "data"
|
|
|
|
|
persistent_volume_claim {
|
|
|
|
|
claim_name = kubernetes_persistent_volume_claim.data_encrypted.metadata[0].name
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "backup"
|
|
|
|
|
persistent_volume_claim {
|
|
|
|
|
claim_name = module.nfs_mailserver_backup_host.claim_name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dns_config {
|
|
|
|
|
option {
|
|
|
|
|
name = "ndots"
|
|
|
|
|
value = "2"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lifecycle {
|
|
|
|
|
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
|
|
|
|
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[mailserver] Add backup CronJob for Roundcube html + enigma PVCs
## Context
Roundcube webmail runs with two encrypted RWO PVCs (see roundcubemail.tf:
`roundcubemail-html-encrypted`, `roundcubemail-enigma-encrypted`). These
carry user-visible state that is NOT regenerable without user action:
- `html` PVC → Apache docroot, plugin installs, skin overrides, session
artefacts (two_factor_webauthn keys, persistent_login tokens, rcguard
throttle state)
- `enigma` PVC → user-uploaded PGP private keyrings
Per the subdir CLAUDE.md "Storage & Backup Architecture" rule every
proxmox-lvm* PVC MUST have a backup CronJob writing to NFS
`/mnt/main/<app>-backup/`. Mailserver already complies via code-z26's
`mailserver-backup` CronJob; Roundcube does not. Losing either Roundcube
PVC means users must re-add 2FA devices, re-install plugins, and
re-import PGP keys — none of it recoverable from a database dump.
Target task: `code-1f6`.
## This change
- Adds `module.nfs_roundcube_backup_host` sourcing
`modules/kubernetes/nfs_volume` pointed at
`/srv/nfs/roundcube-backup` on the Proxmox host (NFSv4, inotify
change-tracker picks it up for Synology offsite).
- Adds `kubernetes_cron_job_v1.roundcube-backup`:
- Schedule `10 3 * * *` — 10 minutes after `mailserver-backup`
(`0 3 * * *`) to avoid NFS write-window contention. Roundcube PVCs
are tiny (<200 MiB combined on current cluster) so the window is
well under 10 min.
- `pod_affinity` on `app=roundcubemail` (Roundcube runs 1 replica with
`Recreate` strategy on a fresh node per pod; the backup pod must
co-locate because both PVCs are RWO).
- `rsync -aH --delete --link-dest=/backup/<prev-week>` into
`/backup/<YYYY-WW>/{html,enigma}/` — hardlinks unchanged files vs
the previous weekly snapshot, keeping storage cost ~= delta only.
- Weekly rotation retains 8 snapshots (~2 months), matching
`mailserver-backup`.
- Pushgateway metrics under `job=roundcube-backup` so existing
`BackupDurationHigh` / `BackupStale` alert patterns detect
regressions without extra wiring.
- `KYVERNO_LIFECYCLE_V1` `ignore_changes` for mutated `dns_config`.
## Layout
```
NFS server 192.168.1.127:/srv/nfs/
├── mailserver-backup/ (0 3 * * * — code-z26)
│ └── <YYYY-WW>/{data,state,log}/
└── roundcube-backup/ (10 3 * * * — this change)
└── <YYYY-WW>/{html,enigma}/
```
## What is NOT in this change
- Changing the mailserver-backup CronJob to also cover Roundcube. Two
separate CronJobs keep the concerns (and pod anti-affinity/affinity)
clean; the 10-min stagger eliminates the contention justification for
merging them.
- Retention alerting tuning — existing Pushgateway/Prometheus rule
ecosystem suffices for now.
- Restore tooling — follows the standard pattern in
`docs/runbooks/` (rsync back, fix perms).
## Reproduce locally
1. Plan: `cd stacks/mailserver && scripts/tg plan -lock=false` →
2 new resources (nfs_volume module + CronJob).
2. Apply, then trigger a one-shot run:
`kubectl -n mailserver create job --from=cronjob/roundcube-backup roundcube-backup-manual-1`
3. Expected on success:
- `kubectl -n mailserver logs job/roundcube-backup-manual-1` → "=== Backup IO Stats ===".
- On Proxmox host:
`ls /srv/nfs/roundcube-backup/$(date +%Y-%W)/` → `html`, `enigma`.
- `/mnt/backup/.nfs-changes.log` (Proxmox) lists fresh paths under
`roundcube-backup/` within ~1s of the rsync finishing.
- Pushgateway: `curl -s prometheus-prometheus-pushgateway.monitoring:9091/metrics | grep roundcube`
shows `backup_duration_seconds`, `backup_last_success_timestamp`.
## Automated
- `terraform fmt -check -recursive stacks/mailserver/modules/mailserver/` → clean.
- `scripts/tg plan -lock=false` in stacks/mailserver expected to show
`+ module.nfs_roundcube_backup_host.*`, `+ kubernetes_cron_job_v1.roundcube-backup`.
Closes: code-1f6
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:10:35 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Roundcube Backup — Daily rsync of html + enigma PVCs to NFS
|
|
|
|
|
# Roundcube uses two encrypted RWO PVCs (see roundcubemail.tf):
|
|
|
|
|
# - roundcubemail-html-encrypted → /var/www/html (plugins, user sessions, skin overrides)
|
|
|
|
|
# - roundcubemail-enigma-encrypted → /var/roundcube/enigma (user-uploaded PGP keys)
|
|
|
|
|
# Losing either one = users lose plugin state + have to re-import PGP keys.
|
|
|
|
|
# Mirrors the mailserver-backup pattern but:
|
|
|
|
|
# - pod_affinity targets app=roundcubemail (both PVCs attach to the
|
|
|
|
|
# Roundcube pod, not mailserver)
|
|
|
|
|
# - schedule offset by +10m (03:10) so two NFS-writers don't overlap
|
|
|
|
|
# - writes to /srv/nfs/roundcube-backup/<YYYY-WW>/{html,enigma}/
|
|
|
|
|
# =============================================================================
|
|
|
|
|
module "nfs_roundcube_backup_host" {
|
|
|
|
|
source = "../../../../modules/kubernetes/nfs_volume"
|
|
|
|
|
name = "roundcube-backup-host"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
nfs_server = var.nfs_server
|
|
|
|
|
nfs_path = "/srv/nfs/roundcube-backup"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_cron_job_v1" "roundcube-backup" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "roundcube-backup"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
concurrency_policy = "Replace"
|
|
|
|
|
failed_jobs_history_limit = 5
|
|
|
|
|
# +10 min offset vs mailserver-backup (03:00) to avoid NFS contention.
|
|
|
|
|
schedule = "10 3 * * *"
|
|
|
|
|
starting_deadline_seconds = 10
|
|
|
|
|
successful_jobs_history_limit = 10
|
|
|
|
|
job_template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
backoff_limit = 3
|
|
|
|
|
ttl_seconds_after_finished = 10
|
|
|
|
|
template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
# RWO co-location: Roundcube PVCs are ReadWriteOnce; the backup
|
|
|
|
|
# pod must land on the same node as the Roundcube pod (single
|
|
|
|
|
# replica, Recreate strategy — see roundcubemail.tf).
|
|
|
|
|
affinity {
|
|
|
|
|
pod_affinity {
|
|
|
|
|
required_during_scheduling_ignored_during_execution {
|
|
|
|
|
label_selector {
|
|
|
|
|
match_labels = {
|
|
|
|
|
app = "roundcubemail"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
topology_key = "kubernetes.io/hostname"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
container {
|
|
|
|
|
name = "roundcube-backup"
|
|
|
|
|
image = "docker.io/library/alpine"
|
|
|
|
|
command = ["/bin/sh", "-c", <<-EOT
|
|
|
|
|
set -euxo pipefail
|
|
|
|
|
apk add --no-cache rsync
|
|
|
|
|
_t0=$(date +%s)
|
|
|
|
|
_rb0=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
_wb0=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
|
|
|
|
|
week=$(date +"%Y-%W")
|
|
|
|
|
prev_week=$(date -d "-7 days" +"%Y-%W" 2>/dev/null || echo "")
|
|
|
|
|
dst=/backup/$week
|
|
|
|
|
mkdir -p "$dst"
|
|
|
|
|
|
|
|
|
|
# Use --link-dest against previous week for space-efficient
|
|
|
|
|
# incrementals (unchanged files are hardlinked, not re-copied).
|
|
|
|
|
link_dest_arg=""
|
|
|
|
|
if [ -n "$prev_week" ] && [ -d "/backup/$prev_week" ]; then
|
|
|
|
|
link_dest_arg="--link-dest=/backup/$prev_week"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Roundcube data layout (from deployment volume mounts in roundcubemail.tf):
|
|
|
|
|
# /src/html -> roundcubemail-html-encrypted (html PVC)
|
|
|
|
|
# /src/enigma -> roundcubemail-enigma-encrypted (enigma PVC, PGP keys)
|
|
|
|
|
for src in /src/html /src/enigma; do
|
|
|
|
|
[ -d "$src" ] || { echo "SKIP missing $src"; continue; }
|
|
|
|
|
name=$(basename "$src")
|
|
|
|
|
rsync -aH --delete $link_dest_arg "$src/" "$dst/$name/"
|
|
|
|
|
done
|
|
|
|
|
|
|
|
|
|
# Rotate — keep 8 weekly snapshots (~2 months)
|
|
|
|
|
find /backup -maxdepth 1 -mindepth 1 -type d -regex '.*/[0-9]+-[0-9]+$' | sort | head -n -8 | xargs -r rm -rf
|
|
|
|
|
|
|
|
|
|
_dur=$(($(date +%s) - _t0))
|
|
|
|
|
_rb1=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
_wb1=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0)
|
|
|
|
|
echo "=== Backup IO Stats ==="
|
|
|
|
|
echo "duration: $${_dur}s"
|
|
|
|
|
echo "read: $(( (_rb1 - _rb0) / 1048576 )) MiB"
|
|
|
|
|
echo "written: $(( (_wb1 - _wb0) / 1048576 )) MiB"
|
|
|
|
|
echo "output: $(du -sh "$dst" | awk '{print $1}')"
|
|
|
|
|
|
|
|
|
|
_out_bytes=$(du -sb "$dst" | awk '{print $1}')
|
|
|
|
|
wget -qO- --post-data "backup_duration_seconds $${_dur}
|
|
|
|
|
backup_read_bytes $(( _rb1 - _rb0 ))
|
|
|
|
|
backup_written_bytes $(( _wb1 - _wb0 ))
|
|
|
|
|
backup_output_bytes $${_out_bytes}
|
|
|
|
|
backup_last_success_timestamp $(date +%s)
|
|
|
|
|
" "http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/roundcube-backup" || true
|
|
|
|
|
EOT
|
|
|
|
|
]
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "html"
|
|
|
|
|
mount_path = "/src/html"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "enigma"
|
|
|
|
|
mount_path = "/src/enigma"
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
volume_mount {
|
|
|
|
|
name = "backup"
|
|
|
|
|
mount_path = "/backup"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "html"
|
|
|
|
|
persistent_volume_claim {
|
|
|
|
|
claim_name = kubernetes_persistent_volume_claim.roundcube_html_encrypted.metadata[0].name
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "enigma"
|
|
|
|
|
persistent_volume_claim {
|
|
|
|
|
claim_name = kubernetes_persistent_volume_claim.roundcube_enigma_encrypted.metadata[0].name
|
|
|
|
|
read_only = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
volume {
|
|
|
|
|
name = "backup"
|
|
|
|
|
persistent_volume_claim {
|
|
|
|
|
claim_name = module.nfs_roundcube_backup_host.claim_name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dns_config {
|
|
|
|
|
option {
|
|
|
|
|
name = "ndots"
|
|
|
|
|
value = "2"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lifecycle {
|
|
|
|
|
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
|
|
|
|
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[mailserver] Add targeted retention for spam@ mailbox
## Context
The @viktorbarzin.me catch-all routes to spam@viktorbarzin.me. The
mailbox had no retention policy. On 2026-04-18 it held 519 messages
consuming 43 MiB. Without a policy, the only brake on growth was
manual deletion, which has not been happening - hence the bd task.
Viktor's explicit constraint when filing code-oy4: DO NOT blind
age-expunge. We need targeted retention that keeps genuine forwarded
human mail for a long time while shedding the recurring-newsletter
cruft that dominates the byte count.
## Profile findings (2026-04-18, verified on the live pod)
Total: 519 messages, 43 MiB, 0 in new/, 0 in tmp/.
Top senders by volume:
138 dan@tldrnewsletter.com
51 hi@ratepunk.com
40 uber@uber.com
35 truenas@viktorbarzin.me
19 ubereats@uber.com
15 hello@travel.jacksflightclub.com
12 chris@chriswillx.com
10 me@viktorbarzin.me
Top senders by storage bytes:
8,176,481 dan@tldrnewsletter.com (19 % of 43 MiB alone)
2,866,104 uber@uber.com
2,207,458 noreply@mail.selfh.st
2,066,094 hi@ratepunk.com
1,675,435 ubereats@uber.com
Age distribution:
97 % older than 14 days (502 / 519)
23 % older than 90 days (121 / 519)
Automated-sender markers:
66 % carry List-Unsubscribe: (342 / 519)
4 % carry Precedence: bulk|list|junk ( 21 / 519)
34 % carry neither marker (= human-ish tail) (177 / 519)
Combined "automated AND >14d": 328 messages -> target of rule 1.
## Retention strategy
Signed off by Viktor 2026-04-18. Two rules, both delete-leaf:
1. Older than 14 days AND header matches one of:
- `^List-Unsubscribe:`
- `^Precedence:\s*(bulk|list|junk)`
- `^Auto-Submitted:\s*auto-`
-> DELETE.
Rationale: these markers are the RFC-agreed indicators of bulk /
robotic senders. A 14-day window still lets genuine subscription
alerts (delivery, flight, calendar invite) come to attention.
2. Older than 90 days AND no automated marker at all
-> DELETE.
Rationale: these are long-tail forwards from real people to the
catch-all. 90 days is deliberately generous - I would rather
leak bytes than lose Viktor's personal correspondence.
3. Everything else -> KEEP (recent traffic, or aged human tail
younger than 90d).
## Implementation
A `kubernetes_cron_job_v1.spam_retention` running every 4h (at :17
past) that `kubectl exec`s a Python retention script into the
mailserver pod.
Why kubectl exec and not a sibling CronJob with the Maildir mounted:
mailserver-data-encrypted is a RWO volume held by the mailserver
pod. A sibling would fail to attach. The nextcloud-watchdog pattern
in stacks/nextcloud/main.tf already solves this for a similar
"interact with the live pod on a schedule" shape. Mirrored here with
its own SA + Role + RoleBinding scoped to list/get pods and create
pods/exec in the mailserver namespace only.
Why Python and not pure shell: POSIX `find + stat + awk` struggles
with the header-scan-up-to-blank-line rule, and `stat -c` is Linux-
GNU-specific anyway. The script reads each message's first 64 KiB,
stops at the first blank line, scans headers only, then checks mtime.
The CronJob streams the Python source via `kubectl exec -i ... --
python3 - <<PYEOF`. After the retention pass, `doveadm force-resync
-u spam@viktorbarzin.me INBOX/spam` refreshes Dovecot's cached index
so the deletions appear in IMAP immediately instead of after the next
pod restart.
Includes the standard KYVERNO_LIFECYCLE_V1 marker on the CronJob so
Kyverno ndots mutation does not cause perpetual drift.
## What is NOT in this change
- Dovecot sieve rules (no sieve infrastructure exists in the module;
the plan file's fallback option was precisely this CronJob path).
- Push of retention metrics to Pushgateway - the script prints them
to the job log for now; plumbing Pushgateway is a follow-up if
Viktor wants alerts.
- Any touch of other mailboxes - only `/var/mail/viktorbarzin.me/spam/cur`
is walked.
- Any mailserver pod restart or config reload.
## Test plan
### Automated
`terraform fmt` + `terragrunt hclfmt` pass. `scripts/tg plan` on the
mailserver stack shows:
Plan: 7 to add, 3 to change, 0 to destroy.
Of the 7 adds, 4 are mine (SA + Role + RoleBinding + CronJob). The
other 3 adds belong to the concurrent roundcube-backup CronJob +
nfs_roundcube_backup_host PV + PVC already on master in parallel.
The 3 in-place updates are pre-existing drift on the mailserver
Deployment, Service and email_roundtrip_monitor CronJob, not
introduced by this change.
### Manual Verification
After `scripts/tg apply` lands the CronJob:
1. Trigger an immediate run:
`kubectl -n mailserver create job --from=cronjob/spam-retention manual-1`
2. Wait for completion, read the log:
`kubectl -n mailserver logs job/manual-1`
-> expected tail:
spam_retention_scanned_total <N>
spam_retention_auto_deleted_total <M>
spam_retention_human_deleted_total <H>
spam_retention_kept_total <K>
spam_retention_errors_total 0
Retention pass complete
3. Confirm mailbox shrunk:
`kubectl -n mailserver exec deploy/mailserver -c docker-mailserver \
-- du -sh /var/mail/viktorbarzin.me/spam/`
-> expected: well below 43 MiB within one run (bulk rule alone
purges ~328 messages per the profile numbers above).
4. Confirm IMAP reflects the deletions:
`kubectl -n mailserver exec deploy/mailserver -c docker-mailserver \
-- doveadm mailbox status -u spam@viktorbarzin.me messages INBOX/spam`
-> expected: message count dropped accordingly.
5. 4 hours later, confirm the next scheduled run logs a much
smaller scan count and 0 deletions (nothing new crossed the
threshold).
Closes: code-oy4
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:20:54 +00:00
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Spam mailbox targeted retention (code-oy4)
|
|
|
|
|
#
|
|
|
|
|
# The @viktorbarzin.me catch-all routes to spam@viktorbarzin.me. Unbounded
|
|
|
|
|
# growth (~43 MiB baseline on 2026-04-18, 519 messages, top sender
|
|
|
|
|
# tldrnewsletter.com = 138 msgs / 8.2 MiB) makes it painful to triage.
|
|
|
|
|
# Profile (2026-04-18):
|
|
|
|
|
# - 502/519 messages older than 14 days (97 %)
|
|
|
|
|
# - 342/519 carry List-Unsubscribe: (66 %)
|
|
|
|
|
# - 21/519 carry Precedence: bulk ( 4 %)
|
|
|
|
|
# - 177/519 carry neither marker (= human-ish, 34 %)
|
|
|
|
|
#
|
|
|
|
|
# Strategy (user-signed-off 2026-04-18, do NOT blind-age-expunge):
|
|
|
|
|
# - Messages older than 14 days carrying List-Unsubscribe OR
|
|
|
|
|
# Precedence: bulk|list|junk OR Auto-Submitted: auto-* -> DELETE
|
|
|
|
|
# - Messages older than 90 days with no automated-sender marker
|
|
|
|
|
# -> DELETE (long-tail human forwards)
|
|
|
|
|
# - Everything else -> KEEP
|
|
|
|
|
#
|
|
|
|
|
# Implementation: kubectl exec into the mailserver pod because the
|
|
|
|
|
# Maildir lives on a RWO encrypted PVC; a sibling CronJob would fail to
|
|
|
|
|
# attach the volume while the mailserver pod holds it. Pattern mirrors
|
|
|
|
|
# the `nextcloud-watchdog` in stacks/nextcloud/main.tf.
|
|
|
|
|
# =============================================================================
|
|
|
|
|
resource "kubernetes_service_account" "spam_retention" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "spam-retention"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_role" "spam_retention" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "spam-retention"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
rule {
|
|
|
|
|
api_groups = [""]
|
|
|
|
|
resources = ["pods"]
|
|
|
|
|
verbs = ["list", "get"]
|
|
|
|
|
}
|
|
|
|
|
rule {
|
|
|
|
|
api_groups = [""]
|
|
|
|
|
resources = ["pods/exec"]
|
|
|
|
|
verbs = ["create"]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_role_binding" "spam_retention" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "spam-retention"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
role_ref {
|
|
|
|
|
api_group = "rbac.authorization.k8s.io"
|
|
|
|
|
kind = "Role"
|
|
|
|
|
name = kubernetes_role.spam_retention.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
subject {
|
|
|
|
|
kind = "ServiceAccount"
|
|
|
|
|
name = kubernetes_service_account.spam_retention.metadata[0].name
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource "kubernetes_cron_job_v1" "spam_retention" {
|
|
|
|
|
metadata {
|
|
|
|
|
name = "spam-retention"
|
|
|
|
|
namespace = kubernetes_namespace.mailserver.metadata[0].name
|
|
|
|
|
}
|
|
|
|
|
spec {
|
|
|
|
|
schedule = "17 */4 * * *"
|
|
|
|
|
concurrency_policy = "Forbid"
|
|
|
|
|
successful_jobs_history_limit = 2
|
|
|
|
|
failed_jobs_history_limit = 3
|
|
|
|
|
starting_deadline_seconds = 300
|
|
|
|
|
job_template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
active_deadline_seconds = 600
|
|
|
|
|
backoff_limit = 1
|
|
|
|
|
ttl_seconds_after_finished = 600
|
|
|
|
|
template {
|
|
|
|
|
metadata {}
|
|
|
|
|
spec {
|
|
|
|
|
service_account_name = kubernetes_service_account.spam_retention.metadata[0].name
|
|
|
|
|
restart_policy = "Never"
|
|
|
|
|
container {
|
|
|
|
|
name = "spam-retention"
|
|
|
|
|
image = "bitnami/kubectl:latest"
|
|
|
|
|
command = ["/bin/bash", "-c", <<-EOF
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
POD=$(kubectl -n mailserver get pods -l app=mailserver -o jsonpath='{.items[0].metadata.name}')
|
|
|
|
|
if [ -z "$POD" ]; then
|
|
|
|
|
echo "ERROR: no mailserver pod found" >&2
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
echo "Targeting pod $POD"
|
|
|
|
|
|
|
|
|
|
# Stream the retention script to python3 inside the mailserver
|
|
|
|
|
# container via stdin. Keeping the logic in Python avoids the
|
|
|
|
|
# POSIX-sh/awk fragility around stat(1) differences and header
|
|
|
|
|
# matching.
|
|
|
|
|
kubectl -n mailserver exec -i "$POD" -c docker-mailserver -- python3 - <<'PYEOF'
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
import time
|
|
|
|
|
|
|
|
|
|
SPAM = "/var/mail/viktorbarzin.me/spam/cur"
|
|
|
|
|
# Retention thresholds, in days, one per rule.
|
|
|
|
|
AUTOMATED_MAX_AGE_DAYS = 14
|
|
|
|
|
HUMAN_MAX_AGE_DAYS = 90
|
|
|
|
|
HEADER_SCAN_BYTES = 65536
|
|
|
|
|
|
|
|
|
|
AUTO_PATTERNS = (
|
|
|
|
|
re.compile(rb"^list-unsubscribe:", re.IGNORECASE),
|
|
|
|
|
re.compile(rb"^precedence:\s*(bulk|list|junk)", re.IGNORECASE),
|
|
|
|
|
re.compile(rb"^auto-submitted:\s*auto-", re.IGNORECASE),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def is_automated(path):
|
|
|
|
|
try:
|
|
|
|
|
with open(path, "rb") as fh:
|
|
|
|
|
head = fh.read(HEADER_SCAN_BYTES)
|
|
|
|
|
except OSError:
|
|
|
|
|
return False
|
|
|
|
|
hdr, _, _ = head.partition(b"\r\n\r\n")
|
|
|
|
|
if hdr == head:
|
|
|
|
|
hdr, _, _ = head.partition(b"\n\n")
|
|
|
|
|
for line in hdr.splitlines():
|
|
|
|
|
for pat in AUTO_PATTERNS:
|
|
|
|
|
if pat.search(line):
|
|
|
|
|
return True
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
if not os.path.isdir(SPAM):
|
|
|
|
|
print(f"SKIP: {SPAM} does not exist")
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
|
|
|
|
|
now = time.time()
|
|
|
|
|
scanned = auto_deleted = human_deleted = kept = errors = 0
|
|
|
|
|
|
|
|
|
|
for entry in sorted(os.listdir(SPAM)):
|
|
|
|
|
path = os.path.join(SPAM, entry)
|
|
|
|
|
try:
|
|
|
|
|
st = os.stat(path)
|
|
|
|
|
except OSError:
|
|
|
|
|
errors += 1
|
|
|
|
|
continue
|
|
|
|
|
if not os.path.isfile(path):
|
|
|
|
|
continue
|
|
|
|
|
scanned += 1
|
|
|
|
|
age_days = (now - st.st_mtime) / 86400
|
|
|
|
|
automated = is_automated(path)
|
|
|
|
|
|
|
|
|
|
if automated and age_days > AUTOMATED_MAX_AGE_DAYS:
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(path)
|
|
|
|
|
auto_deleted += 1
|
|
|
|
|
except OSError:
|
|
|
|
|
errors += 1
|
|
|
|
|
continue
|
|
|
|
|
if (not automated) and age_days > HUMAN_MAX_AGE_DAYS:
|
|
|
|
|
try:
|
|
|
|
|
os.unlink(path)
|
|
|
|
|
human_deleted += 1
|
|
|
|
|
except OSError:
|
|
|
|
|
errors += 1
|
|
|
|
|
continue
|
|
|
|
|
kept += 1
|
|
|
|
|
|
|
|
|
|
# Metric lines (Pushgateway-compatible format). The parent
|
|
|
|
|
# kubectl wrapper logs them for now; Pushgateway integration
|
|
|
|
|
# is a follow-up.
|
|
|
|
|
print(f"spam_retention_scanned_total {scanned}")
|
|
|
|
|
print(f"spam_retention_auto_deleted_total {auto_deleted}")
|
|
|
|
|
print(f"spam_retention_human_deleted_total {human_deleted}")
|
|
|
|
|
print(f"spam_retention_kept_total {kept}")
|
|
|
|
|
print(f"spam_retention_errors_total {errors}")
|
|
|
|
|
|
|
|
|
|
sys.exit(1 if errors else 0)
|
|
|
|
|
PYEOF
|
|
|
|
|
|
|
|
|
|
# Refresh Dovecot index so IMAP sees the deletions immediately.
|
|
|
|
|
kubectl -n mailserver exec "$POD" -c docker-mailserver -- \
|
|
|
|
|
doveadm force-resync -u spam@viktorbarzin.me INBOX/spam || true
|
|
|
|
|
|
|
|
|
|
echo "Retention pass complete"
|
|
|
|
|
EOF
|
|
|
|
|
]
|
|
|
|
|
resources {
|
|
|
|
|
requests = {
|
|
|
|
|
cpu = "10m"
|
|
|
|
|
memory = "32Mi"
|
|
|
|
|
}
|
|
|
|
|
limits = {
|
|
|
|
|
memory = "128Mi"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
dns_config {
|
|
|
|
|
option {
|
|
|
|
|
name = "ndots"
|
|
|
|
|
value = "2"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
lifecycle {
|
|
|
|
|
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
|
|
|
|
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
|
|
|
|
}
|
|
|
|
|
}
|