infra/stacks/mailserver/modules/mailserver/main.tf

1530 lines
54 KiB
Terraform
Raw Normal View History

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]
)
])
}
resource "kubernetes_namespace" "mailserver" {
metadata {
name = "mailserver"
labels = {
tier = var.tier
}
# 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"]]
}
}
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"
ENABLE_RSPAMD_REDIS = "0"
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"
DEFAULT_RELAY_HOST = "[smtp-relay.brevo.com]:587"
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
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
# 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
mail_max_userip_connections = 50
[mailserver] Add Dovecot auth_failure_delay 5s [ci skip] ## Context Dovecot's `dovecot.cf` block previously set only `mail_max_userip_connections = 50`. No equivalent of the SMTP rate limit existed for IMAP auth — brute-force against IMAP/POP auth was throttled only by CrowdSec at the LB level. Adding an in-process auth delay is cheap defense in depth. Addresses code-9mi. ## This change Adds `auth_failure_delay = 5s` to the dovecot.cf ConfigMap key. Each failed auth attempt pauses 5s before responding; a sequential 1000-entry dictionary attack stretches from <1s to ~85min, bought out CrowdSec's ban window. ## What is NOT in this change - `login_processes_count` tuning (workload doesn't warrant it yet) - Equivalent SMTP AUTH delay (CrowdSec already covers, and SMTP AUTH is rate-limited via `smtpd_client_connection_rate_limit`) ## Test Plan ### Automated ``` $ kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- \ doveconf -n | grep -E 'auth_failure|mail_max_userip' auth_failure_delay = 5 secs mail_max_userip_connections = 50 $ kubectl rollout status deployment/mailserver -n mailserver deployment "mailserver" successfully rolled out ``` ### Manual Verification 1. `openssl s_client -connect mail.viktorbarzin.me:993` 2. `a1 LOGIN bogus@viktorbarzin.me wrongpass` — expect ~5s delay before `NO [AUTHENTICATIONFAILED]` 3. Fire 5 failed attempts rapidly: total ≥25s ## Reproduce locally 1. `kubectl exec -n mailserver -c docker-mailserver deployment/mailserver -- doveconf -n | grep auth_failure` 2. Expected: `auth_failure_delay = 5 secs` Closes: code-9mi Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
}
}
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
[DEFAULT]
#logtarget = /var/log/fail2ban.log
logtarget = SYSOUT
EOF
}
[mailserver] Document postfix-accounts.cf hash-drift invariant [ci skip] ## Context The `postfix-accounts.cf` ConfigMap renders `bcrypt(pass, 6)` for each user in `var.mailserver_accounts`. bcrypt generates a fresh salt on every evaluation → the ConfigMap `data` hash line differs every plan run. `ignore_changes = [data["postfix-accounts.cf"]]` was the pragmatic workaround, but the side-effect wasn't documented: a Vault rotation of a mailserver password would be MASKED by ignore_changes — TF would never push the new hash and the pod would keep accepting the old password until manual taint/replace. Addresses bd code-7ns. ## This change Inline comment on the lifecycle block spelling out: - Why ignore_changes exists (non-deterministic bcrypt) - What the invariant costs (masks automatic rotation) - Why it's acceptable TODAY (no automatic rotation for mailserver_accounts — verified in Vault; manual password change is a manual TF run anyway) - Two concrete alternatives if rotation is ever added: (a) deterministic bcrypt with stable per-user salt (b) render from an ESO-synced K8s Secret No code change, no apply needed — this is a comment-only commit. The decision (live-with + document) is one of the three options in the plan. ## What is NOT in this change - Deterministic hashing (not needed until automatic rotation exists) - ESO-driven Secret (same reason) - Removal of ignore_changes (would cause the original drift flap) ## Test Plan ### Automated ``` $ cd stacks/mailserver && /home/wizard/code/infra/scripts/tg plan # no diff expected on this comment-only change; other drift remains # but is pre-existing and out of scope. ``` ### Manual Verification Read the new comment block at `stacks/mailserver/modules/mailserver/ main.tf` around the postfix-accounts-cf lifecycle — comprehensible without session context. Closes: code-7ns Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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).
lifecycle {
[infra] Document intended ignore_changes drift-workarounds [ci skip] ## Context The infra repo has 31 `ignore_changes` blocks. Phase 1 of the state-drift consolidation audit classified 21 as legitimate (immutable fields, cloud-computed values) and 10 as intentional workarounds for known drift sources. The remaining 10 were indistinguishable from accidental/forgotten drift suppression without reading the surrounding context. This commit adds a uniform `# DRIFT_WORKAROUND: <reason>, reviewed 2026-04-18` marker above the 8 intended-workaround blocks (6 CI image-tag decoupling + 2 non-deterministic secret hashes) so they are easy to distinguish from accidental drift suppression during future audits. ## What is NOT in this change - Functional behavior — `ignore_changes` lists are byte-identical. - The Kyverno `dns_config` ignore paths (covered by Wave 3 shared module). - Workarounds being removed — the CI decoupling is intentional by user decision. ## Files touched CI image-tag decoupling (6): - stacks/k8s-portal/modules/k8s-portal/main.tf (also has dns_config for Kyverno) - stacks/novelapp/main.tf - stacks/claude-memory/main.tf - stacks/plotting-book/main.tf - stacks/trading-bot/main.tf (api deployment) - stacks/trading-bot/main.tf (workers deployment — 6 containers) Non-deterministic secret hashes (2): - stacks/owntracks/main.tf (htpasswd bcrypt) - stacks/mailserver/modules/mailserver/main.tf (postfix-accounts.cf) ## Test Plan ### Automated ``` $ rg DRIFT_WORKAROUND stacks/ | wc -l 8 $ terraform fmt -recursive stacks/k8s-portal stacks/novelapp stacks/claude-memory \ stacks/plotting-book stacks/trading-bot stacks/owntracks stacks/mailserver (no output — already formatted) $ git diff --stat stacks/claude-memory/main.tf | 1 + stacks/k8s-portal/modules/k8s-portal/main.tf | 1 + stacks/mailserver/modules/mailserver/main.tf | 3 ++- stacks/novelapp/main.tf | 1 + stacks/owntracks/main.tf | 1 + stacks/plotting-book/main.tf | 1 + stacks/trading-bot/main.tf | 2 ++ 7 files changed, 9 insertions(+), 1 deletion(-) ``` ### Manual Verification No apply required — HCL comments only, zero effect on plan output. ## Reproduce locally 1. `cd infra && git pull` 2. `rg "DRIFT_WORKAROUND.*reviewed 2026-04-18" stacks/ | wc -l` → expect 8 3. `terraform fmt -check -recursive stacks/` → expect clean exit Closes: code-yrg Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.
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
}
}
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" {
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"
namespace = kubernetes_namespace.mailserver.metadata[0].name
annotations = {
"resize.topolvm.io/threshold" = "80%"
"resize.topolvm.io/increase" = "100%"
"resize.topolvm.io/storage_limit" = "5Gi"
}
}
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"
resources {
requests = {
storage = "2Gi"
}
}
}
}
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 = {
[mailserver] Pin dovecot_exporter to SHA + add Diun [ci skip] ## Context `viktorbarzin/dovecot_exporter:latest` was consumed with `IfNotPresent` pull, which means whichever node landed the pod kept whatever digest was cached from an earlier pull. A SHA-level pin is the reproducibility baseline this repo uses for every other home-built image (`headscale`, `excalidraw`, `linkwarden`). ## This change - Pins `dovecot-exporter` container image to `viktorbarzin/dovecot_exporter@sha256:1114224c...` — the digest the pod is actually running today (captured from live `imageID`). - Enables Diun tag watching on the mailserver Deployment (`diun.enable=true`, `diun.include_tags=^latest$`) so new `:latest` digests trigger a notification rather than silently landing on the next `IfNotPresent` miss. Deviation from task spec (code-cno): the task asked for an 8-char SHA *tag*, but Docker Hub only publishes `:latest` for this image — a SHA tag doesn't exist. Used the digest-pin pattern already established at `stacks/headscale/modules/headscale/main.tf:204` instead; Diun watches the `:latest` tag for drift, which is the equivalent notification. ## What is NOT in this change - Volume-mount ordering drift on `kubernetes_deployment.mailserver` (pre-existing; tolerated by Waves 1+2). - Splitting the metrics port into its own Service (code-izl). ## Test Plan ### Automated ``` $ kubectl get pod -n mailserver -l app=mailserver \ -o jsonpath='{.items[0].spec.containers[*].image}' docker.io/mailserver/docker-mailserver:15.0.0 \ viktorbarzin/dovecot_exporter@sha256:1114224c9bf0261ca8e9949a6b42d3c5a2c923d34ca4593f6b62f034daf14fc5 $ kubectl get deployment -n mailserver mailserver \ -o jsonpath='{.spec.template.metadata.annotations}' {"diun.enable":"true","diun.include_tags":"^latest$"} $ kubectl rollout status deployment/mailserver -n mailserver deployment "mailserver" successfully rolled out ``` ### Manual Verification 1. Push a new `:latest` digest to the exporter image (or wait for one). 2. Check Diun notifier output: a tag event for `^latest$` should fire. 3. `kubectl describe deployment/mailserver -n mailserver` shows the digest pin unchanged until someone rebumps it. ## Reproduce locally 1. `kubectl -n mailserver get pod -l app=mailserver -o yaml | \ grep -A1 dovecot_exporter` 2. Expected: `image: viktorbarzin/dovecot_exporter@sha256:1114224c...`. Closes: code-cno Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:26:31 +00:00
"diun.enable" = "true"
"diun.include_tags" = "^latest$"
}
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 {}
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
}
volume_mount {
name = "config"
mount_path = "/tmp/docker-mailserver/dovecot.cf"
sub_path = "dovecot.cf"
read_only = true
}
# 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
}
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"
}
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
}
}
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
}
# 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"
}
}
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.
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"
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.
[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
}
[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
}
}
# =============================================================================
# E2E Email Roundtrip Monitor
# Sends test email via Brevo API, verifies delivery via IMAP, pushes metrics
# =============================================================================
[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"
}
},
]
}
}
}
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
schedule = "*/20 * * * *"
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 '
import requests, imaplib, email, time, os, uuid, sys, ssl, json
BREVO_API_KEY = os.environ["BREVO_API_KEY"]
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}"
start = time.time()
success = 0
duration = 0
try:
# Step 1: Send via Brevo Transactional Email API to smoke-test@ (hits catch-all -> spam@)
resp = requests.post(
"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}"},
"to": [{"email": f"smoke-test@{DOMAIN}"}],
"subject": subject,
"textContent": f"E2E email monitoring probe {marker}. Auto-generated, will be deleted.",
},
timeout=30,
)
resp.raise_for_status()
print(f"Sent test email via Brevo: {resp.status_code} marker={marker}")
[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)
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):
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)
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")
# Delete ALL e2e probe emails (current + any leftovers from previous runs)
if found:
try:
_, 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)")
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,
)
# 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
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,
)
[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)
'
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"
}
}
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]
}
}
[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]
}
}