Compare commits

..

5 Commits

Author SHA1 Message Date
unkinben 0fd269ed9a fix(kanidm): prevent ArgoCD from overwriting repl-cert ConfigMap data
Remove the data keys from kanidm-repl-certs in git so ArgoCD never takes
SSA ownership of them. Add ignoreDifferences for /data on that ConfigMap
in the ApplicationSet template so ArgoCD doesn't flag sidecar-patched
cert values as out-of-sync.
2026-05-24 00:07:02 +10:00
unkinben 1112309a60 feat(kanidm): automate replication cert exchange via native sidecar
Add a native sidecar (bitnami/kubectl, restartPolicy: Always) that runs
kanidmd renew-replication-certificate on each pod and patches the result
into the kanidm-repl-certs ConfigMap (certs are public keys, not secrets).
The config-init init container reads peer certs from the ConfigMap at
startup, building the replication stanza automatically — no manual cert
exchange required after first deploy.

Add RBAC (Role + RoleBinding) granting the kanidm service account
pods/exec and configmap patch permissions scoped to the kanidm namespace.
2026-05-24 00:02:40 +10:00
unkinben b87844b4cc feat(kanidm): 3 replicas, PDB maxUnavailable=1, host anti-affinity
- Increase replicas from 2 to 3
- Add kanidm-2 headless DNS SAN to TLS certificate
- Add PodDisruptionBudget (maxUnavailable: 1) to maintain quorum during
  node drains
- Add requiredDuringSchedulingIgnoredDuringExecution pod anti-affinity
  on kubernetes.io/hostname to spread replicas across distinct hosts
- Update replication peers comment to include kanidm-2 cert exchange step
2026-05-23 23:49:30 +10:00
unkinben 2ddd2173e3 fix(argocd): add consul and vault to platform project destinations
Same fix as benvin/fix-platform-destinations applied to this branch.
2026-05-23 23:16:52 +10:00
unkinben 1e88102a46 feat(kanidm): deploy Kanidm 1.10.3 with 2-replica replication
## Summary

- Deploys Kanidm 1.10.3 (ghcr.io/kanidm/server:1.10.3) as a 2-replica
  StatefulSet with built-in replication enabled
- Domain: auth.unkin.net (primary WebAuthn origin); au.auth.unkin.net
  is an additional hostname for this au-syd1 instance
- TLS: cert-manager Certificate (vault-issuer) covering auth.unkin.net,
  au.auth.unkin.net, kanidm.k8s.syd1.au.unkin.net, and internal
  headless pod DNS names — Kanidm terminates TLS itself (passthrough)
- Gateway: TLS passthrough on port 443 via TLSRoute; HTTP on port 80
  redirects to HTTPS; external-dns creates kanidm.k8s.syd1.au.unkin.net
  (not used in server.toml; canonical origin is auth.unkin.net only)
- Replication: init container generates per-pod server.toml with the
  correct repl:// origin (kanidm-{N}.kanidm-headless.kanidm.svc);
  populate kanidm-repl-peers ConfigMap post-deployment after running
  `kanidmd show-replication-certificate` on each pod
- Storage: 10Gi cephrbd-fast-delete PVC per pod via volumeClaimTemplates
- Security: runAsUser/runAsGroup 1000, runAsNonRoot, no privilege
  escalation, allowPrivilegeEscalation=false
- ArgoCD: platform ApplicationSet and Project updated for kanidm namespace

## Requires

- PR benvin/kanidm-artifactapi (add ^kanidm/ to ghcr immutable patterns)
  to be merged first so artifactapi can cache ghcr.io/kanidm/server

## Post-deployment steps

1. Wait for both pods to reach Running state
2. Exchange replication certificates between pods:
   kubectl exec -n kanidm kanidm-0 -- kanidmd show-replication-certificate
   kubectl exec -n kanidm kanidm-1 -- kanidmd show-replication-certificate
3. Edit kanidm-repl-peers ConfigMap with both nodes' certs
   (see template in configmap.yaml comments)
4. kubectl rollout restart statefulset/kanidm -n kanidm

## Test plan

