checkpoint: stalwart deploy, beam-design, migration scripts, config tweaks

Stalwart + Bulwark mail server deployment with OIDC, TLS cert, vault
secrets. Beam design service. Pingora config cleanup. SeaweedFS
replication fix. Kratos values tweak. Migration scripts for mbox/messages
/calendars from La Suite to Stalwart.
This commit is contained in:
2026-04-06 17:52:30 +01:00
parent 6b05616edd
commit 8662c79212
22 changed files with 1353 additions and 32 deletions

View File

@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: beam-design
namespace: devtools
labels:
app: beam-design
spec:
replicas: 1
selector:
matchLabels:
app: beam-design
template:
metadata:
labels:
app: beam-design
spec:
containers:
- name: beam-design
image: src.sunbeam.pt/studio/beam-ui:latest
ports:
- containerPort: 8080
protocol: TCP
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
securityContext:
runAsUser: 65534
runAsNonRoot: true
allowPrivilegeEscalation: false
---
apiVersion: v1
kind: Service
metadata:
name: beam-design
namespace: devtools
labels:
app: beam-design
spec:
selector:
app: beam-design
ports:
- port: 80
targetPort: 8080
protocol: TCP

View File

@@ -167,19 +167,7 @@ data:
prefix = "/.well-known/"
backend = "http://stalwart.stalwart.svc.cluster.local:8080"
# Stalwart OAuth2 endpoints (/authorize/code, /auth/token, /auth/device)
[[routes.paths]]
prefix = "/authorize"
backend = "http://stalwart.stalwart.svc.cluster.local:8080"
[[routes.paths]]
prefix = "/auth/"
backend = "http://stalwart.stalwart.svc.cluster.local:8080"
# Stalwart login page (used during OAuth flow)
[[routes.paths]]
prefix = "/login"
backend = "http://stalwart.stalwart.svc.cluster.local:8080"
[[routes]]
host_prefix = "messages"
@@ -401,20 +389,8 @@ data:
host_prefix = "build"
backend = "buildkitd.build.svc.cluster.local:1234"
# SMTP inbound: port 25 → Stalwart for mail delivery.
[smtp]
listen = "0.0.0.0:25"
backend = "stalwart.stalwart.svc.cluster.local:25"
# SMTP submission: port 587 → Stalwart for authenticated sending.
[smtp-submission]
listen = "0.0.0.0:587"
backend = "stalwart.stalwart.svc.cluster.local:587"
# IMAPS: port 993 → Stalwart for desktop/mobile email clients.
[imaps]
listen = "0.0.0.0:993"
backend = "stalwart.stalwart.svc.cluster.local:993"
# SMTP/IMAP ports are exposed directly on the Stalwart pod via hostPort
# (see overlays/production/kustomization.yaml), not through Pingora.
# SSH TCP passthrough: port 22 → Gitea SSH pod (headless service → pod:2222).
[ssh]

View File

@@ -102,7 +102,7 @@ kratos:
courier:
smtp:
connection_uri: "smtp://postfix.lasuite.svc.cluster.local:25/?skip_ssl_verify=true"
connection_uri: "smtp://stalwart.stalwart.svc.cluster.local:25/?skip_ssl_verify=true"
from_address: no-reply@DOMAIN_SUFFIX
from_name: Sunbeam Studios

View File

@@ -0,0 +1,70 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: bulwark
namespace: stalwart
spec:
replicas: 1
selector:
matchLabels:
app: bulwark
template:
metadata:
labels:
app: bulwark
spec:
containers:
- name: bulwark
image: src.DOMAIN_SUFFIX/studio/bulwark:latest
ports:
- name: http
containerPort: 3000
env:
- name: JMAP_SERVER_URL
value: https://mail.DOMAIN_SUFFIX
- name: OAUTH_ENABLED
value: "true"
- name: OAUTH_ONLY
value: "true"
- name: LOG_LEVEL
value: "debug"
- name: OAUTH_SCOPES
value: "openid email profile offline_access"
- name: COOKIE_SECURE
value: "false"
- name: OAUTH_CLIENT_ID
valueFrom:
secretKeyRef:
name: oidc-bulwark
key: CLIENT_ID
- name: OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oidc-bulwark
key: CLIENT_SECRET
- name: OAUTH_ISSUER_URL
value: https://auth.DOMAIN_SUFFIX
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: stalwart-app-secrets
key: admin-password
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: 128Mi
cpu: 50m
limits:
memory: 512Mi
cpu: 500m

