{{- /* One NetworkPolicy per node group (Gitea #5). THIS CHART AND valkey ARE THE TWO THAT HAD A POLICY BEFORE #5, so the convention was derived from them rather than imposed on them. Three things changed here; everything else is the original and is right. - THE REASONING MOVED OUT OF "#" COMMENTS into template comments. A "#" comment is part of the RENDERED object: it travels into the cluster, shows up in `helm get manifest` and `kubectl get -o yaml`, and lands in every release diff. ci/run-networkpolicy-test.sh now fails on a rendered "#", and this file was one of the two that failed it. - networkPolicy.ingressController ARRIVED, and it fixes a real hole this file's own comment described without being able to solve: it used to say that setting probeSources means putting the ingress controller's namespace into allowedNamespaces "or the Ingress goes dark". That works, but allowedNamespaces is namespace-granular, so it would have opened the http port to EVERY pod in the controller's namespace - kube-system, here. The new value renders a namespaceSelector and a podSelector in ONE from-entry, which is an AND, and narrows the source to the controller's pods. - THE EXAMPLE IN values.yaml WAS WRONG AND IS CORRECTED. It suggested allowedNamespaces: ["ingress-nginx"]. Measured on 2026-10-14: ingress-nginx is GONE from this cluster - no DaemonSet, no pods - the only IngressClass is traefik, and the controller is the DaemonSet rke2-traefik in kube-system. A policy written against the old controller would have been accepted, would have looked strict, and would have silently blocked all web traffic. WHAT DID NOT CHANGE, because it is the reason this file was the model: This object is the CEILING ON REACHABILITY, and that is its real job here: it holds no matter which ports the process actually bound. A node whose ClusterRole is missing from the database fail-opens into a full node and binds every listener - including IMAP and the admin interface on a group meant to take SMTP only - while staying Running with a green probe. The per-port Services publish only what was asked for, and this policy is the second, independent limit. See values.yaml for the source-level detail. THE PORT NUMBERS HERE ARE POD PORTS (25/465/993/8080), NOT NODEPORTS. This is the easiest mistake to make in this chart, so it is spelled out. A NetworkPolicy is evaluated at the POD's network interface, after kube-proxy has already translated the NodePort to the targetPort. Writing 30025 here would match nothing: the packet arrives at the pod on 25. The policy would then look strict, be accepted by the API server, and silently block every inbound connection - or, if it is the only rule, block the lot. ci/run-networkpolicy-test.sh now asserts this mechanically, against the containerPorts of the pod spec each policy selects. Egress is restricted BY PORT and not by destination, because a NetworkPolicy matches CIDRs and selectors and never hostnames - so dataStore.postgres.host and externalServices.*.host cannot appear in it. What it still buys: a compromised pod cannot open arbitrary outbound ports. And a NetworkPolicy is only a boundary where the cluster's CNI enforces one. An unenforced policy is accepted and filters nothing. */ -}} {{- if .Values.networkPolicy.enabled }} {{- $root := . -}} {{- $ports := fromYaml (include "stalwart.ports" .) -}} {{- $egress := .Values.networkPolicy.egress -}} {{- $probeSources := .Values.networkPolicy.probeSources | default list -}} {{- $allowedNs := .Values.networkPolicy.allowedNamespaces | default list -}} {{- $ic := .Values.networkPolicy.ingressController | default dict -}} {{- range $group, $spec := fromYaml (include "stalwart.nodes" .) }} {{- $name := printf "%s-%s" (include "stalwart.fullname" $root) $group }} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: {{ $name }}-networkpolicy labels: app: {{ $name }} spec: podSelector: matchLabels: app: {{ $name }}-sfs policyTypes: - Ingress - Egress ingress: {{- /* The public mail ports: open to any source. They have to be - the senders are the whole internet, and after NodePort masquerading the source address is a control-plane node anyway. */}} {{- $publicPorts := list }} {{- range $portName := $spec.exposedPorts }} {{- $port := index $ports $portName }} {{- if $port.public }}{{- $publicPorts = append $publicPorts $portName }}{{- end }} {{- end }} {{- if $publicPorts }} - ports: {{- range $portName := $publicPorts }} {{- $port := index $ports $portName }} {{- /* {{ $portName }}: published on a nodePort, reaching the pod on {{ int $port.containerPort }} - which is the number that belongs here. */}} - protocol: {{ $port.protocol | default "TCP" }} port: {{ int $port.containerPort }} {{- end }} {{- end }} {{- /* The http port: probes plus the ingress controller. */}} {{- $http := index $ports "http" }} - ports: - protocol: {{ $http.protocol | default "TCP" }} port: {{ int $http.containerPort }} {{- if or $probeSources $allowedNs }} {{- /* Restricted because networkPolicy.probeSources or .allowedNamespaces is set. MIND THE KUBELET: probes come from the IP of the worker node running the pod, so that address has to be in probeSources or the pod never becomes Ready - and a pod that is never Ready reads as a broken application rather than as a policy. */}} from: - namespaceSelector: matchLabels: {{- /* Set by Kubernetes itself on every namespace (>= 1.21), so this needs no labelling work. */}} kubernetes.io/metadata.name: {{ $root.Release.Namespace }} {{- if $ic.namespace }} {{- /* The ingress controller, as ONE from-entry with TWO selectors - which is an AND: those pods in that namespace. Written as two list entries it would be an OR and would open the http port to every pod in the controller's namespace. This is what replaced the old advice of putting kube-system into allowedNamespaces. */}} - namespaceSelector: matchLabels: kubernetes.io/metadata.name: {{ $ic.namespace | quote }} {{- with $ic.podLabels }} podSelector: matchLabels: {{- toYaml . | nindent 14 }} {{- end }} {{- end }} {{- range $allowedNs }} - namespaceSelector: matchLabels: kubernetes.io/metadata.name: {{ . | quote }} {{- end }} {{- range $probeSources }} - ipBlock: cidr: {{ . | quote }} {{- end }} {{- else }} {{- /* NO "from" SELECTOR, WHICH MEANS OPEN - deliberately, and only inside the cluster: http carries no nodePort, so nothing outside reaches it through this chart. The alternative was to guess the worker node CIDRs, and a wrong guess blocks every kubelet probe. Narrow it by setting networkPolicy.probeSources; the ingress controller is then added from networkPolicy.ingressController, so the Ingress does not go dark. Note that an empty "from: []" would be worse than no key at all: it also allows everything, but while reading as a restriction. ci/run-networkpolicy-test.sh rejects that form. */}} {{- end }} egress: {{- if $egress.dns }} - ports: - protocol: UDP port: 53 - protocol: TCP port: 53 {{- end }} {{- if $egress.smtp }} {{- /* Outbound delivery: port 25 on any host on the internet. Uniform for every group on purpose - the chart does not know which groups run the outboundMta task, the value would drift away from the database, and the failure mode of getting it wrong is mail silently not leaving. */}} - ports: - protocol: TCP port: 25 {{- end }} {{- $outPorts := list (int $root.Values.dataStore.postgres.port) }} {{- with $root.Values.externalServices }} {{- if .s3 }}{{- $outPorts = append $outPorts (int .s3.port) }}{{- end }} {{- if .valkey }}{{- $outPorts = append $outPorts (int .valkey.port) }}{{- end }} {{- end }} {{- range $root.Values.networkPolicy.egress.extraPorts }} {{- $outPorts = append $outPorts (int .) }} {{- end }} {{- /* The data store, the blob store, the in-memory store and anything added through networkPolicy.egress.extraPorts. Ports only - see the header. Unlike most charts in this sweep these are the CONFIGURED ports rather than the standard ones, because this chart holds them as values. */}} - ports: {{- range $outPorts | uniq }} - protocol: TCP port: {{ . }} {{- end }} {{- end }} {{- end }}