- [x] Sandbox tested in sandbox-kanidm: all 11 resources server dry-run OK
- [ ] After merge: ArgoCD syncs kanidm namespace
- [ ] Verify auth.unkin.net and au.auth.unkin.net reachable via Gateway
- [ ] Verify kanidm.k8s.syd1.au.unkin.net DNS record created by external-dns
- [ ] Complete replication cert exchange and verify replication active
2026-05-23 23:12:52 +10:00
37 changed files with 446 additions and 821 deletions
+1 -2
View File
@@ -31,7 +31,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: artifactapi-tls
mode: Terminate
@@ -6,7 +6,6 @@ remotes:
immutable_patterns:
- "^cloudnative-pg/cloudnative-pg"
- "^emberstack/helm-charts"
- "^kanidm/"
- "^openvoxproject/"
- "^stakater/reloader"
- "^voxpupuli/puppetboard"
+1 -2
View File
@@ -31,7 +31,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: rancher-tls
mode: Terminate
+2 -4
View File
@@ -35,8 +35,7 @@ spec:
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: consul-tls
- name: consul-svc
port: 443
@@ -48,6 +47,5 @@ spec:
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: consul-tls
+26
View File
@@ -0,0 +1,26 @@
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: kanidm-tls
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
secretName: kanidm-tls
issuerRef:
kind: ClusterIssuer
name: vault-issuer
commonName: auth.unkin.net
dnsNames:
- auth.unkin.net
- au.auth.unkin.net
- kanidm.k8s.syd1.au.unkin.net
- kanidm.kanidm.svc.cluster.local
- kanidm-0.kanidm-headless.kanidm.svc.cluster.local
- kanidm-1.kanidm-headless.kanidm.svc.cluster.local
- kanidm-2.kanidm-headless.kanidm.svc.cluster.local
privateKey:
algorithm: RSA
size: 4096
+40
View File
@@ -0,0 +1,40 @@
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kanidm-config
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
data:
server.toml: |
version = "2"
domain = "auth.unkin.net"
origin = "https://auth.unkin.net"
bindaddress = "[::]:8443"
db_path = "/data/kanidm.db"
db_arc_size = 2048
tls_chain = "/data/tls/tls.crt"
tls_key = "/data/tls/tls.key"
log_level = "info"
[online_backup]
path = "/data/backups/"
schedule = "0 22 * * *"
versions = 7
[replication]
origin = "__REPL_ORIGIN__"
bindaddress = "[::]:8444"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: kanidm-repl-certs
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
data: {}
+30
View File
@@ -0,0 +1,30 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
traefik.io/instance: internal
annotations:
external-dns.alpha.kubernetes.io/hostname: kanidm.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
spec:
gatewayClassName: traefik-internal
listeners:
- name: http
port: 80
protocol: HTTP
allowedRoutes:
namespaces:
from: Same
- name: https-passthrough
port: 443
protocol: TLS
tls:
mode: Passthrough
allowedRoutes:
namespaces:
from: Same
+27
View File
@@ -0,0 +1,27 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: kanidm-http-redirect
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
hostnames:
- kanidm.k8s.syd1.au.unkin.net
- auth.unkin.net
- au.auth.unkin.net
parentRefs:
- name: kanidm
sectionName: http
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
matches:
- path:
type: PathPrefix
value: /
+16
View File
@@ -0,0 +1,16 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- serviceaccount.yaml
- rbac.yaml
- certificate.yaml
- configmap.yaml
- service.yaml
- statefulset.yaml
- poddisruptionbudget.yaml
- gateway.yaml
- httproute.yaml
- tlsroute.yaml
@@ -2,4 +2,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: stalwart
name: kanidm
+15
View File
@@ -0,0 +1,15 @@
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
+37
View File
@@ -0,0 +1,37 @@
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: kanidm-repl
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list"]
- apiGroups: [""]
resources: ["pods/exec"]
verbs: ["create"]
- apiGroups: [""]
resources: ["configmaps"]
resourceNames: ["kanidm-repl-certs"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: kanidm-repl
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
subjects:
- kind: ServiceAccount
name: kanidm
namespace: kanidm
roleRef:
kind: Role
name: kanidm-repl
apiGroup: rbac.authorization.k8s.io
+43
View File
@@ -0,0 +1,43 @@
---
apiVersion: v1
kind: Service
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
type: ClusterIP
ports:
- name: https
port: 8443
targetPort: https
protocol: TCP
selector:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
---
apiVersion: v1
kind: Service
metadata:
name: kanidm-headless
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
type: ClusterIP
clusterIP: None
ports:
- name: https
port: 8443
targetPort: https
protocol: TCP
- name: replication
port: 8444
targetPort: replication
protocol: TCP
selector:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
+9
View File
@@ -0,0 +1,9 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
+161
View File
@@ -0,0 +1,161 @@
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
serviceName: kanidm-headless
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
template:
metadata:
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
serviceAccountName: kanidm
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
topologyKey: kubernetes.io/hostname
securityContext:
runAsUser: 1000
runAsGroup: 1000
runAsNonRoot: true
fsGroup: 1000
initContainers:
- name: config-init
image: ghcr.io/kanidm/server:1.10.3
command: ["/bin/sh", "-c"]
args:
- |
set -e
REPL_ORIGIN="repl://${POD_NAME}.kanidm-headless.kanidm.svc.cluster.local:8444"
sed "s|__REPL_ORIGIN__|${REPL_ORIGIN}|g" /config-template/server.toml > /config/server.toml
for peer in kanidm-0 kanidm-1 kanidm-2; do
if [ "${peer}" = "${POD_NAME}" ]; then
continue
fi
cert_file="/repl-certs/${peer}"
if [ -s "${cert_file}" ]; then
fqdn="${peer}.kanidm-headless.kanidm.svc.cluster.local"
printf '\n[replication."repl://%s:8444"]\ntype = "mutual-pull"\npartner_cert = "%s"\n' \
"${fqdn}" "$(cat ${cert_file})" >> /config/server.toml
fi
done
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: config-template
mountPath: /config-template
- name: config
mountPath: /config
- name: repl-certs
mountPath: /repl-certs
readOnly: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
- name: repl-cert-publisher
image: bitnami/kubectl:1.33
restartPolicy: Always
command: ["/bin/sh", "-c"]
args:
- |
until kubectl exec "${POD_NAME}" -c kanidm -- /sbin/kanidmd renew-replication-certificate 2>/dev/null | grep -q '^# certificate:'; do
sleep 30
done
while true; do
cert=$(kubectl exec "${POD_NAME}" -c kanidm -- /sbin/kanidmd renew-replication-certificate 2>/dev/null \
| grep '^# certificate:' | sed 's/^# certificate: "\(.*\)"$/\1/')
if [ -n "${cert}" ]; then
kubectl patch configmap kanidm-repl-certs \
--type=merge \
-p "{\"data\":{\"${POD_NAME}\":\"${cert}\"}}"
fi
sleep 3600
done
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
containers:
- name: kanidm
image: ghcr.io/kanidm/server:1.10.3
command: ["/sbin/kanidmd"]
args: ["server", "-c", "/config/server.toml"]
ports:
- name: https
containerPort: 8443
protocol: TCP
- name: replication
containerPort: 8444
protocol: TCP
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /config
readOnly: true
- name: tls
mountPath: /data/tls
readOnly: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: 500m
readinessProbe:
tcpSocket:
port: 8443
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8443
initialDelaySeconds: 30
periodSeconds: 30
volumes:
- name: config-template
configMap:
name: kanidm-config
- name: config
emptyDir: {}
- name: repl-certs
configMap:
name: kanidm-repl-certs
- name: tls
secret:
secretName: kanidm-tls
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: cephrbd-fast-delete
resources:
requests:
storage: 10Gi
+21
View File
@@ -0,0 +1,21 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: TLSRoute
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
spec:
hostnames:
- kanidm.k8s.syd1.au.unkin.net
- auth.unkin.net
- au.auth.unkin.net
parentRefs:
- name: kanidm
sectionName: https-passthrough
rules:
- backendRefs:
- name: kanidm
port: 8443
+1 -2
View File
@@ -31,7 +31,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: litellm-tls
mode: Terminate
+1 -2
View File
@@ -31,7 +31,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: paperclip-tls
mode: Terminate
+1 -2
View File
@@ -35,7 +35,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: puppetboard-tls
mode: Terminate
+1 -2
View File
@@ -28,7 +28,6 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: puppetdb-tls
mode: Terminate
-36
View File
@@ -1,36 +0,0 @@
---
# TLS cert for stalwart's own HTTPS/IMAPS/STARTTLS listeners (mail.main.unkin.net)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: stalwart-tls
namespace: stalwart
spec:
secretName: stalwart-tls
issuerRef:
name: vault-issuer
kind: ClusterIssuer
commonName: mail.main.unkin.net
dnsNames:
- mail.main.unkin.net
privateKey:
size: 4096
algorithm: RSA
---
# TLS cert for Traefik Gateway (internal k8s admin URL)
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: stalwart-gateway-tls
namespace: stalwart
spec:
secretName: stalwart-gateway-tls
issuerRef:
name: vault-issuer
kind: ClusterIssuer
commonName: mail.k8s.syd1.au.unkin.net
dnsNames:
- mail.k8s.syd1.au.unkin.net
privateKey:
size: 4096
algorithm: RSA
-91
View File
@@ -1,91 +0,0 @@
---
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: stalwart-postgres
namespace: stalwart
spec:
affinity:
podAntiAffinityType: preferred
bootstrap:
initdb:
database: stalwart
encoding: UTF8
localeCType: C
localeCollate: C
owner: stalwart
secret:
name: postgres-credentials
enablePDB: true
enableSuperuserAccess: false
failoverDelay: 0
imageName: ghcr.io/cloudnative-pg/postgresql:17-minimal-trixie
instances: 3
logLevel: info
maxSyncReplicas: 0
minSyncReplicas: 0
monitoring:
customQueriesConfigMap:
- key: queries
name: cnpg-default-monitoring
disableDefaultQueries: false
enablePodMonitor: false
postgresql:
parameters:
archive_mode: "on"
archive_timeout: 5min
dynamic_shared_memory_type: posix
effective_cache_size: 256MB
full_page_writes: "on"
log_destination: csvlog
log_directory: /controller/log
log_filename: postgres
log_rotation_age: "0"
log_rotation_size: "0"
log_truncate_on_rotation: "false"
logging_collector: "on"
max_connections: "200"
max_parallel_workers: "16"
max_replication_slots: "16"
max_worker_processes: "16"
shared_buffers: 128MB
shared_memory_type: mmap
ssl_max_protocol_version: TLSv1.3
ssl_min_protocol_version: TLSv1.3
wal_keep_size: 256MB
wal_level: logical
wal_log_hints: "on"
wal_receiver_timeout: 5s
wal_sender_timeout: 5s
syncReplicaElectionConstraint:
enabled: false
primaryUpdateMethod: restart
primaryUpdateStrategy: unsupervised
probes:
liveness:
isolationCheck:
connectionTimeout: 1000
enabled: true
requestTimeout: 1000
replicationSlots:
highAvailability:
enabled: true
slotPrefix: _cnpg_
synchronizeReplicas:
enabled: true
updateInterval: 30
resources:
limits:
cpu: "2"
memory: 2Gi
requests:
cpu: 500m
memory: 1Gi
smartShutdownTimeout: 180
startDelay: 3600
stopDelay: 1800
storage:
resizeInUseVolumes: true
size: 20Gi
storageClass: cephrbd-fast-delete
switchoverDelay: 3600
-33
View File
@@ -1,33 +0,0 @@
---
apiVersion: postgresql.cnpg.io/v1
kind: Pooler
metadata:
name: stalwart-postgres-pooler
namespace: stalwart
spec:
cluster:
name: stalwart-postgres
instances: 2
pgbouncer:
parameters:
default_pool_size: "50"
max_client_conn: "200"
paused: false
poolMode: session
template:
metadata:
labels:
app: pooler
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchExpressions:
- key: app
operator: In
values:
- pooler
topologyKey: kubernetes.io/hostname
containers: []
type: rw
-38
View File
@@ -1,38 +0,0 @@
---
# Traefik gateway for stalwart web admin UI (separate from the LoadBalancer HTTPS port)
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
labels:
traefik.io/instance: internal
annotations:
cert-manager.io/cluster-issuer: vault-issuer
cert-manager.io/common-name: mail.k8s.syd1.au.unkin.net
cert-manager.io/private-key-size: "4096"
external-dns.alpha.kubernetes.io/hostname: mail.k8s.syd1.au.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.200.4
name: stalwart
namespace: stalwart
spec:
gatewayClassName: traefik-internal
listeners:
- allowedRoutes:
namespaces:
from: Same
hostname: mail.k8s.syd1.au.unkin.net
name: http
port: 80
protocol: HTTP
- allowedRoutes:
namespaces:
from: Same
hostname: mail.k8s.syd1.au.unkin.net
name: https
port: 443
protocol: HTTPS
tls:
certificateRefs:
- group: ""
kind: Secret
name: stalwart-gateway-tls
mode: Terminate
-16
View File
@@ -1,16 +0,0 @@
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: stalwart
namespace: stalwart
spec:
parentRefs:
- name: stalwart
namespace: stalwart
hostnames:
- mail.k8s.syd1.au.unkin.net
rules:
- backendRefs:
- name: stalwart
port: 8080
-24
View File
@@ -1,24 +0,0 @@
---
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- certificate.yaml
- cnpg_cluster.yaml
- cnpg_pooler.yaml
- gateway.yaml
- httproute.yaml
- namespace.yaml
- services.yaml
- stalwart-deployment.yaml
- stalwart-hpa.yaml
- valkey.yaml
- vaultauth.yaml
- vaultstaticsecret.yaml
configMapGenerator:
- name: stalwart-config
files:
- config.toml=resources/config.toml
options:
disableNameSuffixHash: true
-228
View File
@@ -1,228 +0,0 @@
[server]
hostname = "mail.main.unkin.net"
greeting = "Stalwart ESMTP"
# SMTP relay listener (receives from postfix for local delivery)
[server.listener."smtp-relay"]
bind = ["0.0.0.0:25"]
protocol = "smtp"
greeting = "Stalwart SMTP Relay"
[server.listener."smtp-relay".proxy]
trusted-networks = ["127.0.0.0/8", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
# SMTP submission listener (user-facing, TLS required)
[server.listener."submission"]
bind = ["0.0.0.0:587"]
protocol = "smtp"
greeting = "Stalwart SMTP Submission"
tls.require = true
[server.listener."submission".proxy]
trusted-networks = ["127.0.0.0/8", "::1"]
# IMAP listener
[server.listener."imap"]
bind = ["0.0.0.0:143"]
protocol = "imap"
[server.listener."imap".proxy]
trusted-networks = ["127.0.0.0/8", "::1"]
# IMAPS listener (implicit TLS)
[server.listener."imaps"]
bind = ["0.0.0.0:993"]
protocol = "imap"
tls.implicit = true
[server.listener."imaps".proxy]
trusted-networks = ["127.0.0.0/8", "::1"]
# HTTPS (web admin + JMAP) for external LoadBalancer
[server.listener."https"]
bind = ["0.0.0.0:443"]
protocol = "http"
tls.implicit = true
[server.listener."https".proxy]
trusted-networks = ["127.0.0.0/8", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
# Plain HTTP for Traefik reverse proxy (TLS terminated at Traefik)
[server.listener."http-internal"]
bind = ["0.0.0.0:8080"]
protocol = "http"
[server.listener."http-internal".proxy]
trusted-networks = ["127.0.0.0/8", "::1", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
[server.tls]
enable = true
implicit = false
certificate = "default"
[server.http]
use-x-forwarded = true
permissive-cors = false
[webadmin]
path = "/var/lib/stalwart/webadmin"
auto-update = true
resource = "https://artifactapi.k8s.syd1.au.unkin.net/generic/github/stalwartlabs/webadmin/releases/latest/download/webadmin.zip"
# PostgreSQL store (via CNPG pooler)
[store."postgresql"]
type = "postgresql"
host = "stalwart-postgres-pooler.stalwart.svc.cluster.local"
port = 5432
database = "stalwart"
user = "stalwart"
password = "%{env:POSTGRES_PASSWORD}%"
timeout = "15s"
[store."postgresql".tls]
enable = true
allow-invalid-certs = false
[store."postgresql".pool]
max-connections = 10
[store."postgresql".purge]
frequency = "0 3 *"
# S3/Ceph-RGW store for blobs
[store."s3"]
type = "s3"
bucket = "stalwart-maildata"
region = "syd1"
access-key = "%{env:S3_ACCESS_KEY}%"
secret-key = "%{env:S3_SECRET_KEY}%"
endpoint = "https://radosgw.service.consul"
timeout = "30s"
key-prefix = "stalwart/"
compression = "lz4"
[store."s3".purge]
frequency = "30 5 *"
# Valkey in-memory store (rate limiting, locks, OAuth codes, greylisting)
[store."valkey"]
type = "redis"
urls = ["redis://stalwart-valkey.stalwart.svc.cluster.local:6379"]
# Storage assignment
[storage]
data = "postgresql"
fts = "postgresql"
blob = "s3"
lookup = "postgresql"
directory = "internal"
in-memory = "valkey"
# Directory configuration
[directory.internal]
type = "internal"
store = "postgresql"
# Fallback admin (password hash from Vault)
[authentication.fallback-admin]
user = "admin"
secret = "%{env:ADMIN_PASSWORD_HASH}%"
[authentication]
[authentication.directory]
directories = ["internal"]
[authorization]
directory = "internal"
# JMAP configuration
[jmap]
directory = "internal"
[jmap.protocol]
request-max-size = 10485760
get.max-objects = 500
query.max-results = 5000
changes.max-results = 5000
upload.max-size = 50000000
upload.ttl = "1h"
# IMAP configuration
[imap]
directory = "internal"
[imap.protocol]
max-requests = 64
# Inbound rate limiting
[[queue.limiter.inbound]]
key = ["remote_ip"]
rate = "500/1s"
enable = true
# Outbound routing through postfix relay
[queue]
path = "/var/lib/stalwart/queue"
[queue.schedule]
retry = ["2s", "5s", "1m", "5m", "15m", "30m", "1h", "2h"]
notify = ["1d", "3d"]
expire = "5d"
[session.extensions]
future-release = "7d"
# Route local domain mail locally, everything else through postfix relay
[queue.strategy]
route = [
{ if = "is_local_domain('', rcpt_domain)", then = "'local'" },
{ else = "'relay'" }
]
[queue.route."local"]
type = "local"
[queue.route."relay"]
type = "relay"
address = "postfix.mailgateway.svc.cluster.local"
port = 25
protocol = "smtp"
[queue.route."relay".tls]
implicit = false
allow-invalid-certs = false
# Session configuration
[session.ehlo]
reject-non-fqdn = false
[session.rcpt]
type = "internal"
store = "postgresql"
max-recipients = 25
[session.data]
max-messages = 10
max-message-size = 52428800
# Spam filtering offloaded to postfix/rspamd — disable internal spam filter
[spam-filter]
enable = false
# TLS certificate (cert-manager issues to /etc/stalwart/tls/)
[certificate."default"]
cert = "%{file:/etc/stalwart/tls/tls.crt}%"
private-key = "%{file:/etc/stalwart/tls/tls.key}%"
default = true
# Logging
[tracer]
type = "log"
level = "info"
ansi = false
multiline = true
# Metrics
[metrics]
prometheus.enable = true
prometheus.port = 9090
-57
View File
@@ -1,57 +0,0 @@
---
# Internal service - postfix connects here to deliver inbound mail
apiVersion: v1
kind: Service
metadata:
name: stalwart
namespace: stalwart
spec:
selector:
app: stalwart
ports:
- name: smtp-relay
port: 25
targetPort: 25
protocol: TCP
- name: http-internal
port: 8080
targetPort: 8080
protocol: TCP
- name: metrics
port: 9090
targetPort: 9090
protocol: TCP
---
# External LoadBalancer for user-facing mail services
apiVersion: v1
kind: Service
metadata:
name: stalwart-external
namespace: stalwart
annotations:
external-dns.alpha.kubernetes.io/hostname: mail.main.unkin.net
external-dns.alpha.kubernetes.io/target: 198.18.199.2
purelb.io/service-group: dmz
purelb.io/addresses: 198.18.199.2
spec:
type: LoadBalancer
externalTrafficPolicy: Local
selector:
app: stalwart
ports:
- name: submission
port: 587
targetPort: 587
protocol: TCP
- name: imap
port: 143
targetPort: 143
protocol: TCP
- name: imaps
port: 993
targetPort: 993
protocol: TCP
- name: https
port: 443
targetPort: 443
protocol: TCP
-109
View File
@@ -1,109 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: stalwart
namespace: stalwart
spec:
selector:
matchLabels:
app: stalwart
template:
metadata:
annotations:
reloader.stakater.com/auto: "true"
labels:
app: stalwart
spec:
securityContext:
runAsUser: 2000
runAsGroup: 2000
fsGroup: 2000
containers:
- name: stalwart
image: ghcr.io/stalwartlabs/stalwart:v0.16.6
ports:
- containerPort: 25
name: smtp-relay
protocol: TCP
- containerPort: 587
name: submission
protocol: TCP
- containerPort: 143
name: imap
protocol: TCP
- containerPort: 993
name: imaps
protocol: TCP
- containerPort: 443
name: https
protocol: TCP
- containerPort: 8080
name: http-internal
protocol: TCP
- containerPort: 9090
name: metrics
protocol: TCP
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgres-credentials
key: password
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: s3-credentials
key: access_key
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: s3-credentials
key: secret_key
- name: ADMIN_PASSWORD_HASH
valueFrom:
secretKeyRef:
name: stalwart-admin
key: password_hash
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 2Gi
volumeMounts:
- name: config
mountPath: /etc/stalwart/config.toml
subPath: config.toml
readOnly: true
- name: tls
mountPath: /etc/stalwart/tls
readOnly: true
- name: data
mountPath: /var/lib/stalwart
volumes:
- name: config
configMap:
name: stalwart-config
- name: tls
secret:
secretName: stalwart-tls
- name: data
emptyDir: {}
-38
View File
@@ -1,38 +0,0 @@
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: stalwart-hpa
namespace: stalwart
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: stalwart
minReplicas: 2
maxReplicas: 6
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
behavior:
scaleUp:
stabilizationWindowSeconds: 0
selectPolicy: Max
policies:
- type: Percent
value: 100
periodSeconds: 60
- type: Pods
value: 2
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
selectPolicy: Min
policies:
- type: Percent
value: 30
periodSeconds: 60
-58
View File
@@ -1,58 +0,0 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: stalwart-valkey
namespace: stalwart
spec:
replicas: 1
selector:
matchLabels:
app: stalwart-valkey
template:
metadata:
labels:
app: stalwart-valkey
spec:
containers:
- name: valkey
image: valkey/valkey:8-alpine
args:
- "--save"
- ""
- "--appendonly"
- "no"
ports:
- containerPort: 6379
name: valkey
protocol: TCP
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
livenessProbe:
tcpSocket:
port: 6379
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: stalwart-valkey
namespace: stalwart
spec:
selector:
app: stalwart-valkey
ports:
- port: 6379
targetPort: 6379
name: valkey
-18
View File
@@ -1,18 +0,0 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: stalwart
spec:
allowedNamespaces:
- stalwart
kubernetes:
audiences:
- vault
role: default
serviceAccount: default
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default
-51
View File
@@ -1,51 +0,0 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: postgres-credentials
namespace: stalwart
spec:
destination:
create: true
name: postgres-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/stalwart/default/postgres-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: s3-credentials
namespace: stalwart
spec:
destination:
create: true
name: s3-credentials
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/stalwart/default/s3-credentials
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: stalwart-admin
namespace: stalwart
spec:
destination:
create: true
name: stalwart-admin
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/stalwart/default/stalwart-admin
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+2 -4
View File
@@ -35,8 +35,7 @@ spec:
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: vault-tls
- name: vault-direct
port: 8200
@@ -47,6 +46,5 @@ spec:
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
- kind: Secret
name: vault-tls
@@ -3,4 +3,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/stalwart
- ../../../base/kanidm
+7 -1
View File
@@ -20,13 +20,13 @@ spec:
- path: apps/overlays/*/externaldns
- path: apps/overlays/*/inteldeviceplugins-system
- path: apps/overlays/*/jfrog
- path: apps/overlays/*/kanidm
- path: apps/overlays/*/node-feature-discovery
- path: apps/overlays/*/puppet
- path: apps/overlays/*/purelb
- path: apps/overlays/*/reflector-system
- path: apps/overlays/*/reloader-system
- path: apps/overlays/*/reposync
- path: apps/overlays/*/stalwart
- path: apps/overlays/*/traefik-system
- path: apps/overlays/*/vm-system
- path: apps/overlays/*/vault
@@ -44,6 +44,12 @@ spec:
destination:
server: https://kubernetes.default.svc
namespace: '{{path[3]}}' # Use directory name as namespace
ignoreDifferences:
- group: ""
kind: ConfigMap
name: kanidm-repl-certs
jsonPointers:
- /data
syncPolicy:
automated:
prune: true
+2
View File
@@ -27,6 +27,8 @@ spec:
server: https://kubernetes.default.svc
- namespace: 'jfrog'
server: https://kubernetes.default.svc
- namespace: 'kanidm'
server: https://kubernetes.default.svc
- namespace: 'node-feature-discovery'
server: https://kubernetes.default.svc
- namespace: 'purelb'