View File

@@ -0,0 +1,12 @@
apiVersion: v1
kind: Service
metadata:
name: bulwark
namespace: stalwart
spec:
selector:
app: bulwark
ports:
- name: http
port: 80
targetPort: 3000

View File

@@ -0,0 +1,13 @@
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: stalwart-tls
namespace: stalwart
spec:
secretName: stalwart-tls
issuerRef:
name: letsencrypt-production
kind: ClusterIssuer
dnsNames:
- "mail.DOMAIN_SUFFIX"
- "cal.DOMAIN_SUFFIX"

View File

@@ -0,0 +1,6 @@
apiVersion: v1
kind: Namespace
metadata:
name: stalwart
labels:
linkerd.io/inject: enabled

View File

@@ -0,0 +1,30 @@
# Bulwark webmail OIDC client — authenticates directly with Hydra.
# Hydra Maester creates K8s Secret "oidc-bulwark" with CLIENT_ID/CLIENT_SECRET.
# DOMAIN_SUFFIX is replaced by sed at deploy time.
apiVersion: hydra.ory.sh/v1alpha1
kind: OAuth2Client
metadata:
name: bulwark
namespace: stalwart
spec:
clientName: Webmail
grantTypes:
- authorization_code
- refresh_token
responseTypes:
- code
scope: openid email profile offline_access
redirectUris:
- https://mail.DOMAIN_SUFFIX/en/auth/callback
- https://mail.DOMAIN_SUFFIX/auth/callback
- https://mail.DOMAIN_SUFFIX/api/auth/callback
postLogoutRedirectUris:
- https://mail.DOMAIN_SUFFIX
tokenEndpointAuthMethod: client_secret_post
secretName: oidc-bulwark
skipConsent: true
tokenLifespans:
authorization_code_grant_access_token_lifespan: 24h
authorization_code_grant_refresh_token_lifespan: 720h
refresh_token_grant_access_token_lifespan: 24h
refresh_token_grant_refresh_token_lifespan: 720h

View File

@@ -0,0 +1,24 @@
# Stalwart OIDC client — registered with Hydra for SSO login.
# Hydra Maester creates K8s Secret "oidc-stalwart" in the stalwart namespace
# with CLIENT_ID and CLIENT_SECRET keys.
# DOMAIN_SUFFIX is replaced by sed at deploy time.
apiVersion: hydra.ory.sh/v1alpha1
kind: OAuth2Client
metadata:
name: stalwart
namespace: stalwart
spec:
clientName: Mail
grantTypes:
- authorization_code
- refresh_token
responseTypes:
- code
scope: openid email profile
redirectUris:
- https://mail.DOMAIN_SUFFIX/authorize/code
postLogoutRedirectUris:
- https://mail.DOMAIN_SUFFIX
tokenEndpointAuthMethod: client_secret_post
secretName: oidc-stalwart
skipConsent: true

View File

@@ -0,0 +1,148 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: stalwart-config
namespace: stalwart
data:
# DOMAIN_SUFFIX is replaced by sed at deploy time.
# Secrets (%{env:VAR}%) are injected via environment variables in the Deployment.
#
# Only LOCAL keys belong in this file (store.*, storage.*, server.*, directory.*,
# tracer.*, cluster.*, certificate.*, authentication.fallback-admin.*).
# Everything else (OIDC, DKIM, spam, etc.) is configured via the Stalwart
# web admin UI and stored in the database.
config.toml: |
# Force http.url to be read from this file (not the database).
config.local-keys.0000 = "store.*"
config.local-keys.0001 = "directory.*"
config.local-keys.0002 = "tracer.*"
config.local-keys.0003 = "server.*"
config.local-keys.0004 = "!server.blocked-ip.*"
config.local-keys.0005 = "!server.allowed-ip.*"
config.local-keys.0006 = "authentication.fallback-admin.*"
config.local-keys.0007 = "cluster.*"
config.local-keys.0008 = "config.local-keys.*"
config.local-keys.0009 = "storage.*"
config.local-keys.0010 = "certificate.*"
config.local-keys.0011 = "http.url"
config.local-keys.0012 = "http.use-x-forwarded"
# Expression-quoted public URL (inner single quotes required).
http.url = "'https://mail.DOMAIN_SUFFIX'"
http.use-x-forwarded = true
[server]
hostname = "mail.DOMAIN_SUFFIX"
max-connections = 1024
[server.listener."smtp"]
bind = ["0.0.0.0:25"]
protocol = "smtp"
[server.listener."submission"]
bind = ["0.0.0.0:587"]
protocol = "smtp"
tls.implicit = false
[server.listener."smtps"]
bind = ["0.0.0.0:465"]
protocol = "smtp"
tls.implicit = true
[server.listener."imap"]
bind = ["0.0.0.0:143"]
protocol = "imap"
tls.implicit = false
[server.listener."imaps"]
bind = ["0.0.0.0:993"]
protocol = "imap"
tls.implicit = true
[server.listener."jmap"]
bind = ["0.0.0.0:8080"]
protocol = "http"
[server.listener."managesieve"]
bind = ["0.0.0.0:4190"]
protocol = "managesieve"
[certificate."default"]
cert = "%{file:/etc/stalwart-tls/tls.crt}%"
private-key = "%{file:/etc/stalwart-tls/tls.key}%"
# ── Storage backends ─────────────────────────────────────────────────────
[store."postgresql"]
type = "postgresql"
host = "postgres-rw.data.svc.cluster.local"
port = 5432
database = "stalwart_db"
user = "stalwart"
password = "%{env:DB_PASSWORD}%"
timeout = "15s"
[store."postgresql".pool]
max-connections = 20
[store."s3"]
type = "s3"
bucket = "sunbeam-stalwart"
endpoint = "http://seaweedfs-filer.storage.svc.cluster.local:8333"
region = "us-east-1"
access-key = "%{env:S3_ACCESS_KEY}%"
secret-key = "%{env:S3_SECRET_KEY}%"
key-prefix = "v1/"
timeout = "60s"
[store."opensearch"]
type = "elasticsearch"
url = "http://opensearch.data.svc.cluster.local:9200"
index = "stalwart"
[store."redis"]
type = "redis"
urls = ["redis://valkey.data.svc.cluster.local:6379/7"]
# ── Storage role assignments ─────────────────────────────────────────────
[storage]
data = "postgresql"
blob = "postgresql"
fts = "opensearch"
lookup = "redis"
directory = "hydra"
# ── Directories (user stores) ──────────────────────────────────────────
# Internal directory for locally-managed accounts (admin, service accounts).
[directory."internal"]
type = "internal"
store = "postgresql"
# OIDC directory — validates Hydra-issued bearer tokens via userinfo.
# When a JMAP client presents a Bearer token, Stalwart calls Hydra's
# userinfo endpoint to map it to a user identity.
[directory."hydra"]
type = "oidc"
timeout = "15s"
endpoint.url = "http://hydra-public.ory.svc.cluster.local:4444/userinfo"
endpoint.method = "userinfo"
fields.email = "email"
fields.username = "email"
fields.full-name = "name"
# ── Authentication ───────────────────────────────────────────────────────
[authentication.fallback-admin]
user = "admin"
secret = "%{env:ADMIN_PASSWORD}%"
# ── Logging ──────────────────────────────────────────────────────────────
[tracer."stdout"]
type = "stdout"
level = "info"
ansi = false
enable = true

View File

