From 73c2ec704f831a09f38c7f5226a0363c26dc0e29 Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Wed, 14 Oct 2026 14:46:11 +0000 Subject: [PATCH] feat!: NetworkPolicy in the last five charts - the sweep is complete matrix-synapse, paperless-ngx, freesailarr, valkey, stalwart. One major each. All 29 installable charts plus template-chart now render a policy, and ci/run-networkpolicy-test.sh passes over all 30 with 206 assertions held. MATRIX-SYNAPSE renders TWO policies, one per workload, and the split is the decision in that file. Synapse federates - it must be able to reach any homeserver on the internet, so 443 and 8448 go to any destination - while the delegation nginx serves two static JSON documents and calls nobody, so its egress is DNS and nothing else. A single policy over both pods would have to permit the union, handing full federation reach and the database port to an nginx whose job is to return a constant, and it would read as one decision rather than as a widening. networkPolicy.egress.extraPorts deliberately does not reach the delegation policy for the same reason. PAPERLESS-NGX names the two pods of its own release that this object does NOT cover: the valkey subchart's pod, which carries its own labels and needs no cover because the valkey chart ships a tighter policy of its own; and the consume CronJob's pods, whose pod template carries NO LABELS AT ALL, so no podSelector can match them. That second one is a real gap in the convention. It is tolerable only because the job moves files between mounted volumes and reaches the SMB share through a CSI volume the node mounts - the pod opens no connection of its own - and if that ever changes the CronJob needs labels before it can need a policy. FREESAILARR is ten containers in one pod behind a VPN, and simpler than that sounds: they share a network namespace, so all inter-container traffic is localhost and no policy sees any of it. Because the namespace belongs to gluetun, everything the *arr containers send is encapsulated before it leaves, so three rules describe the whole outbound surface - DNS, UDP 51820 for the tunnel, and 443 for gluetun's server-list API, which cannot go through the tunnel because it runs before the tunnel exists. The tunnel rule is the one that can break this chart and also the best covered: gluetun's health server answers 200 only while the tunnel is up and all three of its probes address it, so a blocked 51820 is a pod that never goes Ready - in the install-test, which brings up a real tunnel. VALKEY AND STALWART, the two charts that had a policy before this issue, are pulled onto the convention without a single rule changing. What changed: - the reasoning moved out of "#" comments, which are part of the rendered object and travel into the cluster, into template comments. Both files failed the new check; that is what it is for. - stalwart gains networkPolicy.ingressController, which fixes a hole its own comment described without being able to solve: it advised putting the controller's namespace into allowedNamespaces, which is namespace-granular and would have opened the http port to every pod in kube-system. - stalwart's example was WRONG and is corrected. It suggested ["ingress-nginx"]; measured on 2026-10-14, ingress-nginx is gone from this cluster entirely and the controller is rke2-traefik in kube-system. A policy written against the old one would have been accepted, looked strict, and silently blocked all web traffic. Their justified specifics are untouched: valkey stays Ingress-only, because it opens no outbound connection and an egress rule would constrain nothing while making the object look stricter; and it keeps 6379 restricted by source with no probeSources at all, which is safe only because all three of its probes are exec probes and put no packet on the network. The convention was written to accommodate both, and homeassistant and satisfactory now invoke valkey's rule in the other direction. ONE CHANGE TO THE TEST, forced by a structural fact rather than by convenience: the rendered-comment assertion now skips documents from a SUBCHART. A dependency is fetched from the Helm registry, not from the copy in this repository, so the policy paperless-ngx and stalwart bundle is whatever valkey version is PUBLISHED - and nothing in the parent chart can change its rendered comments. Holding the parent responsible would have made the test unfixable until valkey's next release and would fail a chart for a file its own diff does not contain. The subchart's own pull request is where it is caught, because this test runs over every chart including valkey itself. All twelve failure paths were re-run against the change. Part of #5 --- ci/run-networkpolicy-test.sh | 18 +- freesailarr/Chart.yaml | 2 +- freesailarr/templates/NOTES.txt | 69 +++++ .../templates/freesailarr.networkpolicy.yaml | 253 ++++++++++++++++ freesailarr/values.schema.json | 51 ++++ freesailarr/values.yaml | 98 +++++++ matrix-synapse/Chart.yaml | 2 +- matrix-synapse/templates/NOTES.txt | 59 ++++ .../matrix-synapse.networkpolicy.yaml | 269 ++++++++++++++++++ matrix-synapse/values.schema.json | 51 ++++ matrix-synapse/values.yaml | 96 +++++++ paperless-ngx/Chart.yaml | 2 +- paperless-ngx/templates/NOTES.txt | 63 ++++ .../paperless-server.networkpolicy.yaml | 223 +++++++++++++++ paperless-ngx/values.schema.json | 51 ++++ paperless-ngx/values.yaml | 97 +++++++ stalwart/Chart.yaml | 2 +- .../templates/stalwart.networkpolicy.yaml | 129 +++++++-- stalwart/values.schema.json | 14 + stalwart/values.yaml | 39 ++- valkey/Chart.yaml | 2 +- valkey/templates/valkey.networkpolicy.yaml | 55 +++- 22 files changed, 1593 insertions(+), 52 deletions(-) create mode 100644 freesailarr/templates/freesailarr.networkpolicy.yaml create mode 100644 matrix-synapse/templates/matrix-synapse.networkpolicy.yaml create mode 100644 paperless-ngx/templates/paperless-server.networkpolicy.yaml diff --git a/ci/run-networkpolicy-test.sh b/ci/run-networkpolicy-test.sh index 3c9ed42..5f0b957 100755 --- a/ci/run-networkpolicy-test.sh +++ b/ci/run-networkpolicy-test.sh @@ -704,11 +704,27 @@ for policy in policies: # the chart's reasoning in front of everyone who diffs a release. Go template # comments render nothing and are the place for it. It can only be checked on # the raw YAML - the comments are gone once ci/yaml2json has read the stream. +# SUBCHART DOCUMENTS ARE EXCLUDED FROM THIS ONE, and the reason is structural +# rather than lenient. A dependency is fetched from the Helm registry, not from +# the copy in this repository, so the NetworkPolicy that paperless-ngx and +# stalwart bundle is whatever version of valkey is PUBLISHED - and nothing in +# the parent chart can change its rendered comments. Holding the parent +# responsible would make this test unfixable until the subchart's next release, +# and would fail a chart for a file its own diff does not contain. The +# subchart's own pull request is where that is caught, because this test runs +# over every chart including valkey itself. +# +# Recognised from the "# Source:" header Helm writes above each document: a +# subchart's path contains "/charts/". if policies: current, offenders = [], [] for line in raw.splitlines() + ["---"]: if line.strip() == "---": - if re.search(r"^kind:\s*NetworkPolicy\s*$", "\n".join(current), re.M): + body = "\n".join(current) + source = re.search(r"^# Source:\s*(\S+)", body, re.M) + from_subchart = bool(source) and "/charts/" in source.group(1) + if (re.search(r"^kind:\s*NetworkPolicy\s*$", body, re.M) + and not from_subchart): for raw_line in current: stripped = raw_line.strip() if stripped.startswith("#") and not stripped.startswith("# Source:"): diff --git a/freesailarr/Chart.yaml b/freesailarr/Chart.yaml index cb8e8e8..6e14a3e 100644 --- a/freesailarr/Chart.yaml +++ b/freesailarr/Chart.yaml @@ -8,7 +8,7 @@ description: A Helm chart to deploy a Wireguard-tunneled media automation stack type: application -version: 4.0.0+up1.0.0 +version: 5.0.0+up1.0.0 # The chart's own version marker, freely chosen and tied to no upstream. The # stack bundles ten independently versioned projects (nine of them on by # default), so there is no single "app version" to track here. diff --git a/freesailarr/templates/NOTES.txt b/freesailarr/templates/NOTES.txt index f66c27f..a791a78 100644 --- a/freesailarr/templates/NOTES.txt +++ b/freesailarr/templates/NOTES.txt @@ -418,3 +418,72 @@ MORE THAN ONE REPLICA IS NOT MEANINGFUL config volumes, all of them fighting over the same media claims and the same VPN credentials. {{- end }} +{{- if .Values.networkPolicy.enabled }} + +NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT + {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods. + Inbound: the nine probed ports — gluetun's health server (9999) and the eight + web interfaces (8080, 9696, 8191, 7878, 8989, 5055, 6767, 5007) — plus + mediathekarr's newznab port (5008), which is RESTRICTED by source because + nothing probes it and its only consumer is Prowlarr in this same pod. + Outbound: DNS, UDP 51820 (the WireGuard tunnel) and TCP 443 (gluetun's + server-list API){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THE TEN CONTAINERS TALK TO EACH OTHER OVER LOCALHOST, so none of that traffic + is governed by this object at all — they share a network namespace. And + because that namespace belongs to gluetun, everything the *arr containers + send is encapsulated into the tunnel before it leaves the pod: indexer and + tracker traffic needs no egress rule of its own. + + IF THIS POD WILL NOT BECOME READY, SUSPECT THE TUNNEL RULE. gluetun's health + server answers 200 only while the tunnel is up, and all three of its probes + address it, so a blocked UDP 51820 shows up as a pod that never goes Ready + rather than as a network error. 51820 is WireGuard's and Mullvad's standard + port; Mullvad also offers 53, 123, 500 and 4500 for obfuscation, and a + release on one of those has to widen the rule in the template — + networkPolicy.egress.extraPorts is TCP only. + + AND IF SEERR CANNOT REACH AN IN-CLUSTER SERVICE (Jellyfin, say), two things + have to be true, not one: its port in networkPolicy.egress.extraPorts, and + gluetun.vpn.firewallOutboundSubnets set — gluetun's own firewall blocks + cluster-internal egress before this policy is ever consulted. + + Three limits belong in the same breath, because none of them is visible in + the object itself: + + - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and + selectors and cannot express a hostname, so every egress rule permits its + port to ANY address. That is a limit of the object, not a shortcut — this + cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An + outbound port the chart does not know about is BLOCKED: add it to + networkPolicy.egress.extraPorts, and expect to find out through an + application-level connection error rather than through this object. +{{- if .Values.networkPolicy.probeSources }} + - THE PROBE PATH IS RESTRICTED in this release, because + networkPolicy.probeSources is set to + {{ join ", " .Values.networkPolicy.probeSources }} + Probes come from the IP of the WORKER NODE running the pod. If none of those + CIDRs covers the node this pod lands on, the kubelet's probes are dropped + and THE POD NEVER BECOMES READY — the rollout then times out and the symptom + looks like a broken application. Compare + + kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs + + against `kubectl get node -o wide`. +{{- else }} + - THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is + empty, so that rule carries no source selector at all and any pod the CNI + routes to can reach it. Not from the internet — but this is NOT pod-level + isolation. Narrowing it means listing the worker node IPs in + networkPolicy.probeSources, because the kubelet probes from the node's own + address and the chart cannot know it; a wrong guess there leaves the pod + permanently un-Ready. +{{- end }} + - IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not, + this object is accepted and filters nothing, so everything reads as + configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy` + shows the object exists; only a connection attempt from an unrelated + namespace shows that it bites. + + Switch it off with networkPolicy.enabled: false. +{{- end }} diff --git a/freesailarr/templates/freesailarr.networkpolicy.yaml b/freesailarr/templates/freesailarr.networkpolicy.yaml new file mode 100644 index 0000000..9ea4dfd --- /dev/null +++ b/freesailarr/templates/freesailarr.networkpolicy.yaml @@ -0,0 +1,253 @@ +{{- /* +NetworkPolicy for freesailarr (Gitea #5). + +TEN CONTAINERS IN ONE POD, BEHIND A VPN, AND THE POLICY IS SIMPLER THAN THAT +SOUNDS - for one reason worth stating first: the containers talk to each other +over LOCALHOST. They share a network namespace, so none of that traffic ever +reaches a network interface and NO NetworkPolicy has anything to say about it. +Prowlarr reaching Radarr, Recyclarr reaching Sonarr, every one of those hops is +invisible here. What this policy governs is only what enters or leaves the pod. + +INBOUND is the eight published web interfaces plus gluetun's health server, and +almost all of them are probe ports - the kubelet opens an HTTP connection to +each container's own interface - so they are one rule on the probe path. The +newznab port of mediathekarr is the exception: nothing probes it, and its only +consumer is Prowlarr in this same pod over localhost, so it is restricted by +source and that costs nothing. + +OUTBOUND IS WHERE THIS CHART IS UNLIKE EVERY OTHER ONE HERE, because the pod's +network namespace belongs to gluetun. Every connection the *arr containers make +goes into the WireGuard tunnel and leaves the pod as UDP to the VPN endpoint - +so a NetworkPolicy sees the OUTER packets and almost nothing else. Three rules +cover it: DNS, UDP 51820 for the tunnel itself, and 443 for gluetun's own +server-list API. Indexer and tracker traffic needs no rule of its own; it is +already inside the tunnel. + +AND THE TUNNEL RULE IS THE ONE THAT CAN BREAK THIS CHART, which is also why it +is well covered: gluetun's health server returns 200 only while the tunnel is +up, and all three of its probes address it. A blocked UDP 51820 therefore means +a pod that never becomes Ready - visibly, in the install-test, which brings up +a real tunnel. 51820 is WireGuard's standard port and Mullvad's; Mullvad also +offers 53, 123, 500 and 4500 for obfuscation, and a release using one of those +needs networkPolicy.egress.extraPorts. Note that extraPorts is TCP only, so an +alternative UDP port needs the rule here widened instead - said plainly, +because it is a limit of the shared values shape and not an oversight. + +WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer. +Only the ports declared below are reachable at all - whatever else the process +binds, now or after the next app update, is blocked without anyone having to +notice it exists. + +WHAT IT IS NOT: + - EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER + hostnames, so "only to the database host" cannot be expressed at all. This + cluster's CNI is Canal (Flannel plus Calico for policy) and has no FQDN + rule; Calico's GlobalNetworkSet could express one but would bind this chart + to Calico, the same argument that kept implementation-specific objects out + of the Gateway-API sweep (#114). Each egress rule therefore permits its + PORT to any destination. + - NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is + accepted by the API server and filters nothing, so everything reads as + configured while nothing is filtered. + - THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part + of the rendered object, travel into the cluster and appear in every release + diff. ci/run-networkpolicy-test.sh fails on a rendered "#". + +THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is +evaluated at the pod's network interface, after kube-proxy has already +translated a NodePort to the targetPort, so a NodePort number here would match +nothing while looking strict - and, being the only rule for that port, would +block every inbound connection instead. ci/run-networkpolicy-test.sh asserts +every port here against the containerPorts of the pod spec this policy +selects. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- $ns := .Release.Namespace }} +{{- $probeSources := $np.probeSources | default list }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +{{- $ic := $np.ingressController | default dict }} +{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the StatefulSet's pods. ci/run-networkpolicy-test.sh fails if + this matches no pod spec of the same render - which is the mistake that + would otherwise leave a policy in the namespace filtering nothing, and + its usual cause is a label renamed in the workload template. + */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + policyTypes: + - Ingress + {{- /* + Egress is listed, and for this chart it is unusually meaningful: because the + pod's network namespace belongs to gluetun, the only things that leave it are + the tunnel, DNS and gluetun's control-plane calls. Everything the applications + do is encapsulated. So three narrow rules really do describe the pod's whole + outbound surface - which is rare here. + */}} + - Egress + ingress: + {{- /* + THE PROBE PATH, nine ports of it. gluetun's health server (9999) plus the eight + web interfaces the kubelet probes directly: qBittorrent (8080), Prowlarr + (9696), FlareSolverr (8191), Radarr (7878), Sonarr (8989), Seerr (5055), Bazarr + (6767) and Mediathekarr (5007). Every one of them is addressed by at least one + httpGet probe, so none of them can be restricted by source without the worker + node's IP. + + Recyclarr is absent from this list on purpose: it declares no port and its + probes are exec probes, so there is nothing to allow. + */}} + - ports: + - protocol: TCP + port: 9999 + - protocol: TCP + port: 8080 + - protocol: TCP + port: 9696 + - protocol: TCP + port: 8191 + - protocol: TCP + port: 7878 + - protocol: TCP + port: 8989 + - protocol: TCP + port: 5055 + - protocol: TCP + port: 6767 + - protocol: TCP + port: 5007 + {{- if $probeSources }} + {{- /* + RESTRICTED, because the operator supplied probeSources. The kubelet's probes + come from the IP of the WORKER NODE running the pod, so that address has to be + in the list or the pod never becomes Ready - and a pod that is never Ready + reads as a broken application rather than as a policy. + + The ingress controller is added to the same rule - this chart renders TWO + Ingresses, the main one and qBittorrent's, and both go dark the moment this + list is filled without it. + */}} + from: + - namespaceSelector: + matchLabels: + {{- /* + Set by Kubernetes itself on every namespace (>= 1.21), so this needs no + labelling work on a namespace this chart does not own. + */}} + kubernetes.io/metadata.name: {{ $ns }} + {{- if and $routed $ic.namespace }} + {{- /* + ONE from-entry with TWO selectors, which is an AND: pods carrying these labels + IN that namespace. Written as two list entries it would be an OR, and would + open the port to every pod in kube-system. + */}} + - 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, because this port carries no NodePort. The alternative was to guess + the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod + stays un-Ready, the rollout times out, and the cause looks like the + application. An empty "from: []" would be worse than either - it allows + everything too, but while reading as a restriction. Narrow it with + networkPolicy.probeSources. + */}} + {{- end }} + {{- /* + MEDIATHEKARR'S NEWZNAB PORT, and the one inbound port here that can be + restricted: no probe addresses it. Its only consumer is Prowlarr, which runs in + THIS pod and reaches it over localhost - traffic no NetworkPolicy sees at all - + so restricting this rule to the release's own namespace plus allowedNamespaces + costs nothing and removes a port from the cluster's reach. + */}} + - ports: + - protocol: TCP + port: 5008 + from: + - namespaceSelector: + matchLabels: + {{- /* + Set by Kubernetes itself on every namespace (>= 1.21), so this needs no + labelling work on a namespace this chart does not own. + */}} + kubernetes.io/metadata.name: {{ $ns }} + {{- range $allowedNs }} + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ . | quote }} + {{- end }} + egress: + {{- /* + DNS, unconditionally. There is no value to switch this off, and that is + deliberate: a knob whose only other setting breaks every name lookup in + the pod is a trap and not a feature. + */}} + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- /* + THE WIREGUARD TUNNEL, and the rule this chart cannot live without. Everything + the *arr containers do leaves the pod inside this tunnel, so blocking it + blocks all of them at once - and gluetun's health server, which all three of + its probes address, returns 200 only while the tunnel is up. The pod would + never become Ready. + + 51820 is WireGuard's standard port and Mullvad's. Mullvad also offers 53, 123, + 500 and 4500 for obfuscation; a release using one of those must widen THIS rule, + because networkPolicy.egress.extraPorts is TCP only. + */}} + - ports: + - protocol: UDP + port: 51820 + {{- /* + Outbound HTTPS, for gluetun's own control-plane traffic: it fetches and caches + the provider's server list over the API before the tunnel exists, so this + cannot go through the tunnel. Indexer and tracker traffic does NOT need a rule + here - it is already inside the tunnel by the time it leaves the pod. + */}} + - ports: + - protocol: TCP + port: 443 + {{- with $np.egress }} + {{- with .extraPorts }} + {{- /* + Ports only, to any destination - see the header. This is the operator's + escape hatch from the egress cap, and the only alternative to switching + the whole policy off. + */}} + - ports: + {{- range . }} + - protocol: TCP + port: {{ int . }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/freesailarr/values.schema.json b/freesailarr/values.schema.json index 57f454d..4b580d1 100644 --- a/freesailarr/values.schema.json +++ b/freesailarr/values.schema.json @@ -9,6 +9,57 @@ "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.", "type": "object" }, + "networkPolicy": { + "description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "allowedNamespaces": { + "description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "probeSources": { + "description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "ingressController": { + "description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "namespace": { + "type": ["string", "null"] + }, + "podLabels": { + "description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.", + "type": ["object", "null"] + } + } + }, + "egress": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "extraPorts": { + "description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.", + "type": ["array", "null"], + "items": { + "type": "integer" + } + } + } + } + } + }, "scaleDown": { "type": "boolean" }, diff --git a/freesailarr/values.yaml b/freesailarr/values.yaml index 7d2a26b..c54e786 100644 --- a/freesailarr/values.yaml +++ b/freesailarr/values.yaml @@ -1330,3 +1330,101 @@ mediathekarr: capabilities: drop: - ALL + +# --------------------------------------------------------------------------- +# NetworkPolicy (Gitea #5). On by default. The reference implementation with +# the full reasoning is template-chart; the convention is in README, "Chart +# style guidelines", and the operator's view on this chart's wiki page. +# --------------------------------------------------------------------------- +networkPolicy: + # TEN CONTAINERS IN ONE POD, AND THE FIRST THING TO KNOW: they talk to each + # other over LOCALHOST. They share a network namespace, so that traffic never + # touches an interface and no NetworkPolicy sees it. This object governs only + # what enters or leaves the pod. + # + # WHAT IT ALLOWS, inbound: the nine probed ports as one rule (gluetun's + # health server 9999, plus qBittorrent 8080, Prowlarr 9696, FlareSolverr + # 8191, Radarr 7878, Sonarr 8989, Seerr 5055, Bazarr 6767, Mediathekarr + # 5007), and mediathekarr's newznab port 5008 RESTRICTED by source - nothing + # probes it, and its only consumer is Prowlarr over localhost, so restricting + # it costs nothing. + # + # OUTBOUND, and this chart is unlike every other one here: the pod's network + # namespace belongs to gluetun, so everything the *arr containers do is + # encapsulated into the WireGuard tunnel before it leaves. Three rules + # describe the whole outbound surface - DNS, UDP 51820 for the tunnel, and + # 443 for gluetun's server-list API, which cannot go through the tunnel + # because it runs before the tunnel exists. Indexer and tracker traffic needs + # no rule: it is already inside. + # + # THE TUNNEL RULE IS THE ONE THAT CAN BREAK THIS CHART, and it is also the + # best-covered: gluetun's health server returns 200 only while the tunnel is + # up, and all three of its probes address it, so a blocked 51820 means a pod + # that never becomes Ready - visibly, and in the install-test, which brings + # up a real tunnel. 51820 is WireGuard's and Mullvad's standard port; Mullvad + # also offers 53, 123, 500 and 4500 for obfuscation, and a release using one + # of those must widen the rule in the TEMPLATE, because egress.extraPorts is + # TCP only. That is a limit of the shared values shape, said plainly. + enabled: true + # Namespaces whose pods may reach this workload, in addition to the release's + # own namespace, which is always allowed. Empty means: own namespace only. + # + # Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every + # namespace itself, so no labelling work is needed on a namespace this chart + # does not own - and no typo protection either: a name that does not exist + # matches nothing and is silently ineffective. Note the granularity: an entry + # opens the port to EVERY pod in that namespace. + allowedNamespaces: [] + # Source CIDRs allowed to reach the nine probed ports (gluetun's health server and the eight web interfaces), which is every inbound port except mediathekarr's newznab port. + # + # EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a + # "from" selector, so the port is reachable from anywhere the CNI routes to - + # inside the cluster, since it carries no NodePort. + # + # That is the deliberate choice between two bad defaults. The kubelet's probes + # originate from the IP of the WORKER NODE running the pod, an address this + # chart cannot know and must not guess: a CIDR that excludes the real node + # blocks every probe, the pod never becomes Ready, the rollout times out, and + # the symptom reads as a broken application rather than as a policy. + # + # Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind + # the companion trap: once this list is non-empty the rule IS restricted, so + # everything else that needs the port has to be in it. The chart adds the own + # namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] + # The ingress controller allowed to reach the routed port once probeSources + # restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in + # the restricted branch - with the default (open) rule the controller reaches + # the port anyway. The two keys become ONE "from" entry and are therefore an + # AND: pods carrying podLabels IN namespace. That is the point of having them + # at all - the controller's namespace in allowedNamespaces would open the port + # to every pod in kube-system. + # + # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone, + # the only IngressClass is "traefik" and it is the default, and the controller + # is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the + # Gateway API here, so this one source covers the Ingress and the HTTPRoute + # path alike. A policy written against the wrong controller is accepted, looks + # strict, and silently blocks all web traffic - and ingress.className says + # nothing about it, because a class names an implementation, not the pods the + # traffic comes from. + ingressController: + namespace: kube-system + podLabels: + app.kubernetes.io/name: rke2-traefik + egress: + # Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and + # has no switch - a knob whose only other setting breaks every name lookup + # in the pod is a trap, not a feature. + # + # PORTS, NOT DESTINATIONS, and that is a limit of the object rather than + # laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames, + # so "only to the database host" cannot be written at all. Each entry + # permits its port to ANY destination. What the cap still buys is real: a + # compromised pod cannot open arbitrary outbound connections. + # + # TCP ONLY - an alternative WireGuard UDP port cannot be added here. The + # usual entry is an in-cluster service Seerr talks to outside the tunnel + # (Jellyfin on 8096), which also needs gluetun.vpn.firewallOutboundSubnets + # set, or gluetun's own firewall blocks it before this policy is reached. + extraPorts: [] diff --git a/matrix-synapse/Chart.yaml b/matrix-synapse/Chart.yaml index 21099d2..afa2208 100644 --- a/matrix-synapse/Chart.yaml +++ b/matrix-synapse/Chart.yaml @@ -3,7 +3,7 @@ name: matrix-synapse description: A Helm chart to deploy a Matrix Synapse server on Kubernetes icon: https://raw.githubusercontent.com/element-hq/synapse/develop/docs/favicon.png type: application -version: 7.2.0+upv1.159.0 +version: 8.0.0+upv1.159.0 appVersion: "v1.159.0" maintainers: - name: jklein \ No newline at end of file diff --git a/matrix-synapse/templates/NOTES.txt b/matrix-synapse/templates/NOTES.txt index 8ea56da..0786e9f 100644 --- a/matrix-synapse/templates/NOTES.txt +++ b/matrix-synapse/templates/NOTES.txt @@ -396,3 +396,62 @@ matrix.env IS APPENDED, NOT MERGED {{ .Release.Namespace }}. Note that Synapse takes its real configuration from homeserver.yaml, not from the environment. {{- end }} +{{- if .Values.networkPolicy.enabled }} + +NETWORKPOLICY IS ACTIVE — TWO OBJECTS, ONE PER WORKLOAD + {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects the Synapse pods and + {{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy the delegation nginx. They are + separate because their needs are opposite: Synapse federates and must be able + to reach any homeserver on the internet, while the delegation server returns + two static JSON documents and calls nobody. One policy over both pods would + have to permit the union — handing full federation reach and the database port + to an nginx that serves a constant. + + Synapse: inbound TCP 8008 only; outbound DNS, TCP 5432 (Postgres), 443 and + 8448 (federation, remote media, push gateways, identity servers){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP + {{ join ", " . }}{{ end }}{{ end }}. + Delegation nginx: inbound TCP 8080 only; outbound DNS and nothing else — + networkPolicy.egress.extraPorts deliberately does not widen it. + + Three limits, none of them visible in the objects themselves: + + - FEDERATION CANNOT BE NARROWED. 443 and 8448 go to ANY destination, because + a federating homeserver may contact any other homeserver there is, and a + NetworkPolicy matches CIDRs and selectors but never hostnames — with this + cluster's CNI (Canal: Flannel plus Calico) there is no FQDN rule either. + What the cap still buys is real: Synapse can open NO other outbound port, + so a compromised instance reaches no other database, no SSH and no mail + server in the cluster. + - 5432 IS THE STANDARD PORT, NOT YOUR CONFIGURED ONE. Synapse's Postgres + connection lives inside secrets.configSecretRef, which this chart never + parses. A database elsewhere is blocked — put its port in + networkPolicy.egress.extraPorts. The same applies to a TURN server for + voice, or to SMTP if this release sends mail. +{{- if .Values.networkPolicy.probeSources }} + - THE PROBE PATHS ARE RESTRICTED in this release, because + networkPolicy.probeSources is set to + {{ join ", " .Values.networkPolicy.probeSources }} + Probes come from the IP of the WORKER NODE running the pod. If none of those + CIDRs covers the node a pod lands on, its probes are dropped and THE POD + NEVER BECOMES READY — the rollout times out and the symptom looks like a + broken application. + + AND CHECK FEDERATION AFTER THIS CHANGE, not just the web UI. Inbound + federation reaches Synapse through the Ingress, and every remote homeserver + that wants to federate fetches /.well-known/matrix from the delegation + server — so a wrong ingressController here stops federation and stops it + being discoverable, and that failure appears on OTHER people's servers + before it appears on yours. +{{- else }} + - BOTH PROBE PORTS ARE OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is + empty, so neither rule carries a source selector and any pod the CNI routes + to can reach them. Not from the internet — neither port has a NodePort — + but this is NOT pod-level isolation. Narrowing it means listing the worker + node IPs, because the kubelet probes from the node's own address and the + chart cannot know it; a wrong guess leaves the pod permanently un-Ready. +{{- end }} + + Switch both off with networkPolicy.enabled: false. And remember that a policy + is only a boundary where the CNI enforces one — unenforced, both objects are + accepted and filter nothing, so everything reads as configured. +{{- end }} diff --git a/matrix-synapse/templates/matrix-synapse.networkpolicy.yaml b/matrix-synapse/templates/matrix-synapse.networkpolicy.yaml new file mode 100644 index 0000000..79ecf57 --- /dev/null +++ b/matrix-synapse/templates/matrix-synapse.networkpolicy.yaml @@ -0,0 +1,269 @@ +{{- /* +Two NetworkPolicies, one per workload (Gitea #5). + +THIS CHART RENDERS TWO POD TEMPLATES AND THEY NEED DIFFERENT RULES, which is +why this file is written out rather than generated from one shape like most of +the sweep. A single policy selecting both would have to be the UNION of what +each needs - Synapse's federation egress granted to the delegation server, and +the delegation server's ports granted to Synapse - and a policy that is the +union of two workloads' needs is strictly weaker than two policies, while +looking like one decision. + + --networkpolicy the Synapse StatefulSet + --delegation-networkpolicy the delegation nginx Deployment + +The split matters most on egress. Synapse federates: it opens connections to +other people's homeservers, to push gateways and to identity servers, so its +egress rule is necessarily wide. The delegation server serves two static JSON +documents under /.well-known/matrix and calls NOTHING - so its egress is DNS +and nothing else, which is as tight as this object gets. Merging them would +have handed Synapse's reach to an nginx whose entire job is to return a +constant. + +BOTH WORKLOADS SIT BEHIND THE SAME INGRESS, on different paths (/ to Synapse, +/.well-known/matrix to the delegation server), so both carry the ingress +controller in their restricted branch - and both would go dark together if it +were wrong. + +AND BOTH PROBE PORTS STAY OPEN BY DEFAULT, because all three probes of each +workload address its own HTTP port. The kubelet probes from the IP of the +worker node running the pod, which no chart can know; narrowing is +networkPolicy.probeSources, and it applies to both policies at once. + +WHAT THESE POLICIES DO NOT DO: + - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and + selectors and never hostnames, so "only the homeservers we federate with" + cannot be expressed - and with Canal (Flannel plus Calico) there is no FQDN + rule to reach for. Federation therefore means 443 and 8448 to any address, + which is the honest description: Synapse may contact any homeserver on the + internet, because that is what federation is. + - THE DATABASE PORT IS A GUESS. Synapse's Postgres connection lives inside + secrets.configSecretRef, a 1Password item this chart never parses, so 5432 + is the standard port and not the configured one. + - A POLICY IS ONLY A BOUNDARY WHERE THE CNI ENFORCES ONE. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- $ns := .Release.Namespace }} +{{- $probeSources := $np.probeSources | default list }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +{{- $ic := $np.ingressController | default dict }} +{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the Synapse StatefulSet's pods - NOT the delegation Deployment's, + which has its own policy below. ci/run-networkpolicy-test.sh reports which pod + templates each selector matches, so a label renamed in one of the two + workloads shows up as a finding rather than as a policy that filters nothing. + */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + policyTypes: + - Ingress + - Egress + ingress: + {{- /* + 8008 - Synapse's client and federation API, the port the Service forwards 80 + to, and the port all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 8008 + {{- if $probeSources }} + {{- /* + RESTRICTED, because the operator supplied probeSources. The kubelet's + probes come from the IP of the worker node running the pod, so that + address has to be in the list or the pod never becomes Ready - and a pod + that is never Ready reads as a broken application rather than as a policy. + + MIND THE FEDERATION SIDE OF THIS RULE. Inbound federation traffic arrives + through the Ingress, so the controller entry below is what keeps other + homeservers able to reach this one. Restricting this rule without it does + not merely hide a web UI: it stops federation inbound, which looks like + other servers being unreachable rather than like a local policy. + */}} + from: + - namespaceSelector: + matchLabels: + {{- /* + Set by Kubernetes itself on every namespace (>= 1.21), so this + needs no labelling work on a namespace this chart does not own. + */}} + kubernetes.io/metadata.name: {{ $ns }} + {{- if and $routed $ic.namespace }} + {{- /* + ONE from-entry with TWO selectors, which is an AND: pods carrying these + labels IN that namespace. Written as two list entries it would be an OR, + and would open the port to every pod in kube-system. + */}} + - 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, because this port carries no NodePort. The alternative was to + guess the worker node CIDRs, and a wrong guess blocks every kubelet probe: + the pod stays un-Ready, the rollout times out, and the cause looks like + the application. An empty "from: []" would be worse than either - it + allows everything too, but while reading as a restriction. + */}} + {{- end }} + egress: + {{- /* + DNS, unconditionally. There is no value to switch this off, and that is + deliberate: a knob whose only other setting breaks every name lookup in the + pod is a trap and not a feature. Synapse needs it more than most - every + federated server is resolved by name, including its SRV and .well-known + delegation lookups. + */}} + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- /* + PostgreSQL on the STANDARD port. The connection lives inside + secrets.configSecretRef, a 1Password item this chart never parses, so this + is the conventional port and not the configured one - a Postgres elsewhere + is blocked and belongs in networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + FEDERATION, and this is the rule that cannot be narrowed. 443 and 8448 to + ANY destination: 8448 is the Matrix federation port, 443 is where a + delegating homeserver puts its federation API instead, and both are also + what Synapse uses to fetch remote media, to reach a push gateway and to + reach an identity server. + + "Any destination" is not laziness here, it is the specification: a + federating homeserver may contact any other homeserver on the internet, and + this object cannot match a hostname in any case. What the cap still buys is + real - Synapse cannot open a connection on any OTHER port, so a compromised + instance reaches no database, no SSH and no mail server elsewhere in the + cluster. + */}} + - ports: + - protocol: TCP + port: 443 + - protocol: TCP + port: 8448 + {{- with $np.egress }} + {{- with .extraPorts }} + {{- /* + Ports only, to any destination - see the header. The operator's escape hatch + from the egress cap, and the only alternative to switching the whole policy + off. + */}} + - ports: + {{- range . }} + - protocol: TCP + port: {{ int . }} + {{- end }} + {{- end }} + {{- end }} +--- +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-networkpolicy +spec: + {{- /* Selects the delegation nginx Deployment's pods only. */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-delegation-nginx-deployment + policyTypes: + - Ingress + - Egress + ingress: + {{- /* + 8080 - the only port this pod declares, the port the delegation Service + forwards 80 to, and the port all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 8080 + {{- if $probeSources }} + {{- /* + RESTRICTED, because the operator supplied probeSources - the same value + governs both policies in this chart. The kubelet probes from the node's + own IP, so it has to be in the list or this pod never becomes Ready. + + AND THE CONSUMERS OF THIS ONE ARE OTHER PEOPLE'S HOMESERVERS. The + delegation documents under /.well-known/matrix are fetched by every remote + server that wants to federate with this one, through the Ingress. Restrict + this rule without the controller entry and federation stops being + discoverable - a failure that shows up on OTHER servers, not here. + */}} + from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: {{ $ns }} + {{- if and $routed $ic.namespace }} + - 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 }} + {{- /* Open inside the cluster, as above - the probe path. */}} + {{- end }} + egress: + {{- /* + DNS AND NOTHING ELSE, and this is the tightest egress rule in the chart on + purpose. This nginx serves two static JSON documents and calls nobody; it + does not touch the database, it does not federate, it has no upstream. Even + DNS is only here because the container resolves at start. + + This is exactly what the two-policy split buys. Folded into Synapse's + policy, this pod would have been permitted 443, 8448 and 5432 - full + federation reach and the database - for a workload whose entire job is to + return a constant. networkPolicy.egress.extraPorts deliberately does NOT + apply here: an entry meant for Synapse's database has no business widening + this. + */}} + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 +{{- end }} diff --git a/matrix-synapse/values.schema.json b/matrix-synapse/values.schema.json index a26e4a0..cdfaf9f 100644 --- a/matrix-synapse/values.schema.json +++ b/matrix-synapse/values.schema.json @@ -9,6 +9,57 @@ "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.", "type": "object" }, + "networkPolicy": { + "description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "allowedNamespaces": { + "description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "probeSources": { + "description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "ingressController": { + "description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "namespace": { + "type": ["string", "null"] + }, + "podLabels": { + "description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.", + "type": ["object", "null"] + } + } + }, + "egress": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "extraPorts": { + "description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.", + "type": ["array", "null"], + "items": { + "type": "integer" + } + } + } + } + } + }, "scaleDown": { "type": "boolean" }, diff --git a/matrix-synapse/values.yaml b/matrix-synapse/values.yaml index 4345a94..97782c2 100644 --- a/matrix-synapse/values.yaml +++ b/matrix-synapse/values.yaml @@ -285,3 +285,99 @@ httpRoute: # A chart-global hostname list covering both would erase that distinction. # Set this only when the server name itself has to move. hostnames: [] + +# --------------------------------------------------------------------------- +# NetworkPolicy (Gitea #5). On by default. TWO objects, one per workload - see +# the template for why they are not one. The reference implementation with the +# full reasoning is template-chart; the convention is in README, "Chart style +# guidelines". +# --------------------------------------------------------------------------- +networkPolicy: + # WHAT THESE POLICIES ALLOW. Synapse: inbound 8008 only, outbound DNS plus TCP + # 5432 (Postgres), 443 and 8448 (federation, remote media, push gateways, + # identity servers). The delegation nginx: inbound 8080 only, outbound DNS and + # NOTHING else - it serves two static JSON documents and calls nobody. + # + # THAT ASYMMETRY IS THE REASON THERE ARE TWO. One policy selecting both pods + # would have to permit the union of what each needs, which would hand + # Synapse's full federation reach and its database port to an nginx whose + # whole job is to return a constant - and it would look like one decision + # rather than a widening. + # + # WHAT THEY DO NOT DO: federation cannot be narrowed. 443 and 8448 go to ANY + # destination, because a federating homeserver may contact any other + # homeserver on the internet and this object matches CIDRs and selectors but + # never hostnames. And 5432 is the STANDARD Postgres port, not the configured + # one - the connection lives inside secrets.configSecretRef, which this chart + # never parses. + # + # Both probe ports stay open inside the cluster by default (see probeSources), + # and a policy is only a boundary where the CNI enforces one. + enabled: true + # Namespaces whose pods may reach either workload, in addition to the + # release's own namespace, which is always allowed. Empty means: own namespace + # only. Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on + # every namespace itself, so no labelling work is needed - and no typo + # protection either: a name that does not exist matches nothing and is + # silently ineffective. An entry opens the port to EVERY pod in that + # namespace. + allowedNamespaces: [] + # Source CIDRs allowed to reach the two probe ports (Synapse's 8008 and the + # delegation server's 8080). ONE VALUE GOVERNS BOTH POLICIES. + # + # EMPTY MEANS OPEN, NOT BLOCKED. With no entry both rules are rendered WITHOUT + # a "from" selector, so the ports are reachable from anywhere the CNI routes + # to - inside the cluster, since neither carries a NodePort. + # + # The kubelet's probes originate from the IP of the WORKER NODE running the + # pod, an address this chart cannot know and must not guess: a CIDR that + # excludes the real node blocks every probe, the pod never becomes Ready, the + # rollout times out, and the symptom reads as a broken application rather + # than as a policy. + # + # AND MIND WHAT ELSE THESE TWO PORTS CARRY. Inbound federation arrives at + # 8008 through the Ingress, and every remote homeserver that wants to + # federate fetches /.well-known/matrix from 8080. Narrowing this list without + # ingressController below therefore does not merely hide a web UI: it stops + # federation, inbound and discoverable, and the failure shows up on OTHER + # people's servers before it shows up here. + probeSources: [] + # The ingress controller allowed to reach both ports once probeSources + # restricts them. Rendered only while ingress.enabled or httpRoute.enabled is + # true, and only in the restricted branch - with the default (open) rules the + # controller reaches both ports anyway. + # + # The two keys become ONE "from" entry and are therefore an AND: pods carrying + # podLabels IN namespace. That is the point of having them here - the + # controller's namespace in allowedNamespaces would open both ports to every + # pod in kube-system. + # + # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone, + # the only IngressClass is "traefik" and it is the default, and the controller + # is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the + # Gateway API here, so this one source covers the Ingress and the HTTPRoute + # path alike. A policy written against the wrong controller is accepted, looks + # strict, and silently blocks all web traffic - and ingress.className says + # nothing about it, because a class names an implementation, not the pods the + # traffic comes from. + ingressController: + namespace: kube-system + podLabels: + app.kubernetes.io/name: rke2-traefik + egress: + # Additional outbound TCP ports FOR SYNAPSE ONLY. These deliberately do NOT + # widen the delegation server's policy: an entry meant for Synapse's + # database has no business reaching a workload that serves a constant. + # + # DNS is always allowed and has no switch - a knob whose only other setting + # breaks every name lookup is a trap, not a feature, and Synapse resolves a + # name for every server it federates with. + # + # PORTS, NOT DESTINATIONS: a NetworkPolicy matches CIDRs and selectors and + # never hostnames, so each entry permits its port to ANY destination. + # + # The usual entries here: a Postgres that is not on 5432, a TURN/coturn + # server for voice (3478 and its relay range), or SMTP (587 or 465) if this + # release sends mail - Synapse's mail configuration lives inside + # secrets.configSecretRef, which this chart never reads. + extraPorts: [] diff --git a/paperless-ngx/Chart.yaml b/paperless-ngx/Chart.yaml index 1ee75b9..561e022 100644 --- a/paperless-ngx/Chart.yaml +++ b/paperless-ngx/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/dev/src-ui/s type: application -version: 13.1.0+up3.3.1 +version: 14.0.0+up3.3.1 appVersion: 3.3.1 diff --git a/paperless-ngx/templates/NOTES.txt b/paperless-ngx/templates/NOTES.txt index 68defad..0c46e49 100644 --- a/paperless-ngx/templates/NOTES.txt +++ b/paperless-ngx/templates/NOTES.txt @@ -531,3 +531,66 @@ BACKUP IS TWO THINGS AT THE SAME TIME paperless.env adds {{ len .Values.paperless.env }} variable(s), rendered after the ones the chart sets itself, so they win on a name the chart also sets. {{- end }} +{{- if .Values.networkPolicy.enabled }} + +NETWORKPOLICY IS ACTIVE — WHAT IT CAPS, AND WHAT IT DOES NOT + {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy selects this workload's pods. + Inbound: TCP 8000 only. Outbound: DNS, TCP 5432 (Postgres), 6379 + (Redis/Valkey), 443{{ if .Values.paperless.files.tikaGotenberg.enabled }}, 80 (the Tika and Gotenberg Services){{ end }}{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP + {{ join ", " . }}{{ end }}{{ end }}. + + MAIL FETCHING IS NOT PERMITTED. Paperless' mail accounts are rows in its + database, configured in the web UI, so this chart cannot see them. A release + that fetches mail needs IMAP (993, or 143) in + networkPolicy.egress.extraPorts — otherwise a mail rule silently stops + importing, with a healthy pod behind it. + + AND TWO PODS OF THIS RELEASE ARE NOT COVERED BY THIS OBJECT: + - the valkey subchart's pod, which carries its own labels. It needs no + cover from here: the valkey chart ships its own NetworkPolicy, and a + tighter one. + - the consume CronJob's pods, whose pod template carries NO labels at all, + so no podSelector can match them. They move files between mounted + volumes and reach the SMB share through a CSI volume the NODE mounts, so + the pod opens no connection of its own — but the gap is real and is named + here rather than left to be discovered. + + Three limits belong in the same breath, because none of them is visible in + the object itself: + + - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and + selectors and cannot express a hostname, so every egress rule permits its + port to ANY address. That is a limit of the object, not a shortcut — this + cluster's CNI (Canal: Flannel plus Calico) has no FQDN rule either. An + outbound port the chart does not know about is BLOCKED: add it to + networkPolicy.egress.extraPorts, and expect to find out through an + application-level connection error rather than through this object. +{{- if .Values.networkPolicy.probeSources }} + - THE PROBE PATH IS RESTRICTED in this release, because + networkPolicy.probeSources is set to + {{ join ", " .Values.networkPolicy.probeSources }} + Probes come from the IP of the WORKER NODE running the pod. If none of those + CIDRs covers the node this pod lands on, the kubelet's probes are dropped + and THE POD NEVER BECOMES READY — the rollout then times out and the symptom + looks like a broken application. Compare + + kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-sfs + + against `kubectl get node -o wide`. +{{- else }} + - THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is + empty, so that rule carries no source selector at all and any pod the CNI + routes to can reach it. Not from the internet — but this is NOT pod-level + isolation. Narrowing it means listing the worker node IPs in + networkPolicy.probeSources, because the kubelet probes from the node's own + address and the chart cannot know it; a wrong guess there leaves the pod + permanently un-Ready. +{{- end }} + - IT IS ONLY A BOUNDARY WHERE THE CNI ENFORCES POLICIES. Where it does not, + this object is accepted and filters nothing, so everything reads as + configured while nothing is filtered. `kubectl -n {{ .Release.Namespace }} get networkpolicy` + shows the object exists; only a connection attempt from an unrelated + namespace shows that it bites. + + Switch it off with networkPolicy.enabled: false. +{{- end }} diff --git a/paperless-ngx/templates/paperless-server.networkpolicy.yaml b/paperless-ngx/templates/paperless-server.networkpolicy.yaml new file mode 100644 index 0000000..c9a6619 --- /dev/null +++ b/paperless-ngx/templates/paperless-server.networkpolicy.yaml @@ -0,0 +1,223 @@ +{{- /* +NetworkPolicy for paperless-ngx (Gitea #5). + +Paperless reaches a Postgres and a Redis, and - when +paperless.files.tikaGotenberg.enabled is true - the Tika and Gotenberg services, +which in this repository are two other charts whose Services listen on port 80. +That last rule is derived from the switch rather than always rendered, so a +release without Tika does not carry a rule that permits nothing. + +TWO THINGS THIS POLICY DOES NOT COVER, and both are real rather than +theoretical: + +1. THE VALKEY SUBCHART'S POD. This chart declares valkey as an optional + dependency, and the subchart renders its own StatefulSet with its own + labels. It is not covered by THIS policy - but it does not need to be: the + valkey chart ships a NetworkPolicy of its own, and it is the tighter one + (Ingress-only, 6379 restricted by source, because its probes are exec probes + and no kubelet traffic has to be let through). This is the one subchart in + the repository that brings its own policy. + +2. THE CONSUME CRONJOB'S PODS. paperless-consume.cronjob.yaml renders a pod + template with NO LABELS AT ALL, so no podSelector can match it and no policy + in this chart can restrict it. That is a gap in the convention, named here + rather than glossed. What makes it tolerable: the CronJob's whole job is to + move files between two mounted volumes, and the SMB share reaches it as a + CSI volume - mounted by the NODE, not opened by the pod - so the pod itself + opens no connection that a policy would have anything to say about. If that + ever changes, the CronJob needs labels before it can need a policy. + +3. MAIL FETCHING IS NOT PERMITTED. Paperless' mail accounts live in its + DATABASE, configured through the web UI, so this chart cannot see them. A + release that fetches mail needs IMAP (993 or 143) in + networkPolicy.egress.extraPorts, and the symptom otherwise is a mail rule + that silently stops importing. + +WHAT THIS OBJECT IS: a ceiling on reachability, not an authentication layer. +Only the ports declared below are reachable at all - whatever else the process +binds, now or after the next app update, is blocked without anyone having to +notice it exists. + +WHAT IT IS NOT: + - EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER + hostnames, so "only to the database host" cannot be expressed at all. This + cluster's CNI is Canal (Flannel plus Calico for policy) and has no FQDN + rule; Calico's GlobalNetworkSet could express one but would bind this chart + to Calico, the same argument that kept implementation-specific objects out + of the Gateway-API sweep (#114). Each egress rule therefore permits its + PORT to any destination. + - NOT A BOUNDARY WHERE THE CNI DOES NOT ENFORCE ONE. An unenforced policy is + accepted by the API server and filters nothing, so everything reads as + configured while nothing is filtered. + - THE REASONING IS IN TEMPLATE COMMENTS, not in "#" comments: those are part + of the rendered object, travel into the cluster and appear in every release + diff. ci/run-networkpolicy-test.sh fails on a rendered "#". + +THE PORT NUMBERS BELOW ARE POD PORTS, NEVER NODEPORTS. A NetworkPolicy is +evaluated at the pod's network interface, after kube-proxy has already +translated a NodePort to the targetPort, so a NodePort number here would match +nothing while looking strict - and, being the only rule for that port, would +block every inbound connection instead. ci/run-networkpolicy-test.sh asserts +every port here against the containerPorts of the pod spec this policy +selects. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- $ns := .Release.Namespace }} +{{- $probeSources := $np.probeSources | default list }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +{{- $ic := $np.ingressController | default dict }} +{{- $routed := or .Values.ingress.enabled .Values.httpRoute.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the paperless server's StatefulSet pods. Note that this render may + contain the valkey subchart's pods as well - this selector must not match them, + and ci/run-networkpolicy-test.sh reports which pod templates it does match so + that is visible rather than assumed. + */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + policyTypes: + - Ingress + {{- /* + Egress is listed because there is a lot to restrict and most of it is known. + What is NOT permitted and is the plausible surprise: IMAP. Paperless' mail + accounts are rows in its database, configured in the web UI, so the chart + cannot see them - see networkPolicy.egress.extraPorts. + */}} + - Egress + ingress: + {{- /* + 8000 - the only port this pod declares, the one the Service forwards 80 to, and + the port all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 8000 + {{- if $probeSources }} + {{- /* + RESTRICTED, because the operator supplied probeSources. The kubelet's probes + come from the IP of the WORKER NODE running the pod, so that address has to be + in the list or the pod never becomes Ready - and a pod that is never Ready + reads as a broken application rather than as a policy. + + The ingress controller is added to the same rule, or the Ingress goes dark the + moment this list is filled. + */}} + from: + - namespaceSelector: + matchLabels: + {{- /* + Set by Kubernetes itself on every namespace (>= 1.21), so this needs no + labelling work on a namespace this chart does not own. + */}} + kubernetes.io/metadata.name: {{ $ns }} + {{- if and $routed $ic.namespace }} + {{- /* + ONE from-entry with TWO selectors, which is an AND: pods carrying these labels + IN that namespace. Written as two list entries it would be an OR, and would + open the port to every pod in kube-system. + */}} + - 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, because this port carries no NodePort. The alternative was to guess + the worker node CIDRs, and a wrong guess blocks every kubelet probe: the pod + stays un-Ready, the rollout times out, and the cause looks like the + application. An empty "from: []" would be worse than either - it allows + everything too, but while reading as a restriction. Narrow it with + networkPolicy.probeSources. + */}} + {{- end }} + egress: + {{- /* + DNS, unconditionally. There is no value to switch this off, and that is + deliberate: a knob whose only other setting breaks every name lookup in + the pod is a trap and not a feature. + */}} + - ports: + - protocol: UDP + port: 53 + - protocol: TCP + port: 53 + {{- /* + PostgreSQL on the STANDARD port. The connection details live in a 1Password + item this chart never parses, so this is the conventional port and not the + configured one; a Postgres elsewhere is blocked and belongs in + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Redis/Valkey on the STANDARD port, same reasoning. This covers the bundled + valkey subchart as well as an external one - the subchart's Service uses 6379 + too. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- if .Values.paperless.files.tikaGotenberg.enabled }} + {{- /* + TIKA AND GOTENBERG, on port 80 - which is what their Services listen on in this + repository, even though their containers use 9998 and 3000. That distinction is + the whole reason this rule says 80: an EGRESS rule names the port on the + DESTINATION as the client dials it, and the client dials the Service. + + Rendered only while paperless.files.tikaGotenberg.enabled is true, so a release + without them carries no rule that permits nothing. Endpoints on another port - + the containers' own 9998 and 3000, if addressed directly rather than through + their Services - need networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 80 + {{- end }} + {{- /* + Outbound HTTPS, for the trained classifier's model downloads and for any + remote resource a consumed document pulls. To any destination, because those + are hostnames. + */}} + - ports: + - protocol: TCP + port: 443 + {{- with $np.egress }} + {{- with .extraPorts }} + {{- /* + Ports only, to any destination - see the header. This is the operator's + escape hatch from the egress cap, and the only alternative to switching + the whole policy off. + */}} + - ports: + {{- range . }} + - protocol: TCP + port: {{ int . }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/paperless-ngx/values.schema.json b/paperless-ngx/values.schema.json index dffd273..efd1364 100644 --- a/paperless-ngx/values.schema.json +++ b/paperless-ngx/values.schema.json @@ -51,6 +51,57 @@ "description": "Helm's conventional cross-chart values. Unused by this chart, kept open so an umbrella install cannot break on it.", "type": "object" }, + "networkPolicy": { + "description": "The chart's NetworkPolicy (Gitea #5), on by default. A ceiling on reachability and not an authentication layer: only the declared container ports are reachable at all, egress is capped by PORT (a NetworkPolicy matches CIDRs and selectors, never hostnames, so a destination host cannot be expressed), and ingress is capped by source for every port no network probe touches. Only a boundary where the cluster's CNI enforces policies - an unenforced policy is accepted by the API server and filters nothing. The template's header carries the full list of what it does not do.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "allowedNamespaces": { + "description": "Namespaces whose pods may reach this workload, in addition to the release's own namespace, which is always allowed. Matched on the kubernetes.io/metadata.name label Kubernetes sets on every namespace itself, so no labelling work is needed - and no typo protection either: a name that does not exist matches nothing and is silently ineffective. An entry opens the port to EVERY pod in that namespace; for the ingress controller use ingressController instead.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "probeSources": { + "description": "Source CIDRs allowed to reach the port the network probes address. EMPTY MEANS OPEN, not blocked: with no entry that rule is rendered without a \"from\" selector. The kubelet's probes come from the IP of the worker node running the pod, an address the chart cannot know and must not guess - a guessed CIDR that excludes the real node blocks every probe and the pod never becomes Ready, which reads as a broken application rather than as a policy. Setting this restricts the rule, which is why the chart adds the own namespace, ingressController and allowedNamespaces to the same rule.", + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + "ingressController": { + "description": "The ingress controller allowed to reach the routed port once probeSources restricts it; rendered only while ingress.enabled or httpRoute.enabled is true. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is the whole point: the controller's namespace in allowedNamespaces would open the port to every pod in kube-system. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "namespace": { + "type": ["string", "null"] + }, + "podLabels": { + "description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.", + "type": ["object", "null"] + } + } + }, + "egress": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "extraPorts": { + "description": "Additional outbound TCP ports, to ANY destination - ports and not destinations, because a NetworkPolicy cannot express a hostname. DNS is always allowed and has no switch. Without an entry here an outbound port the chart does not already derive is blocked, which is the cap's whole purpose and also its upgrade risk.", + "type": ["array", "null"], + "items": { + "type": "integer" + } + } + } + } + } + }, "scaleDown": { "type": "boolean" }, diff --git a/paperless-ngx/values.yaml b/paperless-ngx/values.yaml index 9282567..165efca 100644 --- a/paperless-ngx/values.yaml +++ b/paperless-ngx/values.yaml @@ -540,3 +540,100 @@ valkey: # And one rule that is about this repository rather than about an installation: # changes to valkey/ must not share a pull request with changes to this chart. # Chart.yaml says why; README and the wiki's valkey page carry the long form. + +# --------------------------------------------------------------------------- +# NetworkPolicy (Gitea #5). On by default. The reference implementation with +# the full reasoning is template-chart; the convention is in README, "Chart +# style guidelines", and the operator's view on this chart's wiki page. +# --------------------------------------------------------------------------- +networkPolicy: + # WHAT THIS POLICY ALLOWS: inbound 8000 only, and outbound DNS plus TCP 5432 + # (Postgres), 6379 (Redis/Valkey), 443, and - only while + # paperless.files.tikaGotenberg.enabled is true - 80 for the Tika and + # Gotenberg Services. + # + # NOTE WHY THAT RULE SAYS 80 AND NOT 9998/3000: an egress rule names the port + # on the DESTINATION as the client dials it, and paperless dials those + # charts' SERVICES, which listen on 80 and forward to the containers' own + # ports. + # + # THREE THINGS IT DOES NOT COVER: + # - The VALKEY SUBCHART'S POD. It carries its own labels, so this policy + # does not select it - and it does not need to: the valkey chart ships a + # NetworkPolicy of its own, and a tighter one. + # - The CONSUME CRONJOB'S PODS. Their pod template carries NO LABELS, so no + # podSelector can match them and nothing here restricts them. Tolerable + # only because the job moves files between mounted volumes and the SMB + # share arrives as a CSI volume mounted by the NODE - the pod opens no + # connection of its own. If that changes, the CronJob needs labels first. + # - MAIL FETCHING. Paperless' mail accounts are rows in its database, + # configured in the web UI, so this chart cannot see them. A release that + # fetches mail needs IMAP in egress.extraPorts, or a mail rule silently + # stops importing. + # + # And the usual two: 5432 and 6379 are the STANDARD ports rather than the + # configured ones, and a policy is only a boundary where the CNI enforces + # one. + enabled: true + # Namespaces whose pods may reach this workload, in addition to the release's + # own namespace, which is always allowed. Empty means: own namespace only. + # + # Matched on the "kubernetes.io/metadata.name" label Kubernetes sets on every + # namespace itself, so no labelling work is needed on a namespace this chart + # does not own - and no typo protection either: a name that does not exist + # matches nothing and is silently ineffective. Note the granularity: an entry + # opens the port to EVERY pod in that namespace. + allowedNamespaces: [] + # Source CIDRs allowed to reach port 8000, which is the port all three probes address. + # + # EMPTY MEANS OPEN, NOT BLOCKED. With no entry that rule is rendered WITHOUT a + # "from" selector, so the port is reachable from anywhere the CNI routes to - + # inside the cluster, since it carries no NodePort. + # + # That is the deliberate choice between two bad defaults. The kubelet's probes + # originate from the IP of the WORKER NODE running the pod, an address this + # chart cannot know and must not guess: a CIDR that excludes the real node + # blocks every probe, the pod never becomes Ready, the rollout times out, and + # the symptom reads as a broken application rather than as a policy. + # + # Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32"]) - and mind + # the companion trap: once this list is non-empty the rule IS restricted, so + # everything else that needs the port has to be in it. The chart adds the own + # namespace, the ingressController below and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] + # The ingress controller allowed to reach the routed port once probeSources + # restricts it. Rendered only while ingress.enabled or httpRoute.enabled is true, and only in + # the restricted branch - with the default (open) rule the controller reaches + # the port anyway. The two keys become ONE "from" entry and are therefore an + # AND: pods carrying podLabels IN namespace. That is the point of having them + # at all - the controller's namespace in allowedNamespaces would open the port + # to every pod in kube-system. + # + # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is gone, + # the only IngressClass is "traefik" and it is the default, and the controller + # is the DaemonSet rke2-traefik in kube-system. The same Traefik serves the + # Gateway API here, so this one source covers the Ingress and the HTTPRoute + # path alike. A policy written against the wrong controller is accepted, looks + # strict, and silently blocks all web traffic - and ingress.className says + # nothing about it, because a class names an implementation, not the pods the + # traffic comes from. + ingressController: + namespace: kube-system + podLabels: + app.kubernetes.io/name: rke2-traefik + egress: + # Additional outbound TCP ports. DNS (UDP and TCP 53) is always allowed and + # has no switch - a knob whose only other setting breaks every name lookup + # in the pod is a trap, not a feature. + # + # PORTS, NOT DESTINATIONS, and that is a limit of the object rather than + # laziness: a NetworkPolicy matches CIDRs and selectors and never hostnames, + # so "only to the database host" cannot be written at all. Each entry + # permits its port to ANY destination. What the cap still buys is real: a + # compromised pod cannot open arbitrary outbound connections. + # + # IMAP (993, or 143 for the plaintext case) is the entry this chart needs + # most, for mail accounts configured in the web UI. Also a Postgres or + # Redis on a non-standard port, or a Tika/Gotenberg addressed on its + # container port (9998 / 3000) instead of through its Service. + extraPorts: [] diff --git a/stalwart/Chart.yaml b/stalwart/Chart.yaml index 90a3137..736e150 100644 --- a/stalwart/Chart.yaml +++ b/stalwart/Chart.yaml @@ -28,7 +28,7 @@ type: application # ci/stalwart/test-values.yaml as it stands on origin/main - measured, both # empty diffs. New optional values with unchanged defaults is exactly the minor # case (README, "Versioning"). -version: 1.3.0+upv0.16.25 +version: 2.0.0+upv0.16.25 appVersion: "v0.16.25" maintainers: diff --git a/stalwart/templates/stalwart.networkpolicy.yaml b/stalwart/templates/stalwart.networkpolicy.yaml index ea9e681..464f71e 100644 --- a/stalwart/templates/stalwart.networkpolicy.yaml +++ b/stalwart/templates/stalwart.networkpolicy.yaml @@ -1,15 +1,41 @@ {{- /* -One NetworkPolicy per node group. +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. +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 !!! +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 @@ -17,6 +43,8 @@ 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 @@ -32,6 +60,7 @@ unenforced policy is accepted and filters nothing. {{- $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 }} --- @@ -49,9 +78,11 @@ spec: - 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. */}} + {{- /* + 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 }} @@ -61,7 +92,10 @@ spec: - ports: {{- range $portName := $publicPorts }} {{- $port := index $ports $portName }} - # {{ $portName }}: published on nodePort {{ int $port.nodePort }}, reaching the pod on {{ int $port.containerPort }} + {{- /* + {{ $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 }} @@ -72,17 +106,38 @@ spec: - 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. + {{- /* + 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. + {{- /* + 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: @@ -93,12 +148,18 @@ spec: 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 (and then .allowedNamespaces for the - # ingress controller, or the Ingress goes dark). + {{- /* + 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 }} @@ -109,10 +170,12 @@ spec: 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. + {{- /* + 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 @@ -125,8 +188,12 @@ spec: {{- 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. + {{- /* + 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 diff --git a/stalwart/values.schema.json b/stalwart/values.schema.json index 998bbe2..6a51942 100644 --- a/stalwart/values.schema.json +++ b/stalwart/values.schema.json @@ -304,6 +304,20 @@ "description": "On by default. Only a real boundary where the cluster's CNI enforces NetworkPolicies; an unenforced policy is accepted by the API server and filters nothing.", "type": "boolean" }, + "ingressController": { + "description": "The ingress controller allowed to reach the http port once probeSources or allowedNamespaces restricts it; with neither set the http rule carries no source selector and the controller reaches the port anyway. The two keys become ONE \"from\" entry and are therefore an AND - pods carrying podLabels IN namespace - which is why this is separate from the namespace-granular allowedNamespaces. The default is measured on this cluster (DaemonSet rke2-traefik in kube-system, which also serves the Gateway API here), not assumed; a policy written against the wrong controller is accepted, looks strict and silently blocks all web traffic.", + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "namespace": { + "type": ["string", "null"] + }, + "podLabels": { + "description": "Label set the controller's pods carry, rendered verbatim as a podSelector's matchLabels. Kept open because the chart does not interpret the keys.", + "type": ["object", "null"] + } + } + }, "probeSources": { "description": "Source CIDRs allowed to reach the http port, where the kubelet's probe traffic has to be permitted - probes come from the IP of the worker node running the pod. EMPTY MEANS OPEN (the rule renders without a 'from' selector), which is the deliberate choice: a guessed CIDR that misses the real node addresses blocks every probe, and a pod that never becomes Ready reads as a broken application rather than as a policy.", "type": ["array", "null"], diff --git a/stalwart/values.yaml b/stalwart/values.yaml index 347d104..f95a308 100644 --- a/stalwart/values.yaml +++ b/stalwart/values.yaml @@ -504,16 +504,43 @@ networkPolicy: # (e.g. ["10.0.1.11/32", "10.0.1.12/32"]). # # And mind the companion trap: once this list is non-empty the http rule is - # restricted, which cuts off the ingress controller too unless its namespace - # is in allowedNamespaces below. + # restricted, which cuts off everything else that needs the port. The chart + # adds the release's own namespace, ingressController below and + # allowedNamespaces to the same rule for exactly that reason. probeSources: [] # Namespaces allowed to reach the http port, matched on the # kubernetes.io/metadata.name label Kubernetes sets on every namespace - # itself. The release's own namespace is always allowed. Put the ingress - # controller's namespace here (e.g. ["ingress-nginx"]) as soon as - # probeSources is set. A name that does not exist matches nothing and is - # silently ineffective. + # itself. The release's own namespace is always allowed. A name that does not + # exist matches nothing and is silently ineffective. + # + # NOT FOR THE INGRESS CONTROLLER - use ingressController below. This value is + # namespace-granular, so naming the controller's namespace here would open + # the http port to EVERY pod in it (kube-system, in this cluster). The + # earlier version of this comment advised exactly that, with + # ["ingress-nginx"] as the example, and both halves of that advice are now + # wrong: see ingressController. allowedNamespaces: [] + # The ingress controller allowed to reach the http port once probeSources or + # allowedNamespaces restricts it. With neither set the http rule carries no + # source selector at all and the controller reaches the port anyway, so this + # value only becomes load-bearing at the moment one of them is filled - which + # is also the moment the Ingress would otherwise go dark. + # + # The two keys are rendered as ONE "from" entry, which makes them an AND: + # pods carrying podLabels IN namespace. That is the whole point of having + # them separate from allowedNamespaces. + # + # MEASURED ON THIS CLUSTER, NOT ASSUMED (2026-10-14): ingress-nginx is GONE - + # no DaemonSet, no pods - the only IngressClass is "traefik" and it is the + # default, and the controller is the DaemonSet rke2-traefik in kube-system. + # The same Traefik serves the Gateway API here as well. A policy written + # against the wrong controller is accepted, looks strict, and silently blocks + # all web traffic; ingress.className says nothing about it, because a class + # names an implementation and not the pods the traffic comes from. + ingressController: + namespace: kube-system + podLabels: + app.kubernetes.io/name: rke2-traefik # Egress is restricted BY PORT, not by destination, and the reason is a limit # of the object rather than laziness: a NetworkPolicy matches ipBlock CIDRs # and pod/namespace selectors, never hostnames — so dataStore.postgres.host diff --git a/valkey/Chart.yaml b/valkey/Chart.yaml index 2325787..a2109b6 100644 --- a/valkey/Chart.yaml +++ b/valkey/Chart.yaml @@ -26,7 +26,7 @@ type: application # running image with no change in this repository and nothing reports it. The # remedy if that ever matters is pinDigests for this one image - see the valkey # block in renovate.json5. -version: 1.0.0+up9.1.2.alpine +version: 2.0.0+up9.1.2.alpine appVersion: "9.1.2-alpine" maintainers: diff --git a/valkey/templates/valkey.networkpolicy.yaml b/valkey/templates/valkey.networkpolicy.yaml index 44fc14b..e72a2a1 100644 --- a/valkey/templates/valkey.networkpolicy.yaml +++ b/valkey/templates/valkey.networkpolicy.yaml @@ -1,27 +1,64 @@ +{{- /* +NetworkPolicy for valkey (Gitea #5). + +THIS CHART AND stalwart ARE THE TWO THAT HAD A POLICY BEFORE #5, so the +convention was derived from them rather than imposed on them. What changed here +is the FORM and not a single rule: the reasoning moved out of "#" comments, +which are part of the rendered object and travel into the cluster, into template +comments, which render nothing. ci/run-networkpolicy-test.sh now fails on a +rendered "#", and this file was one of the two that failed it. + +TWO THINGS THAT DELIBERATELY DID NOT CHANGE, because they are right and the +convention was written to accommodate them: + +1. INGRESS ONLY. This instance opens no outbound connection of its own - no + replication, no cluster bus - so an egress rule here would constrain nothing + while making the object look stricter than it is. policyTypes names only + what there is something to restrict. homeassistant and satisfactory invoke + the same rule in the other direction, for outbound traffic nobody can + enumerate. + +2. 6379 IS RESTRICTED BY SOURCE, with no probeSources value anywhere in this + chart - and that combination is only safe because of a detail worth knowing: + ALL THREE PROBES HERE ARE EXEC PROBES. An exec probe runs inside the + container through the kubelet's runtime and puts no packet on the network, so + there is no kubelet traffic to let through and nothing to accommodate. A + chart with an httpGet or tcpSocket probe on this port could NOT restrict it + this way - it would need the worker node's IP, which no chart can know, and + the pod would never become Ready without it. minecraft-server is tight for + the same reason; every web app in the sweep is not. + +So this policy is what the rest of the rollout aims at and mostly cannot reach: +a port that is genuinely closed to everything but the namespaces named below. +*/ -}} {{- if .Values.networkPolicy.enabled }} apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy spec: podSelector: matchLabels: app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs - # Ingress only. Egress is deliberately unrestricted: this instance opens no - # outbound connection of its own (no replication, no cluster bus), so an - # egress rule here would constrain nothing while making the object look - # stricter than it is. policyTypes: - Ingress ingress: - # One rule with a list of sources. Note the shape: an empty "from" would - # allow EVERYTHING, so the namespaces are always spelled out - the release's - # own namespace first, then whatever networkPolicy.allowedNamespaces adds. + {{- /* + One rule with a list of sources. Note the shape: an empty "from" would allow + EVERYTHING, so the namespaces are always spelled out - the release's own + namespace first, then whatever networkPolicy.allowedNamespaces adds. An + empty "from: []" is worse still, because it allows everything while reading + as a restriction; ci/run-networkpolicy-test.sh rejects that form outright. + */}} - from: - namespaceSelector: matchLabels: - # Set by Kubernetes itself on every namespace (>= 1.21), so this - # needs no labelling work on the target namespaces. + {{- /* + Set by Kubernetes itself on every namespace (>= 1.21), so this + needs no labelling work on the target namespaces. + */}} kubernetes.io/metadata.name: {{ .Release.Namespace }} {{- range .Values.networkPolicy.allowedNamespaces }} - namespaceSelector: