Add rspamd, ClamAV and sentinel Valkey to the mail namespace
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/kubeconform Pipeline was successful

This commit is contained in:
2026-10-12 21:47:39 +11:00
parent 1d9b7ec924
commit f627902dd6
20 changed files with 652 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
---
# clamd + freshclam (the image's init runs both); signatures come from database.clamav.net.
apiVersion: apps/v1
kind: Deployment
metadata:
name: clamav
namespace: mail
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: clamav
template:
metadata:
labels:
app.kubernetes.io/name: clamav
spec:
automountServiceAccountToken: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: clamav
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: clamav
image: docker.io/clamav/clamav:1.5.4
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3310
name: clamd
protocol: TCP
startupProbe:
tcpSocket:
port: clamd
periodSeconds: 10
failureThreshold: 60
readinessProbe:
tcpSocket:
port: clamd
periodSeconds: 10
livenessProbe:
tcpSocket:
port: clamd
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 1536Mi
limits:
cpu: "2"
memory: 3Gi
volumeMounts:
- name: db
mountPath: /var/lib/clamav
volumes:
- name: db
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: clamav
namespace: mail
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: clamav
---
apiVersion: v1
kind: Service
metadata:
name: clamav
namespace: mail
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: clamav
ports:
- name: clamd
port: 3310
targetPort: clamd
+31
View File
@@ -3,7 +3,38 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- clamav.yaml
- cnpg_backup.yaml
- cnpg_cluster.yaml
- namespace.yaml
- rspamd-deployment.yaml
- rspamd-sa-rules.yaml
- rspamd-valkey.yaml
- rspamd-vault.yaml
- stalwart-blobs-bucket.yaml
- vaultauth.yaml
configMapGenerator:
- name: rspamd-config
namespace: mail
options:
disableNameSuffixHash: true
files:
- rspamd/actions.conf
- rspamd/antivirus.conf
- rspamd/dkim_signing.conf
- rspamd/gpt.conf
- rspamd/greylist.conf
- rspamd/history_redis.conf
- rspamd/options.inc
- rspamd/redis.conf
- rspamd/spamassassin.conf
- rspamd/worker-controller.inc
- rspamd/worker-normal.inc
- rspamd/worker-proxy.inc
- name: rspamd-valkey-scripts
namespace: mail
options:
disableNameSuffixHash: true
files:
- rspamd-valkey/start.sh
+158
View File
@@ -0,0 +1,158 @@
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: rspamd
namespace: mail
annotations:
reloader.stakater.com/auto: "true"
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: rspamd
template:
metadata:
labels:
app.kubernetes.io/name: rspamd
spec:
serviceAccountName: rspamd
automountServiceAccountToken: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: rspamd
securityContext:
runAsNonRoot: true
runAsUser: 11333
runAsGroup: 11333
fsGroup: 11333
seccompProfile:
type: RuntimeDefault
containers:
- name: rspamd
image: docker.io/rspamd/rspamd:4.2.2
imagePullPolicy: IfNotPresent
env:
- name: RSPAMD_LOCAL_ADDR
value: "*"
- name: RSPAMD_LOG_TYPE
value: console
- name: RSPAMD_LITELLM_API_KEY
valueFrom:
secretKeyRef:
name: rspamd-litellm
key: key
- name: RSPAMD_CONTROLLER_PASSWORD
valueFrom:
secretKeyRef:
name: rspamd-controller
key: password
ports:
- containerPort: 11332
name: milter
protocol: TCP
- containerPort: 11334
name: controller
protocol: TCP
readinessProbe:
tcpSocket:
port: milter
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /ping
port: controller
initialDelaySeconds: 60
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "2"
memory: 1536Mi
volumeMounts:
- name: config
mountPath: /etc/rspamd/local.d
readOnly: true
- name: sa-rules
mountPath: /etc/rspamd/sa-rules
readOnly: true
- name: dkim
mountPath: /etc/rspamd/dkim
readOnly: true
- name: data
mountPath: /var/lib/rspamd
volumes:
- name: config
configMap:
name: rspamd-config
- name: sa-rules
configMap:
name: rspamd-sa-rules
- name: dkim
secret:
secretName: dkim-unkin-net
defaultMode: 0440
items:
- key: private_key
path: unkin.net.mail.key
- name: data
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: rspamd
namespace: mail
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: rspamd
---
# Milter for the smarthost (rspamd.mail.svc:11332); controller/web UI is in-cluster only.
apiVersion: v1
kind: Service
metadata:
name: rspamd
namespace: mail
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: rspamd
ports:
- name: milter
port: 11332
targetPort: milter
- name: controller
port: 11334
targetPort: controller
---
# Milter for the DMZ gateways over WireGuard.
apiVersion: v1
kind: Service
metadata:
name: rspamd-milter
namespace: mail
annotations:
purelb.io/addresses: 198.18.200.17
purelb.io/service-group: common
spec:
type: LoadBalancer
selector:
app.kubernetes.io/name: rspamd
ports:
- name: milter
port: 11332
targetPort: milter
+8
View File
@@ -0,0 +1,8 @@
---
# SpamAssassin rules loaded by the rspamd spamassassin module (*.cf).
apiVersion: v1
kind: ConfigMap
metadata:
name: rspamd-sa-rules
namespace: mail
data: {}
+177
View File
@@ -0,0 +1,177 @@
---
# Sentinel-managed Valkey for rspamd (bayes, history, ratelimit, gpt cache).
# rspamd has no Redis Cluster support, so the valkey-operator (cluster mode)
# cannot be used. Pod 0 seeds a fresh set; afterwards start.sh follows the
# master the sentinels report. Hostnames carry a trailing dot so lookups of a
# deleted pod fail fast instead of walking the search list (sentinel TILT).
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: rspamd-valkey
namespace: mail
annotations:
configmap.reloader.stakater.com/reload: "rspamd-valkey-scripts"
spec:
serviceName: rspamd-valkey
replicas: 3
selector:
matchLabels:
app.kubernetes.io/name: rspamd-valkey
template:
metadata:
labels:
app.kubernetes.io/name: rspamd-valkey
spec:
automountServiceAccountToken: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app.kubernetes.io/name: rspamd-valkey
securityContext:
runAsNonRoot: true
runAsUser: 999
runAsGroup: 999
fsGroup: 999
seccompProfile:
type: RuntimeDefault
containers:
- name: valkey
image: docker.io/valkey/valkey:9.0.0
imagePullPolicy: IfNotPresent
command: ["sh", "/scripts/start.sh", "valkey"]
env:
- name: DOMAIN_SUFFIX
value: .rspamd-valkey.mail.svc.cluster.local.
ports:
- containerPort: 6379
name: valkey
protocol: TCP
readinessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "ping"]
initialDelaySeconds: 30
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
volumeMounts:
- name: data
mountPath: /data
- name: scripts
mountPath: /scripts
readOnly: true
- name: sentinel
image: docker.io/valkey/valkey:9.0.0
imagePullPolicy: IfNotPresent
command: ["sh", "/scripts/start.sh", "sentinel"]
env:
- name: DOMAIN_SUFFIX
value: .rspamd-valkey.mail.svc.cluster.local.
ports:
- containerPort: 26379
name: sentinel
protocol: TCP
readinessProbe:
exec:
command: ["valkey-cli", "-p", "26379", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["valkey-cli", "-p", "26379", "ping"]
initialDelaySeconds: 30
periodSeconds: 30
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- ALL
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
volumeMounts:
- name: sentinel
mountPath: /sentinel
- name: scripts
mountPath: /scripts
readOnly: true
volumes:
- name: scripts
configMap:
name: rspamd-valkey-scripts
- name: sentinel
emptyDir: {}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: cephrbd-fast-delete
resources:
requests:
storage: 5Gi
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: rspamd-valkey
namespace: mail
spec:
maxUnavailable: 1
selector:
matchLabels:
app.kubernetes.io/name: rspamd-valkey
---
# Stable per-pod DNS, published before readiness so replicas can find the seed.
apiVersion: v1
kind: Service
metadata:
name: rspamd-valkey
namespace: mail
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app.kubernetes.io/name: rspamd-valkey
ports:
- name: valkey
port: 6379
targetPort: valkey
- name: sentinel
port: 26379
targetPort: sentinel
---
apiVersion: v1
kind: Service
metadata:
name: rspamd-valkey-sentinel
namespace: mail
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: rspamd-valkey
ports:
- name: sentinel
port: 26379
targetPort: sentinel
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Usage: start.sh valkey|sentinel. Ask peer sentinels for the current master;
# with no answer (fresh cluster) pod 0 is the master.
set -eu
name="$(hostname)"
sts="${name%-*}"
self="${name}${DOMAIN_SUFFIX}"
master=""
for i in 0 1 2; do
peer="${sts}-${i}${DOMAIN_SUFFIX}"
[ "$peer" = "$self" ] && continue
master="$(timeout 3 valkey-cli -h "$peer" -p 26379 --raw sentinel get-master-addr-by-name rspamd 2>/dev/null | head -n1 || true)"
[ -n "$master" ] && break
done
[ -n "$master" ] || master="${sts}-0${DOMAIN_SUFFIX}"
if [ "$1" = sentinel ]; then
cat > /sentinel/sentinel.conf <<CONF
port 26379
protected-mode no
sentinel resolve-hostnames yes
sentinel announce-hostnames yes
sentinel announce-ip $self
sentinel monitor rspamd $master 6379 2
sentinel down-after-milliseconds rspamd 5000
sentinel failover-timeout rspamd 60000
sentinel parallel-syncs rspamd 1
CONF
exec valkey-sentinel /sentinel/sentinel.conf
fi
set -- --dir /data --appendonly yes --protected-mode no --replica-announce-ip "$self"
[ "$master" = "$self" ] || set -- "$@" --replicaof "$master" 6379
exec valkey-server "$@"
+79
View File
@@ -0,0 +1,79 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: rspamd
namespace: mail
---
# Separate auth for the litellm engine: the `rspamd` k8s role is the only one
# whose policy grants litellm/creds/rspamd.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: rspamd
namespace: mail
spec:
allowedNamespaces:
- mail
kubernetes:
audiences:
- vault
role: rspamd
serviceAccount: rspamd
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default
---
# Haiku-scoped LiteLLM virtual key for the gpt module (field `key`).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultDynamicSecret
metadata:
name: rspamd-litellm
namespace: mail
spec:
allowStaticCreds: false
destination:
create: true
name: rspamd-litellm
overwrite: true
mount: litellm
path: creds/rspamd
revoke: true
vaultAuthRef: rspamd
---
# DKIM private key for unkin.net, selector `mail` (field `private_key`).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: dkim-unkin-net
namespace: mail
spec:
destination:
create: true
name: dkim-unkin-net
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/mail/default/dkim-unkin-net
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
---
# Controller (web UI) password (field `password`).
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: rspamd-controller
namespace: mail
spec:
destination:
create: true
name: rspamd-controller
overwrite: true
hmacSecretData: true
mount: kv
path: kubernetes/namespace/mail/default/rspamd-controller
refreshAfter: 5m
type: kv-v2
vaultAuthRef: default
+3
View File
@@ -0,0 +1,3 @@
reject = 15;
add_header = 6;
greylist = null;
+8
View File
@@ -0,0 +1,8 @@
clamav {
type = "clamav";
servers = "clamav.mail.svc.cluster.local:3310";
symbol = "CLAM_VIRUS";
action = "reject";
scan_mime_parts = true;
log_clean = false;
}
+8
View File
@@ -0,0 +1,8 @@
# Sign outbound only: authenticated or local_addrs senders, never inbound.
sign_authenticated = true;
sign_local = true;
sign_inbound = false;
use_domain = "header";
allow_hdrfrom_mismatch = false;
selector = "mail";
path = "/etc/rspamd/dkim/$domain.$selector.key";
+25
View File
@@ -0,0 +1,25 @@
enabled = true;
type = "openai";
url = "http://litellm.litellm.svc.cluster.local:4000/v1/chat/completions";
api_key = "{= env.LITELLM_API_KEY =}";
model = "claude-haiku-4-5";
model_parameters = {
"claude-haiku-4-5" = {
max_tokens = 400,
temperature = 0.0,
}
};
timeout = 10s;
# Only ask the model about inbound mail whose score is uncertain (2 <= score < 12).
condition = <<EOD
return function(task)
if task:get_user() then return false, 'authenticated' end
local ip = task:get_from_ip()
if ip and ip:is_local() then return false, 'local sender' end
local res = task:get_metric_result()
if not res then return false, 'no result' end
if res.passthrough then return false, 'passthrough' end
if res.score < 2 or res.score >= 12 then return false, 'score not uncertain' end
return true
end
EOD
+1
View File
@@ -0,0 +1 @@
enabled = false;
+1
View File
@@ -0,0 +1 @@
nrows = 5000;
+3
View File
@@ -0,0 +1,3 @@
# Pod network (Stalwart via the smarthost) and lab networks: local, so outbound mail from them is DKIM-signed.
local_addrs = ["127.0.0.0/8", "::1", "10.42.0.0/16", "198.18.0.0/16"];
task_timeout = 20s;
+4
View File
@@ -0,0 +1,4 @@
sentinels = "rspamd-valkey-sentinel.mail.svc.cluster.local:26379";
sentinel_masters_pattern = "^rspamd$";
sentinel_watch_time = 10s;
servers = "rspamd-valkey-0.rspamd-valkey.mail.svc.cluster.local.:6379";
+1
View File
@@ -0,0 +1 @@
ruleset = "/etc/rspamd/sa-rules/*.cf";
@@ -0,0 +1,3 @@
password = "{= env.CONTROLLER_PASSWORD =}";
enable_password = "{= env.CONTROLLER_PASSWORD =}";
allow_file_and_shm_inputs = false;
+2
View File
@@ -0,0 +1,2 @@
count = 2;
allow_file_and_shm_inputs = false;
+1
View File
@@ -0,0 +1 @@
allow_file_and_shm_inputs = false;
+18
View File
@@ -0,0 +1,18 @@
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultAuth
metadata:
name: default
namespace: mail
spec:
allowedNamespaces:
- mail
kubernetes:
audiences:
- vault
role: default
serviceAccount: default
tokenExpirationSeconds: 600
method: kubernetes
mount: k8s/au/syd1
vaultConnectionRef: vso-system/default