diff --git a/apps/base/mail/clamav.yaml b/apps/base/mail/clamav.yaml new file mode 100644 index 0000000..09ad431 --- /dev/null +++ b/apps/base/mail/clamav.yaml @@ -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 diff --git a/apps/base/mail/kustomization.yaml b/apps/base/mail/kustomization.yaml index 603ae53..289d94f 100644 --- a/apps/base/mail/kustomization.yaml +++ b/apps/base/mail/kustomization.yaml @@ -3,16 +3,50 @@ 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 - smarthost-networkpolicy.yaml - smarthost-pdb.yaml - smarthost-service.yaml - smarthost-statefulset.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/external_relay.conf + - rspamd/force_actions.conf + - rspamd/gpt.conf + - rspamd/greylist.conf + - rspamd/history_redis.conf + - rspamd/local_networks.map + - rspamd/milter_headers.conf + - rspamd/options.inc + - rspamd/redis.conf + - rspamd/settings.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 - name: smarthost-config namespace: mail options: diff --git a/apps/base/mail/rspamd-deployment.yaml b/apps/base/mail/rspamd-deployment.yaml new file mode 100644 index 0000000..2764651 --- /dev/null +++ b/apps/base/mail/rspamd-deployment.yaml @@ -0,0 +1,140 @@ +--- +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_HASH + valueFrom: + secretKeyRef: + name: rspamd-controller + key: password_hash + 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 only (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 diff --git a/apps/base/mail/rspamd-sa-rules.yaml b/apps/base/mail/rspamd-sa-rules.yaml new file mode 100644 index 0000000..9789497 --- /dev/null +++ b/apps/base/mail/rspamd-sa-rules.yaml @@ -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: {} diff --git a/apps/base/mail/rspamd-valkey.yaml b/apps/base/mail/rspamd-valkey.yaml new file mode 100644 index 0000000..4d9cd67 --- /dev/null +++ b/apps/base/mail/rspamd-valkey.yaml @@ -0,0 +1,178 @@ +--- +# 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. start.sh follows the master the sentinels report; on a full +# restart the highest replication offset wins. 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 + podManagementPolicy: Parallel + 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: ["sh", "-c", "valkey-cli info replication | grep -q '^master_host:127.0.0.1' && exit 1; valkey-cli ping | grep -q PONG"] + 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 diff --git a/apps/base/mail/rspamd-valkey/start.sh b/apps/base/mail/rspamd-valkey/start.sh new file mode 100755 index 0000000..b30cdce --- /dev/null +++ b/apps/base/mail/rspamd-valkey/start.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# Usage: start.sh valkey|sentinel. valkey boots read-only as a replica of +# 127.0.0.1:1 ("undecided") so peers can compare offsets; decide() then follows +# the sentinels' master, a live peer master, or the highest-offset node. +set -eu +name="$(hostname)" +sts="${name%-*}" +self="${name}${DOMAIN_SUFFIX}" + +info() { + timeout 3 valkey-cli -h "$1" info replication 2>/dev/null | tr -d '\r' || true +} + +field() { + printf '%s\n' "$1" | sed -n "s/^$2://p" +} + +undecided() { + [ "$(field "$1" master_host)" = 127.0.0.1 ] +} + +choose_master() { + deadline=$(($(date +%s) + 60)) + while :; do + best="" + best_off=-1 + answered=0 + for i in 0 1 2; do + peer="${sts}-${i}${DOMAIN_SUFFIX}" + if [ "$peer" = "$self" ]; then + r="$(info 127.0.0.1)" + else + m="$(timeout 3 valkey-cli -h "$peer" -p 26379 --raw sentinel get-master-addr-by-name rspamd 2>/dev/null | head -n1 || true)" + [ -n "$m" ] && { echo "$m"; return; } + r="$(info "$peer")" + [ -n "$r" ] || continue + answered=$((answered + 1)) + [ "$(field "$r" role)" = master ] && { echo "$peer"; return; } + fi + undecided "$r" || continue + off="$(field "$r" master_repl_offset)" + [ "${off:-0}" -gt "$best_off" ] && best="$peer" best_off="${off:-0}" + done + if [ "$best" = "$self" ] && { [ "$answered" -eq 2 ] || [ "$(date +%s)" -ge "$deadline" ]; }; then + echo "$self" + return + fi + sleep 3 + done +} + +decide() { + until valkey-cli ping >/dev/null 2>&1; do sleep 1; done + master="$(choose_master)" + target="$master 6379" + [ "$master" = "$self" ] && target="no one" + until valkey-cli replicaof $target; do sleep 1; done +} + +if [ "$1" = sentinel ]; then + until r="$(info 127.0.0.1)" && [ -n "$(field "$r" role)" ] && ! undecided "$r"; do sleep 2; done + master="$self" + [ "$(field "$r" role)" = master ] || master="$(field "$r" master_host)" + cat > /sentinel/sentinel.conf <= 12 then return false, 'score not uncertain' end + return true +end +EOD diff --git a/apps/base/mail/rspamd/greylist.conf b/apps/base/mail/rspamd/greylist.conf new file mode 100644 index 0000000..a6ee831 --- /dev/null +++ b/apps/base/mail/rspamd/greylist.conf @@ -0,0 +1 @@ +enabled = false; diff --git a/apps/base/mail/rspamd/history_redis.conf b/apps/base/mail/rspamd/history_redis.conf new file mode 100644 index 0000000..f3507c7 --- /dev/null +++ b/apps/base/mail/rspamd/history_redis.conf @@ -0,0 +1 @@ +nrows = 5000; diff --git a/apps/base/mail/rspamd/local_networks.map b/apps/base/mail/rspamd/local_networks.map new file mode 100644 index 0000000..79dd882 --- /dev/null +++ b/apps/base/mail/rspamd/local_networks.map @@ -0,0 +1,30 @@ +# Outbound sources (smarthost mynetworks): DKIM-signed, scored with reject, no GPT. +127.0.0.0/8 +::1 +10.42.0.0/16 +10.10.12.200/32 +198.18.1.10/32 +198.18.2.160/27 +198.18.2.192/27 +198.18.13.0/24 +198.18.14.0/24 +198.18.15.0/24 +198.18.16.0/24 +198.18.17.0/24 +198.18.18.0/24 +198.18.19.0/24 +198.18.20.0/24 +198.18.21.0/24 +198.18.22.0/24 +198.18.23.0/24 +198.18.25.0/24 +198.18.26.0/24 +198.18.27.0/24 +198.18.28.0/24 +198.18.29.0/24 +198.18.64.0/24 +198.18.65.0/24 +198.18.66.0/24 +198.18.67.0/24 +198.18.68.0/24 +198.18.69.0/24 diff --git a/apps/base/mail/rspamd/milter_headers.conf b/apps/base/mail/rspamd/milter_headers.conf new file mode 100644 index 0000000..a08e07f --- /dev/null +++ b/apps/base/mail/rspamd/milter_headers.conf @@ -0,0 +1,2 @@ +use = ["x-spamd-result", "x-spam-status", "x-spam-level", "authentication-results"]; +skip_local = true; diff --git a/apps/base/mail/rspamd/options.inc b/apps/base/mail/rspamd/options.inc new file mode 100644 index 0000000..2628ef7 --- /dev/null +++ b/apps/base/mail/rspamd/options.inc @@ -0,0 +1,2 @@ +local_addrs = "/etc/rspamd/local.d/local_networks.map"; +task_timeout = 20s; diff --git a/apps/base/mail/rspamd/redis.conf b/apps/base/mail/rspamd/redis.conf new file mode 100644 index 0000000..1f94eb5 --- /dev/null +++ b/apps/base/mail/rspamd/redis.conf @@ -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"; diff --git a/apps/base/mail/rspamd/settings.conf b/apps/base/mail/rspamd/settings.conf new file mode 100644 index 0000000..e6eb04d --- /dev/null +++ b/apps/base/mail/rspamd/settings.conf @@ -0,0 +1,19 @@ +outbound { + priority = high; + ip_map = "/etc/rspamd/local.d/local_networks.map"; + apply { + symbols_disabled = ["GPT_CHECK"]; + } +} +inbound { + priority = high; + ip_map = "/etc/rspamd/local.d/local_networks.map"; + inverse = true; + apply { + symbols_disabled = ["DKIM_SIGNED"]; + # Tag only; reject stays defined (unreachable by score) so ClamAV's virus reject still applies. + actions { + reject = 1000; + } + } +} diff --git a/apps/base/mail/rspamd/spamassassin.conf b/apps/base/mail/rspamd/spamassassin.conf new file mode 100644 index 0000000..6377a4d --- /dev/null +++ b/apps/base/mail/rspamd/spamassassin.conf @@ -0,0 +1 @@ +ruleset = "/etc/rspamd/sa-rules/*.cf"; diff --git a/apps/base/mail/rspamd/worker-controller.inc b/apps/base/mail/rspamd/worker-controller.inc new file mode 100644 index 0000000..7c36f18 --- /dev/null +++ b/apps/base/mail/rspamd/worker-controller.inc @@ -0,0 +1,3 @@ +password = "{= env.CONTROLLER_PASSWORD_HASH =}"; +enable_password = "{= env.CONTROLLER_PASSWORD_HASH =}"; +allow_file_and_shm_inputs = false; diff --git a/apps/base/mail/rspamd/worker-normal.inc b/apps/base/mail/rspamd/worker-normal.inc new file mode 100644 index 0000000..2a16e07 --- /dev/null +++ b/apps/base/mail/rspamd/worker-normal.inc @@ -0,0 +1,2 @@ +count = 2; +allow_file_and_shm_inputs = false; diff --git a/apps/base/mail/rspamd/worker-proxy.inc b/apps/base/mail/rspamd/worker-proxy.inc new file mode 100644 index 0000000..897afe8 --- /dev/null +++ b/apps/base/mail/rspamd/worker-proxy.inc @@ -0,0 +1 @@ +allow_file_and_shm_inputs = false; diff --git a/apps/base/mail/vaultauth.yaml b/apps/base/mail/vaultauth.yaml new file mode 100644 index 0000000..8f0b14a --- /dev/null +++ b/apps/base/mail/vaultauth.yaml @@ -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