Compare commits

..

7 Commits

Author SHA1 Message Date
unkinben 5d6d6a9441 fix(stalwart): pin stalwart-external LoadBalancer to 198.18.199.2 2026-05-24 19:54:29 +10:00
unkinben 572b6bfd9f fix(stalwart): add purelb dmz service-group annotation to LoadBalancer 2026-05-24 19:52:03 +10:00
unkinben b465763302 feat(stalwart): use Valkey for in-memory store, improve health probes
- Replace PostgreSQL in-memory store with Valkey (Redis-compatible) for
  better performance on rate limiting, distributed locks, and OAuth codes
- Add single-replica Valkey deployment with no persistence (data is transient)
- Switch liveness/readiness probes to HTTP GET /healthz/live and
  /healthz/ready on port 8080 per official Kubernetes probe documentation
- Update webadmin resource URL to use artifactapi proxy instead of direct
  GitHub download
2026-05-24 12:56:32 +10:00
unkinben 0d89a69c18 feat(stalwart): deploy Stalwart mail server with CNPG and S3
- stalwart namespace with Deployment + HPA (2-6 replicas)
- CNPG PostgreSQL cluster (3 instances, 20Gi cephrbd-fast-delete) with PgBouncer pooler
- S3/Ceph-RGW for blob storage (stalwart-maildata bucket, lz4 compressed)
- Secrets from Vault: postgres-credentials, s3-credentials, stalwart-admin
- TLS cert via cert-manager (vault-issuer) for mail.main.unkin.net
- SMTP relay on port 25 (internal ClusterIP, trusted pod CIDRs)
- Submission on port 587, IMAP 143/993, HTTPS 443 via LoadBalancer
- HTTP port 8080 for Traefik reverse proxy (web admin at mail.k8s.syd1.au.unkin.net)
- Outbound mail routed through postfix.mailgateway.svc.cluster.local:25
- Spam filtering offloaded to postfix/rspamd (disabled internally)
2026-05-24 12:44:46 +10:00
unkinben cbc2c1cb9f fix(gateways): add explicit group: "" to all certificateRefs entries (#153)
The Gateway API admission server defaults certificateRefs[].group to ""
when it is omitted. ArgoCD diffed the desired state (no group field) against
the live state (group: "") and flagged every gateway as out of sync.

Fix: explicitly set group: "" in all certificateRefs entries so the
rendered manifest matches the API server's canonical form exactly.

Affected: artifactapi, cattle-system, consul, litellm, paperclip,
puppet (puppetboard + puppetdb), vault.

Reviewed-on: #153
2026-05-23 23:47:24 +10:00
unkinben c6f9893804 fix(argocd): add vault and consul to platform project destinations (#152)
Vault and consul namespaces were missing from the platform AppProject
allowed destinations, causing ArgoCD sync failures with:
  destination server 'https://kubernetes.default.svc' and namespace
  'vault' do not match any of the allowed destinations in project 'platform'

Reviewed-on: #152
2026-05-23 23:27:24 +10:00
unkinben e43fb742ad feat(artifactapi): add kanidm to ghcr docker immutable patterns (#151)
Prerequisite for kanidm deployment (PR benvin/kanidm).

Reviewed-on: #151
2026-05-23 23:09:38 +10:00
37 changed files with 821 additions and 446 deletions
+2 -1
View File
@@ -31,6 +31,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: artifactapi-tls
mode: Terminate
@@ -6,6 +6,7 @@ remotes:
immutable_patterns:
- "^cloudnative-pg/cloudnative-pg"
- "^emberstack/helm-charts"
- "^kanidm/"
- "^openvoxproject/"
- "^stakater/reloader"
- "^voxpupuli/puppetboard"
+2 -1
View File
@@ -31,6 +31,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: rancher-tls
mode: Terminate
+4 -2
View File
@@ -35,7 +35,8 @@ spec:
tls:
mode: Terminate
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: consul-tls
- name: consul-svc
port: 443
@@ -47,5 +48,6 @@ spec:
tls:
mode: Terminate
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: consul-tls
-26
View File
@@ -1,26 +0,0 @@
---
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
@@ -1,40 +0,0 @@
---
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
@@ -1,30 +0,0 @@
---
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
@@ -1,27 +0,0 @@
---
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
@@ -1,16 +0,0 @@
---
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
-15
View File
@@ -1,15 +0,0 @@
---
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
@@ -1,37 +0,0 @@
---
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
@@ -1,43 +0,0 @@
---
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
@@ -1,9 +0,0 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: kanidm
namespace: kanidm
labels:
app.kubernetes.io/name: kanidm
app.kubernetes.io/instance: kanidm
-161
View File
@@ -1,161 +0,0 @@
---
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
@@ -1,21 +0,0 @@
---
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
+2 -1
View File
@@ -31,6 +31,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: litellm-tls
mode: Terminate
+2 -1
View File
@@ -31,6 +31,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: paperclip-tls
mode: Terminate
+2 -1
View File
@@ -35,6 +35,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: puppetboard-tls
mode: Terminate
+2 -1
View File
@@ -28,6 +28,7 @@ spec:
protocol: HTTPS
tls:
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: puppetdb-tls
mode: Terminate
+36
View File
@@ -0,0 +1,36 @@
---
# 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
@@ -0,0 +1,91 @@
---
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
@@ -0,0 +1,33 @@
---
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
@@ -0,0 +1,38 @@
---
# 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
@@ -0,0 +1,16 @@
---
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
@@ -0,0 +1,24 @@
---
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
@@ -2,4 +2,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: kanidm
name: stalwart
+228
View File
@@ -0,0 +1,228 @@
[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
@@ -0,0 +1,57 @@
---
# 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
@@ -0,0 +1,109 @@
---
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
@@ -0,0 +1,38 @@
---
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
@@ -0,0 +1,58 @@
---
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
@@ -0,0 +1,18 @@
---
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
@@ -0,0 +1,51 @@
---
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
+4 -2
View File
@@ -35,7 +35,8 @@ spec:
tls:
mode: Terminate
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: vault-tls
- name: vault-direct
port: 8200
@@ -46,5 +47,6 @@ spec:
tls:
mode: Terminate
certificateRefs:
- kind: Secret
- group: ""
kind: Secret
name: vault-tls
@@ -3,4 +3,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../../base/kanidm
- ../../../base/stalwart
+1 -7
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,12 +44,6 @@ 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,8 +27,6 @@ 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'