From f775e451d1ff6fe3f6c314fdf26e0238c823a8ca Mon Sep 17 00:00:00 2001 From: Justin Klein Date: Wed, 14 Oct 2026 21:21:24 +0000 Subject: [PATCH] NetworkPolicy follow-up (Gitea #5): the gotenberg switch, and three things that were load-bearing and written nowhere Chart-Style-Guide, gotenberg and keycloak. THE CONVENTION SENTENCE that explains the whole shape of this rollout, and was missing: no chart ships allowedNamespaces pre-filled, ever. A chart naming mc-router or stalwart in its defaults would bake in an assumption about this cluster's namespace layout, which a chart cannot keep true. Restrictive is the default; filling it is the DEPLOYMENT adaptation, made where the namespace layout is visible. So the release-side one-liners this sweep produces are the expected procedure rather than a defect list, and the major on each chart is what announces them. ingressController is the single exception, because it names a cluster component and not a tenant. THE ONE CLUSTER SETTING THIS CONVENTION DEPENDS ON AND CANNOT CHECK: Traefik must not run with hostNetwork. Measured 2026-10-14 - the rke2-traefik DaemonSet has none and its pods carry real pod IPs (10.42.3.120, 10.42.4.94, 10.42.0.187 in the pod CIDR 10.42.0.0/16, while the nodes sit on entirely different addresses), and only because of that does a podSelector match it. On host networking those pods share the node's network namespace, stop being selectable as pods, the ingressController entry matches nothing, and every web chart in the repository goes dark at once - while every policy is still accepted and still reads as correct. The symptom to recognise is exactly that shape: all web traffic stops together and every pod stays Ready, because the probe path does not run through the controller. Same class of dependency as --enable-certificate-owner-ref in #126, and it is recorded both here and in the header of template-chart's policy, so whoever changes the controller's networking meets the consequence on the thing they are changing. AND WHY THE ingressController RULE WORKS AT ALL: a policy applies to traffic that came through a Service. A Service is only destination NAT and the policy is evaluated at the POD's interface, so a ClusterIP exempts nothing. What the Service changes is the SOURCE ADDRESS - pod to ClusterIP to pod preserves it, so a podSelector can still match the Traefik pod; NodePort traffic with the default externalTrafficPolicy: Cluster is masqueraded to a node, which is why those rules cannot be restricted by source and are left open instead. stalwart's template already carried the second half; the first is the one everything rests on. gotenberg gains networkPolicy.egress.remoteAssets, off by default - the maintainer's decision, and it keeps restrictive-by-default intact: a named switch rather than an opening, so the trade is a decision someone made instead of an unplaceable [80, 443] in extraPorts. Its page now carries both measured halves: paperless-ngx sends the .eml to TIKA for parsing and the resulting HTML HERE for rendering, two separate cluster-internal endpoints - so EMBEDDED images are MIME parts inside the file and need no network from either service (which is why apache-tika stays DNS-only), while REMOTE images are fetched by Chromium at render time and are silently dropped with the switch off. The PDF is produced and is simply missing them. Named as unverified, because it was not checked: whether paperless re-embeds what Tika extracted before handing the HTML over. If it does not, embedded images would be lost too - but that would be pre-existing paperless behaviour and not the policy. keycloak's page gets the measurement that makes its blast radius smaller than the page previously implied: every readable in-cluster consumer uses the PUBLIC name (bulwark's OAUTH_ISSUER_URL, homarr's AUTH_OIDC_ISSUER, the paperless releases through their socialaccount block), so their traffic arrives from Traefik and is permitted as shipped. A search for the in-cluster Service name across pod environments and ConfigMaps returned only Keycloak's own pods reaching keycloak-pg-rw.jlab-pg.svc.cluster.local:5432 - which also confirms the egress rule derived from keycloak.db.port is right for the live release. The login test stays the acceptance criterion, and the residual risk is stated rather than dismissed: a consumer whose issuer URL lives in a secret or a realm import is not covered by that search. What changed is that there is now a measured reason to expect the test to pass. --- Chart-Style-Guide.md | 13 +++++++++++++ gotenberg.md | 20 ++++++++++++++++++-- keycloak.md | 12 ++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/Chart-Style-Guide.md b/Chart-Style-Guide.md index 8ede7da..da733a3 100644 --- a/Chart-Style-Guide.md +++ b/Chart-Style-Guide.md @@ -362,6 +362,8 @@ Every port falls into one of four, and the class decides the rule: `networkPolicy.enabled`, `.allowedNamespaces`, `.probeSources`, `.ingressController`, `.egress.extraPorts`. A chart ships **only the ones that reach a rule**: a value that silently does nothing is worse than one that is absent, because it reads as a control. `samba` and `satisfactory` carry `enabled` alone; `teamspeak-server` has `allowedNamespaces` but no `probeSources`. +**No chart ships `allowedNamespaces` pre-filled, ever.** A chart naming `mc-router`, `paperless-jk` or `stalwart` in its defaults would bake in an assumption about *this* cluster's namespace layout — a claim a chart cannot keep true. So the default is the empty list and the chart is **restrictive**; filling it is the **deployment adaptation**, made where the namespace layout is actually visible. That is why a sweep like Gitea #5 produces a list of release-side one-liners: they are the **expected procedure**, not a defect list, and the breaking-change major on each chart is what announces them. `ingressController` is the single exception, because it names a cluster *component* rather than a tenant — and it is still overridable. + `ingressController` is its own two-key value rather than a line in `allowedNamespaces` for one reason: **its two selectors render as one `from` entry, which is an AND** — those pods in that namespace. As two list entries they would be an OR, and `kube-system` in `allowedNamespaces` would open the port to every pod in the cluster's own namespace. Its default is **measured, not assumed** (2026-10-14): `ingress-nginx` is gone from this cluster, the only `IngressClass` is `traefik`, and the controller is the DaemonSet `rke2-traefik` in `kube-system` — which also serves the Gateway API here, so one source covers the Ingress and the HTTPRoute path alike. `ingress.className` says nothing about it: a class names an implementation, not the pods the traffic comes from. ### What to write down per chart, because the object will not say it @@ -383,5 +385,16 @@ Every port falls into one of four, and the class decides the rule: - **Never `from: []`.** It allows everything, identically to having no key, while reading as a restriction. Write no `from` where open is meant and say so in a template comment. - **Reasoning in Go template comments, never `#`.** A `#` comment is part of the rendered *object*: it ships to the cluster and lands in every release diff. And such a comment must not spell out its own closing delimiter — the parser ends it early and the rest is emitted as manifest content, failing the render with `did not find expected node content` while pointing at the template rather than at the sentence. - **A conditional `containerPort` needs a conditional rule.** `minecraft-server`'s RCON port renders only when `server.rcon.enabled`; a rule without the same gate names a port the pod does not declare. +- **A policy applies to traffic that came through a Service**, and that is why the `ingressController` rule works at all. A Service is only destination NAT and the policy is evaluated at the **pod's** interface, so a ClusterIP exempts nothing. What the Service changes is the **source address**: pod → ClusterIP → pod **preserves** it, so a `podSelector` can still match the Traefik pod and a `namespaceSelector` a consumer's namespace. Traffic from outside through a `NodePort` with the default `externalTrafficPolicy: Cluster` is **masqueraded to a node**, which is why those rules cannot be restricted by source and are left open. `stalwart`'s template already carried that second half; the first half is what everything else rests on. + +### The one cluster setting this convention depends on and cannot check + +**Traefik must not run with `hostNetwork`.** Measured 2026-10-14: the `rke2-traefik` DaemonSet has none, and its pods carry real pod IPs — `10.42.3.120`, `10.42.4.94`, `10.42.0.187`, in the pod CIDR `10.42.0.0/16`, while the nodes sit on entirely different addresses. **Only because of that does a `podSelector` match it**, and `networkPolicy.ingressController` is built on exactly that. + +Switch the ingress controller to host networking and its pods share the node's network namespace. They stop being selectable as pods, the `ingressController` entry matches nothing, and **every web chart in this repository goes dark at once** in the restricted configuration — while every policy is still accepted and still reads as correct. + +**The symptom to recognise** is that shape: all web traffic stops together and **every pod stays `Ready`**, because the probe path does not run through the controller. + +Nothing in this repository can detect it and nothing can fail usefully on it — the same class of dependency as `--enable-certificate-owner-ref` in #126. It is recorded here and in the header of `template-chart/templates/xxx.networkpolicy.yaml`, so whoever changes the controller's networking meets the consequence attached to the thing they are changing. See [CI/CD](CI-CD#networkpolicy-render-test) for the gate and for what it provably does **not** cover. diff --git a/gotenberg.md b/gotenberg.md index 70875ac..d09d3b0 100644 --- a/gotenberg.md +++ b/gotenberg.md @@ -82,13 +82,14 @@ An erased block (`gotenberg.securityContext:` with no children, a `null` probe, ### `networkPolicy` -Inbound **3000** only. Outbound **DNS and nothing else** — and that is a deliberate, costly trade rather than an oversight; see the operating note. There is no `ingressController` value, because this chart renders no Ingress. +Inbound **3000** only. Outbound **DNS and nothing else by default** — a deliberate, costly trade rather than an oversight, and `networkPolicy.egress.remoteAssets` is the named switch that widens it when a release needs remote images; see the operating note for both halves. There is no `ingressController` value, because this chart renders no Ingress. | Value | Type | Default | Meaning | |---|---|---|---| | `networkPolicy.enabled` | bool | `true` | Renders the policy. **Only a boundary where the CNI enforces one** — unenforced, the API server accepts it and nothing is filtered, so everything reads as configured. | | `networkPolicy.allowedNamespaces` | list | `[]` | Namespaces whose pods may reach the restricted ports, additionally to the release's own. Matched on the `kubernetes.io/metadata.name` label Kubernetes sets itself — no labelling work, and no typo protection either: a name that does not exist matches nothing and is silently ineffective. Namespace-granular, so an entry opens the port to **every** pod in it. | | `networkPolicy.probeSources` | list | `[]` | CIDRs allowed to reach the probed port. **Empty means open, not blocked** — the rule is then rendered with no `from` at all. Probes come from the IP of the **worker node running the pod**, which the chart cannot know; a list that misses it drops every probe and the pod never becomes `Ready`. | +| `networkPolicy.egress.remoteAssets` | bool | `false` | Lets Chromium fetch over TCP **80 and 443** to any destination, for the **remote** images in a rendered document. Off by default — see the operating note. Use this rather than putting 80/443 in `extraPorts`: the named value says *why* the ports are open. | | `networkPolicy.egress.extraPorts` | list | `[]` | Additional outbound **TCP** ports, to any destination. The escape hatch from the egress cap, and the only alternative to switching the policy off. | ## Operating notes @@ -128,7 +129,22 @@ The repo's resource-quantity render test injects a bare integer memory request i Gotenberg drives a **headless Chromium**, and 3000 is the port the probes address — so it is reachable from the whole cluster by default. An egress rule for 80/443 would therefore turn a service anyone can POST to into a way of fetching arbitrary URLs: a textbook SSRF pivot. -The price of blocking it is real and silent. HTML conversion keeps inline content and **loses images the page would have fetched** — a degraded PDF, not a failed pod, so nothing will alert on it. A `paperless-ngx` that only converts mail bodies does not need them; a release that converts URLs or needs remote assets sets `networkPolicy.egress.extraPorts: [80, 443]` **knowingly**. +The price of blocking it is real and silent, and the two halves were measured rather than guessed. `paperless-ngx` splits a mail into two steps with two separate cluster-internal endpoints: the `.eml` goes to **Tika** for parsing (`PAPERLESS_TIKA_ENDPOINT`) and the resulting HTML comes **here** for rendering (`PAPERLESS_TIKA_GOTENBERG_ENDPOINT`). Of the images in that HTML: + +- **Embedded** images are MIME parts *inside* the file. Neither Tika nor Gotenberg needs the network for them — which is why [apache-tika](apache-tika#networkpolicy) stays DNS-only, and why a mail that carries its pictures with it needs nothing from the switch. +- **Remote** images (`` in the body) are fetched by **Chromium, here, at render time**. With the switch off they are **silently dropped**: the PDF is produced and is simply missing them. Nothing fails, nothing is logged by this chart, no probe notices. + +So a release that renders mail bodies referencing remote pictures sets + +```yaml +networkPolicy: + egress: + remoteAssets: true +``` + +and **not** `extraPorts: [80, 443]` — the named value records the decision, a port list cannot. The SSRF argument above is unchanged by it and is exactly why the default is off. + +**One thing deliberately not claimed:** whether `paperless-ngx` re-embeds the images Tika extracted before handing the HTML over. If it does not, embedded images would be lost as well — but that would be **pre-existing paperless behaviour and not this policy**, so do not read the switch as a fix for it. Unverified. Same as Tika: this chart has no Ingress, so narrowing `probeSources` means putting the consumers' namespaces in `allowedNamespaces` in the same change, or every conversion stops. diff --git a/keycloak.md b/keycloak.md index 9daf64e..49f3ce6 100644 --- a/keycloak.md +++ b/keycloak.md @@ -557,6 +557,18 @@ This chart renders **no** NetworkPolicy. If one is ever added, it has to allow 7 **2. Check your consumers' issuer URLs.** An application that reaches Keycloak on its **in-cluster Service address** rather than its public URL is a pod in another namespace, and the http rule blocks it. Those namespaces go into `networkPolicy.allowedNamespaces`. Consumers using the public issuer URL arrive through Traefik and are already covered. +> **Measured on 2026-10-14, and the news is good: every readable in-cluster consumer uses the public name.** +> +> | Consumer | Value | +> | --- | --- | +> | `bulwark` | `OAUTH_ISSUER_URL = https://id.mail.bekemeyer-klein.de/realms/mail` | +> | `homarr` | `AUTH_OIDC_ISSUER = https://bk.id.bekemeyer-klein.de/realms/bekemeyer-klein` | +> | `paperless-{jk,cmk,ngx}` | through their `PAPERLESS_SOCIALACCOUNT_PROVIDERS` block | +> +> Their traffic therefore arrives **from Traefik** and is permitted by the rule as it ships. A search across pod environments and ConfigMaps for Keycloak's in-cluster Service name (`keycloak-keycloak-service`, `*.keycloak.svc`) returned **only Keycloak's own pods**, reaching their database at `keycloak-pg-rw.jlab-pg.svc.cluster.local:5432` — which incidentally confirms that the egress rule derived from `keycloak.db.port` is right for the live release. +> +> **The residual risk, stated rather than dismissed:** anything that addresses Keycloak by its Service name *would* be cut off, and among the readable consumers there is **none**. A consumer whose issuer URL lives somewhere unreadable (a secret, a realm import) is not covered by this check — which is why the login test above stays the acceptance criterion. What changed is that there is now a measured reason to *expect* it to pass, instead of a hope. + ### The management port is the probed one, which is unfortunate here specifically 9000 carries the health and readiness endpoints **and the metrics**, unauthenticated, and before this object existed the only thing protecting it was that no Ingress pointed at it. Because all three probes address it, it cannot be restricted by source without the worker node's IP — so the policy improves it from *anything that can route to the pod* to *anything inside the cluster*, and `networkPolicy.probeSources` is what closes the rest.