@@ -0,0 +1,103 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: stalwart
namespace: stalwart
spec:
replicas: 1
selector:
matchLabels:
app: stalwart
template:
metadata:
labels:
app: stalwart
spec:
containers:
- name: stalwart
image: stalwartlabs/stalwart:v0.15.5
ports:
- name: smtp
containerPort: 25
- name: submission
containerPort: 587
- name: smtps
containerPort: 465
- name: imap
containerPort: 143
- name: imaps
containerPort: 993
- name: managesieve
containerPort: 4190
- name: http
containerPort: 8080
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: stalwart-db-credentials
key: password
- name: S3_ACCESS_KEY
valueFrom:
secretKeyRef:
name: seaweedfs-s3-credentials
key: S3_ACCESS_KEY
- name: S3_SECRET_KEY
valueFrom:
secretKeyRef:
name: seaweedfs-s3-credentials
key: S3_SECRET_KEY
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: stalwart-app-secrets
key: admin-password
- name: DKIM_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: stalwart-app-secrets
key: dkim-private-key
- name: OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: oidc-stalwart
key: CLIENT_ID
- name: OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oidc-stalwart
key: CLIENT_SECRET
volumeMounts:
- name: config
mountPath: /opt/stalwart/etc/config.toml
subPath: config.toml
readOnly: true
- name: tls
mountPath: /etc/stalwart-tls
readOnly: true
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
cpu: "1"
volumes:
- name: config
configMap:
name: stalwart-config
- name: tls
secret:
secretName: stalwart-tls

View File

@@ -0,0 +1,30 @@
apiVersion: v1
kind: Service
metadata:
name: stalwart
namespace: stalwart
spec:
selector:
app: stalwart
ports:
- name: smtp
port: 25
targetPort: 25
- name: submission
port: 587
targetPort: 587
- name: smtps
port: 465
targetPort: 465
- name: imap
port: 143
targetPort: 143
- name: imaps
port: 993
targetPort: 993
- name: managesieve
port: 4190
targetPort: 4190
- name: http
port: 8080
targetPort: 8080

View File

@@ -0,0 +1,85 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: vso-auth
namespace: stalwart
spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: vso
serviceAccount: default
---
# Stalwart DB credentials from OpenBao database secrets engine (static role, 24h rotation).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: stalwart-db-credentials
namespace: stalwart
spec:
vaultAuthRef: vso-auth
mount: database
path: static-creds/stalwart
allowStaticCreds: true
refreshAfter: 5m
rolloutRestartTargets:
- kind: Deployment
name: stalwart
destination:
name: stalwart-db-credentials
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
password:
text: "{{ index .Secrets \"password\" }}"
---
# Stalwart application secrets (admin password, DKIM key) from OpenBao KV.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: stalwart-app-secrets
namespace: stalwart
spec:
vaultAuthRef: vso-auth
mount: secret
type: kv-v2
path: stalwart
refreshAfter: 30s
destination:
name: stalwart-app-secrets
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
admin-password:
text: "{{ index .Secrets \"admin-password\" }}"
dkim-private-key:
text: "{{ index .Secrets \"dkim-private-key\" }}"
---
# SeaweedFS S3 credentials (shared — same secret as lasuite namespace).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: seaweedfs-s3-credentials
namespace: stalwart
spec:
vaultAuthRef: vso-auth
mount: secret
type: kv-v2
path: seaweedfs
refreshAfter: 30s
destination:
name: seaweedfs-s3-credentials
create: true
overwrite: true
transformation:
excludeRaw: true
templates:
S3_ACCESS_KEY:
text: "{{ index .Secrets \"access-key\" }}"
S3_SECRET_KEY:
text: "{{ index .Secrets \"secret-key\" }}"

View File

@@ -25,7 +25,7 @@ spec:
mountPath: /data/filer
containers:
- name: filer
image: chrislusf/seaweedfs:latest
image: chrislusf/seaweedfs:4.18
args:
- filer
- -port=8888

View File

@@ -16,7 +16,7 @@ spec:
spec:
containers:
- name: master
image: chrislusf/seaweedfs:latest
image: chrislusf/seaweedfs:4.18
args:
- master
- -port=9333