diff --git a/kubernetes/infrastructure/mail/stalwart/certificate.yaml b/kubernetes/infrastructure/mail/stalwart/certificate.yaml new file mode 100644 index 0000000..669f104 --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/certificate.yaml @@ -0,0 +1,24 @@ +# TLS certificate for the Stalwart mail listeners (SMTP/IMAP/POP3 STARTTLS + implicit TLS) +# and the management WebUI hostname. +# +# Issued by cert-manager via the letsencrypt-production ClusterIssuer (Cloudflare DNS-01). +# The resulting Secret (mail-vhaudiquet-fr-tls) is consumed in two places: +# 1. The Stalwart HelmRelease mailTls block -> mounted into the pod + pushed to +# Stalwart by the tls-sync sidecar (JMAP) so SMTP/IMAP/POP3 present this cert. +# 2. The chart ingress (TLS termination is handled by Caddy in this cluster, so the +# ingress is TLS-less and only routes HTTP to the mgmt port). +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: mail-vhaudiquet-fr + namespace: stalwart +spec: + secretName: mail-vhaudiquet-fr-tls + issuerRef: + name: letsencrypt-production + kind: ClusterIssuer + commonName: mail.vhaudiquet.fr + dnsNames: + - mail.vhaudiquet.fr + duration: 2160h # 90 days + renewBefore: 360h # 15 days before expiry diff --git a/kubernetes/infrastructure/mail/stalwart/kustomization.yaml b/kubernetes/infrastructure/mail/stalwart/kustomization.yaml new file mode 100644 index 0000000..9648dce --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/kustomization.yaml @@ -0,0 +1,15 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: stalwart +resources: + - namespace.yaml + - repository.yaml + - certificate.yaml + - recovery-admin-secret.yaml + - release.yaml +secretGenerator: + - name: stalwart-values + files: + - values.yaml=values.yaml +configurations: + - kustomizeconfig.yaml diff --git a/kubernetes/infrastructure/mail/stalwart/kustomizeconfig.yaml b/kubernetes/infrastructure/mail/stalwart/kustomizeconfig.yaml new file mode 100644 index 0000000..3365acc --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/kustomizeconfig.yaml @@ -0,0 +1,6 @@ +nameReference: +- kind: Secret + version: v1 + fieldSpecs: + - path: spec/valuesFrom/name + kind: HelmRelease diff --git a/kubernetes/infrastructure/mail/stalwart/namespace.yaml b/kubernetes/infrastructure/mail/stalwart/namespace.yaml new file mode 100644 index 0000000..97039b5 --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: stalwart diff --git a/kubernetes/infrastructure/mail/stalwart/recovery-admin-secret.yaml b/kubernetes/infrastructure/mail/stalwart/recovery-admin-secret.yaml new file mode 100644 index 0000000..3aac012 --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/recovery-admin-secret.yaml @@ -0,0 +1,47 @@ +# Recovery / bootstrap administrator credential for Stalwart. +# +# This Secret is referenced by: +# - recoveryAdmin.existingSecret in the HelmRelease, which injects it into the +# Stalwart pod (and the tls-sync sidecar) so the cert-manager certificate can +# be pushed to Stalwart via JMAP. Keep it present for the lifetime of the +# deployment; the tls-sync sidecar needs it on every cert renewal. +# +# The username/password here are ONLY the recovery account. +apiVersion: v1 +kind: Secret +metadata: + name: stalwart-recovery-admin + namespace: stalwart + labels: + app.kubernetes.io/name: stalwart + app.kubernetes.io/component: recovery-admin +type: Opaque +stringData: + username: ENC[AES256_GCM,data:CYm0+Kw=,iv:UdgReqdb8GdoOfv1ZEW/7BAvY3vV88httj3W976eV0Y=,tag:PPrDQY/mo0RcJiz/cmxFSA==,type:str] + password: ENC[AES256_GCM,data:r0mPJ4qKIhAWDw==,iv:L5+OKTznQgDUgqttHwdW0X66vz+JPTqWdwYPGklCuj0=,tag:WNOj7ZjC6Lx76UlPsgSDhg==,type:str] +sops: + lastmodified: "2026-07-02T20:57:39Z" + mac: ENC[AES256_GCM,data:lIVOaMsFV9F1/yLvykA3LH5ukn9PIba6N8cE9RGdcFepM56tBYlY6fbkcKTtojyAkoYov6OCjgTsOkMtUzRdGES4Zti6zHB3cTPal8bJCf5cAfYnGFRcQaASIkVjNZkQSJ2pDRP4rZqioCHuj2/a6uJZ4hhtSpxrECYbpzD2Gk8=,iv:31fXHsWH1ZP00Od5DSxgYPuw87NqQNfWl4vFtoouIHk=,tag:u5cJn231VT2ZTmlcdpBz0g==,type:str] + pgp: + - created_at: "2026-07-02T20:57:39Z" + enc: |- + -----BEGIN PGP MESSAGE----- + + hQIMA7uy4qQr71wiAQ//fYBIypexVfQJn5BxVCRgZKRBcYw78XSoPjFYWFyk5F7I + UFRQKtdZOVVhgNHtFJOVyBvl1sAmmzAt3xq3L+Z5bvtrUfZ33tTp7ty+MmSmuutS + wquSh4NOHUYENGvo5pWsgVwQDj5bI2qUq4L92aedpB2XJycb7/Df7BWFKNMNgWdt + l+Hj9HEJWxmFjrz1MQJEL2r5WOx9/hK7uatbDTXVFPqyyW1aCM5XOkKErTuc3Avn + 8tNNlvP1aqhI/2dHfOSZ1l0/PHhdi3RBmGa3BfAZUXEseBEesIK/EaJZXMjQYhF2 + 5tAPQ1irqg8LLv9mhrUD2mWaUigcuZ1EeyfZFvNtY/noF8fyZny1jra0sDe4CmQP + 7uyP6CNRWPCikutUw45oxtw+/Us5Ld8xzJxhDEm0e+8FFiAK/SFSOx57JbjNzoCj + dqHU3UIzyp9VVZohqW7xdHOW64fgDI41BDKBsEiKraLnZUoXL2Lx0lnjxayJqVsD + 2pX9D5YXzmtwFvUU5ZpcCzUWFu9MVxSfp6HdKvocGdtpSz7J23PQvHtkv20GxJYd + J45RYGgekuJ2EXc9kPaCuSex/0BKtYybOk5ANRv2yGeUZlc6EoW8/GZ4sHXPY6um + tt1DdIsMNMjAU68EgRkhmQnFAkZixJVdDR3u4MNXFUr0WdVIqzjFBavyrkcK6HjS + XAGhFNWdk5DNZ1ChGrMKEnYCZEB6XNkofa7S2xAomEoxyuCq7X2vbfBfqDQ/Rtcf + kSx0TpIOSHxefSFMduPz90rDqdZgUW0D3GND0PD5XRFFiJxxdrRYPux9MDMd + =Ef8e + -----END PGP MESSAGE----- + fp: DC6910268E657FF70BA7EC289974494E76938DDC + encrypted_regex: ^(data|stringData|.*.key|.*.crt)$ + version: 3.10.2 diff --git a/kubernetes/infrastructure/mail/stalwart/release.yaml b/kubernetes/infrastructure/mail/stalwart/release.yaml new file mode 100644 index 0000000..d731113 --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/release.yaml @@ -0,0 +1,21 @@ +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: stalwart + namespace: stalwart + annotations: + homeprod.io/essential: "true" +spec: + interval: 1m + chart: + spec: + sourceRef: + kind: HelmRepository + name: stalwart-helm + namespace: stalwart + chart: stalwart + version: "0.7.5" + interval: 1m + valuesFrom: + - kind: Secret + name: stalwart-values diff --git a/kubernetes/infrastructure/mail/stalwart/repository.yaml b/kubernetes/infrastructure/mail/stalwart/repository.yaml new file mode 100644 index 0000000..6cce39a --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/repository.yaml @@ -0,0 +1,8 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: stalwart-helm + namespace: stalwart +spec: + interval: 1m + url: https://kgrubb.github.io/stalwart-helm-chart diff --git a/kubernetes/infrastructure/mail/stalwart/values.yaml b/kubernetes/infrastructure/mail/stalwart/values.yaml new file mode 100644 index 0000000..1839507 --- /dev/null +++ b/kubernetes/infrastructure/mail/stalwart/values.yaml @@ -0,0 +1,138 @@ +# Stalwart Mail Server - Helm values +# +# TLS for the mail listeners is sourced from cert-manager: the +# mail-vhaudiquet-fr-tls Secret (see certificate.yaml) is mounted into the pod +# and pushed to Stalwart by the chart's tls-sync sidecar over JMAP, which also +# calls ReloadTlsCertificates on every cert-manager renewal. +# Container image +image: + repository: stalwartlabs/stalwart + tag: v0.16.11 + pullPolicy: IfNotPresent +# Single-node RocksDB deployment. +replicaCount: 1 +role: "" +pushShard: "" +# Recovery / bootstrap administrator. +# The Secret (stalwart-recovery-admin) is required for the lifetime of the +# deployment because the tls-sync sidecar authenticates to Stalwart's JMAP API +# with these credentials on every cert-manager renewal. +recoveryAdmin: + enabled: true + username: ENC[AES256_GCM,data:N8MwV7g=,iv:ipHgAWJ6J1C7TrugLLeJrV/IVk9xfC8DvCMBfIDyJMk=,tag:OwbcThuQhS/aBLH0tyewnA==,type:str] + password: "" + existingSecret: ENC[AES256_GCM,data:tGvczUq1+EDNR8CAFgLTMd+/N3v7Vig=,iv:XYrfBXYGexxUXskRJDSYx02D+nIYUmNtSCScplBenZM=,tag:/MZ5H6yuJlD7D6TN8CwVuQ==,type:str] + usernameKey: username + passwordKey: password +# Recovery mode suspends mail services and exposes only the management listener. +# Ship production values with this OFF. +recoveryMode: + enabled: true + port: 8080 + logLevel: info +extraEnv: {} +extraSecretEnv: {} +# The bootstrap hook Job provisions a *fresh* domain/accounts/OIDC directory. +bootstrap: + enabled: false +# cert-manager TLS integration for the mail (SMTP/IMAP/POP3 STARTTLS + implicit +# TLS) listeners. The Secret below is produced by the Certificate in +# certificate.yaml (letsencrypt-production, Cloudflare DNS-01). +mailTls: + enabled: true + existingSecret: ENC[AES256_GCM,data:HkvfPP3KuHm5O81LrSA6Slq0iH88rg==,iv:nHnYsJ9uaRa+rKE20bVuxyruM7kepPX21Hhbl2uvuwo=,tag:YUxgSPhFyecT3ls2sEqEVA==,type:str] + certKey: tls.crt + privateKeyKey: tls.key + mountPath: /etc/stalwart/tls/ingress + hostname: mail.vhaudiquet.fr + domain: vhaudiquet.fr + # How often the sidecar re-checks the mounted cert for changes. + reloadIntervalSeconds: 300 +# config.json contents - ONLY the DataStore object. All other settings +# (listeners, storage backends, domains, accounts, certs) live in the database +# and are managed via JMAP/WebUI. +config: + '@type': RocksDb + path: /var/lib/stalwart +# Main Service: ClusterIP. Used by the Ingress for the management WebUI (mgmt) +# and for in-cluster access. Mail L4 ports are exposed separately via mailService. +service: + type: ClusterIP + ports: + smtp: 25 + smtps: 465 + submission: 587 + imap: 143 + imaps: 993 + pop3: 110 + pop3s: 995 + sieve: 4190 + http: 80 + https: 443 + mgmt: 8080 +# Dedicated LoadBalancer for SMTP/IMAP/POP3/Sieve (L4). HTTP/HTTPS/mgmt are NOT +# exposed here - the WebUI is reached via the Ingress (Traefik) below. +# The WAN router NAT for 25/465/587/993/995/4190 must be repointed to this IP. +mailService: + enabled: true + name: stalwart-mail + type: LoadBalancer + loadBalancerIP: 10.1.2.143 + annotations: {} +# WebUI ingress. TLS is terminated by Caddy at the edge (wildcard cert), so the +# path is: public -> Caddy (TLS) -> Traefik (HTTP) -> this Ingress -> mgmt:8080. +# No TLS block here. +ingress: + enabled: true + className: traefik + annotations: {} + hosts: + - host: mail.vhaudiquet.fr + paths: + - path: / + pathType: Prefix + portName: mgmt + tls: [] +# Persistent volume for the RocksDB data directory. +persistence: + enabled: true + accessMode: ReadWriteOnce + storageClass: "" + size: 50Gi +resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: "1" + memory: 1Gi +# Inherit the chart's secure defaults (UID/GID 2000, non-root, RuntimeDefault +# seccomp, drop ALL caps + add NET_BIND_SERVICE for privileged mail ports). +podSecurityContext: {} +containerSecurityContext: {} +sops: + lastmodified: "2026-07-02T20:57:39Z" + mac: ENC[AES256_GCM,data:8xnZW0A6hsysCDpfhfXnQ+NMc+VTSRwkMj4u2p2ERG8lj132dg5ErYG106jkQLXB3IkzxiAY9uyoquTpssVCOMemLRP9HgZmzqadI6kfpE2HZBHktgeY6MysF14RE4ep3naci0wVvpFiKv+sgr9QIbBmMnnN0pYwx5tASVFSgJc=,iv:KccW9I3LmxEmVfAnXjKz66FO1LgIO86zCFqp2/ulp4Q=,tag:xCNcgbHlttioSCvoZamwWw==,type:str] + pgp: + - created_at: "2026-07-02T20:57:39Z" + enc: |- + -----BEGIN PGP MESSAGE----- + + hQIMA7uy4qQr71wiAQ//fdCucpqT55/e76AABnVcBvwM3s34HK33n+6bvEwE2jTr + IPU6UXEZZzMCuGnSWSSy07hSX/0rhw6ETLIJmEPQkqOnxk6T3rcz9+8fLPapfLjI + sxFayU+Dw/s2rycErhrPTxgwLp3FBlnyEf/Zk22emUYiXnxZPoh4oXA8bjrMzSXj + tD5SVhvbHAT4WqTgG1cYgZ6XaDJ1eODqPXExJf6EekPlLAC/ohzgtPV44WTFj2YU + nVKouOGsc6ZUXkEwOm0HZcfJ5PuEOtCQsvD7BSKuTMbP5CUA9iuuBIhC8PmbCqxa + 630fP2rDyVyaRucrjUgO7/N0AfaymYysSoRlqGPR/fgt0iJ/jeOu8POQMvK51IQL + Gtvp663bJpooCh3ALfvQsGmkpsdQX6QttNYUFyO8yRNDhbYKfT5Ha27a/xUdOEFA + Z/imx8fjjH/Anj1n30cy5w/9sZY4lmmxmf/zQMhJMGsTy5efWxbg8DYVOan/bgP3 + 1ZMLYNwLxg0CPRPrJuZbI2Iz2d7GertI22acyAF7OCGljwg4lL/SYPIs02cKYuKO + x2M0fpTo7kWlb5CWwDkEfwU14ZmLw8buAvYeG7KblG11kZtgvbWB1LqoXU5TMv4y + gZA82VdTEUepOYavRMOaPScnyhCImdC/1xLH6gQCrk5qWZYVAy7GEvLRM19pWtHS + XgHxDIdiNeTVD5OuVsxpke02osFrfT8q4ybGJF8KH0ykebuegGtKZuCdncTc5Hnk + wnJjZaHHqWGei7pGHWeN6FPQ0jSj8nMosQEpxs1X1UZhUG03t141nCgduTiQZhE= + =63dW + -----END PGP MESSAGE----- + fp: DC6910268E657FF70BA7EC289974494E76938DDC + encrypted_regex: ^(password|value|ssh-key|api-key|user|username|privateKey|clientSecret|clientId|apiKey|extraArgs.*|.*Secret.*|extraEnvVars|.*SECRET.*|.*secret.*|key|.*Password|.*\.ya?ml)$ + version: 3.10.2 diff --git a/kubernetes/kustomization.yaml b/kubernetes/kustomization.yaml index 405387a..4ac5860 100644 --- a/kubernetes/kustomization.yaml +++ b/kubernetes/kustomization.yaml @@ -22,6 +22,7 @@ resources: - home/zigbee2mqtt # Infrastructure - infrastructure/authentik + - infrastructure/mail/stalwart # Personal - personal/linkwarden - personal/notesnook diff --git a/kubernetes/system/caddy/caddyfile.yaml b/kubernetes/system/caddy/caddyfile.yaml index c2ad662..65e988f 100644 --- a/kubernetes/system/caddy/caddyfile.yaml +++ b/kubernetes/system/caddy/caddyfile.yaml @@ -62,11 +62,10 @@ data: reverse_proxy traefik.traefik.svc.cluster.local.:80 } - # Docker VM services (via Traefik) + # Docker VM services (via Docker Traefik) @alexscript host alexscript.vhaudiquet.fr @clips host clips.vhaudiquet.fr @jellyfin host flix.vhaudiquet.fr - @mail host mail.vhaudiquet.fr handle @alexscript { reverse_proxy 10.1.2.212:80 @@ -77,8 +76,11 @@ data: handle @jellyfin { reverse_proxy 10.1.2.212:80 } + + # Stalwart WebUI (now in Kubernetes, via k8s Traefik) + @mail host mail.vhaudiquet.fr handle @mail { - reverse_proxy 10.1.2.212:80 + reverse_proxy traefik.traefik.svc.cluster.local.:80 } }