diff --git a/.github/workflows/validate-charts.yml b/.github/workflows/validate-charts.yml index 4979c78..49c3f1d 100644 --- a/.github/workflows/validate-charts.yml +++ b/.github/workflows/validate-charts.yml @@ -303,6 +303,52 @@ jobs: - name: "Check every rendered scalar that must be a string" run: ci/run-yaml-scalar-render-test.sh + networkpolicy: + name: "NetworkPolicy" + runs-on: ubuntu-latest + # The sixth job that ignores the changed-chart matrix and the fifth that runs + # over *every* chart (Gitea #5), for the same reason as the render tests + # above: the mistake arrives by copy into a chart whose own diff nobody reads + # for it - and since #5 this convention is in all 29 installable charts, so + # there is a lot of copy. + # + # What it asserts, on every NetworkPolicy a chart renders, against the pod + # specs, probes and Services of the SAME render: the podSelector really + # matches a pod; every ingress port is a containerPort of those pods; no + # ingress rule allows every source by accident; and every port a NETWORK + # probe addresses is either left unrestricted or comes with + # networkPolicy.probeSources and the consequence written into NOTES.txt. + # Plus two guards of its own: a chart that renders NO policy fails, and a + # rendered "#" comment fails. + # + # Every one of those failures is SILENT. The API server accepts a policy + # whose selector matches nothing - it then sits in the namespace, is listed + # by kubectl, reads as a boundary and filters exactly nothing - just as + # readily as it accepts one naming a NodePort number, which matches no + # traffic at all because the policy is evaluated at the pod's interface + # after kube-proxy has already translated the port. Neither is a render + # error and neither denies anything, so neither the build nor a rollout can + # see it. + # + # It does NOT replace the install-test, which is the real net under this + # convention: that one proves ENFORCEMENT on a live k3s with its policy + # controller running, and it is what makes "all charts at once, default on" + # defensible. This job is the cheap half, and it covers the two charts the + # install-test cannot reach at all - satisfactory and template-chart, both + # in ci/excluded-charts.txt. + # + # No cluster and no Trivy; a few seconds for the whole repository, depends + # on no other job. The YAML is parsed by Helm through ci/yaml2json, because + # the runners have neither pyyaml nor yq and a hand-written parser could read + # nothing and report nothing - see the script's header. + steps: + - name: "Checkout Repository" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: "Install Helm" + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5 + - name: "Check every rendered NetworkPolicy against the render it belongs to" + run: ci/run-networkpolicy-test.sh + trivy-config: name: "Trivy config ${{ matrix.chart }}" needs: detect-changed-charts diff --git a/CLAUDE.md b/CLAUDE.md index c7ce967..1e56490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -116,6 +116,7 @@ The binding style guide for all current and future charts lives in **README.md ( - Every container offers an additive `env` block rendered through `tpl` with `value`, `secretKeyRef`, and `configMapKeyRef` forms. - Hardening: `automountServiceAccountToken: false`, non-root, `readOnlyRootFilesystem: true`, caps dropped, `allowPrivilegeEscalation: false`; contexts values-configurable (`securityContext.pod` / `securityContext.container`); writable paths as emptyDirs; image-forced deviations MUST be documented with a comment in the chart's `values.yaml`. - Probes: liveness/readiness/startup on every workload (exception: paperless consume CronJob), values-configurable with calibrated defaults. +- **Every chart renders a `NetworkPolicy`, `networkPolicy.enabled` defaults to `true`** (#5); adding one to an existing chart is a **major**. Values: `enabled`, `allowedNamespaces`, `probeSources`, `ingressController`, `egress.extraPorts` — and only the ones that actually reach a rule, because a value that does nothing reads as a control. Each port falls in one of four classes and the class decides the rule: a port a **network** probe addresses stays open (`no from`) until `probeSources` is set, because the kubelet probes from the worker node's IP and no chart can know it; a NodePort-published port stays open (the source is already masqueraded); anything else is restricted to the namespace, `allowedNamespaces` and — for HTTP — `ingressController`; a replica-to-replica bus gets a bare `podSelector`. **`exec` probes are not network probes** (no packet, so the port can be restricted — `valkey`, `minecraft-server`). **`allowedNamespaces` is never shipped pre-filled** — a chart must not bake in an assumption about this cluster's namespace layout, so restrictive is the default and filling it is the deployment adaptation (which is why a sweep produces release-side one-liners: that is the procedure, not a defect list). **A policy applies to traffic that came through a Service** — a Service is only destination NAT and the policy acts at the pod interface; what changes is the *source address*, preserved for pod→ClusterIP→pod (this is why the `ingressController` rule works) and masqueraded to a node for NodePort traffic. **And the whole convention depends on one setting it cannot check: Traefik must not run with `hostNetwork`** — measured, its pods have real pod IPs in `10.42.0.0/16`; on host networking they stop being selectable as pods and every web chart goes dark at once while every pod stays `Ready`. **Pod ports, never NodePorts.** Never `from: []`. Reasoning in Go template comments, never `#` (those ship to the cluster), and such a comment must not spell out its own closing delimiter. Egress is by **port, never destination** — Canal has no FQDN rule and Calico-specific objects are out of scope — so most dependency ports are the *standard* ones and a dependency elsewhere is blocked; `policyTypes` names only what there is something to restrict (`valkey`, `homeassistant`, `satisfactory` are Ingress-only, each argued in its own file). `ci/run-networkpolicy-test.sh` is the gate. Full rules: README, "NetworkPolicies". - Resources: requests as values defaults; limits auto-computed by the `_helpers.tpl` helper (`limitFactor` default 3; no CPU limits unless set explicitly). - Replicas per workload (explicit `0` allowed) plus a chart-global `scaleDown` flag that overrides them. - `icon:` mandatory in every `Chart.yaml`; `helm lint --strict` without findings; CI fixtures under `ci//` or a justified exclusion. diff --git a/README.md b/README.md index b1f1102..4ed1ffd 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,32 @@ Three things to know before touching it: Measured locally: **30 charts, 408 scalars verified, 0 failed**, no cluster. The fail path was exercised rather than assumed, in all three of its shapes, against a throwaway copy of the chart as it stands on `main`: the unquoted schedule aborted the render with `yaml: line 6: did not find expected alphabetic or numeric character` and the test reported it; with only the schedule quoted, the star-valued `env` entry aborted the render on the StatefulSet instead; and with only the bool-looking value left — a render that **succeeds** — the type assertion produced a `FAIL` naming the variable, the container and the exact API-server message. **Seven charts still render their `env` block bare** (`matrix-synapse`, `minecraft-router`, `minecraft-server`, `rallly`, `satisfactory`, `teamspeak-server`, and `paperless-ngx` until #11); the test is green for them only because their fixtures set no hostile `env` value, and the overlay is what makes the difference between covered and merely green. +### NetworkPolicy render test (all charts, every PR) + +`ci/run-networkpolicy-test.sh [chart …]` (no argument = all charts) renders every chart with the same fixtures the install-test uses and holds **every `NetworkPolicy` it renders against the pod specs, probes and Services of the same render**. Introduced with Gitea #5, which put a policy into all 29 installable charts at once with `networkPolicy.enabled: true` as the default — 29 objects whose every failure mode is silent, because the API server accepts a policy that filters **nothing** exactly as readily as one that filters **everything**. + +| Assertion | What goes wrong without it | Who notices today | +|---|---|---| +| the `podSelector` matches a pod spec of the same render | the object sits in the namespace, is listed by `kubectl`, reads as a boundary and filters nothing at all — usually because a label was renamed in the workload template, a different file and a different diff | nobody, ever: nothing is denied, so there is no symptom to trace | +| every **ingress** port is a `containerPort` of the selected pods | a NodePort number where a pod port belongs. The policy is evaluated at the pod's interface, *after* kube-proxy translated the port, so `30025` matches nothing while looking strict — and being the only rule for that port, it blocks every inbound connection instead | nobody in CI (see below), and in production as a service that went mute | +| no ingress rule allows every source by accident | `from: []` allows everything, identically to having no `from` at all, while reading in a diff and in `kubectl` output as a restriction — the one mistake in this class that makes a policy *weaker* while looking *stricter* | nobody | +| every port a **network** probe addresses is either unrestricted, or the chart offers `networkPolicy.probeSources` **and** says in `NOTES.txt` what a wrong CIDR costs | the kubelet probes from the IP of the worker node running the pod, which no chart can know. A restricted probe port without that address drops every probe: the pod never becomes `Ready`, the rollout times out, and the symptom reads as a broken application | the install-test, in production's CNI — **but not in CI**, see below | + +Egress ports are **deliberately not** held against the pod's `containerPort`s: an egress port is a port on the *destination*, and that comparison would fail every chart that reaches a database. + +Two guards beyond the four, both earned rather than designed. **A chart that renders no policy at all fails** — that is how this issue decayed in the first place: `garage` (#81) and `keycloak` (#83) were both created *after* #5 was filed and both arrived without a policy, `keycloak` with its unauthenticated management surface on 9000. And **a `#` comment inside a rendered policy fails**, because a `#` comment is part of the *object*: it travels into the cluster, shows up in `helm get manifest` and `kubectl get -o yaml`, and lands in every release diff. Reasoning belongs in Go template comments, which render nothing. Both precedent charts failed that check when it was first run, which is what it is for. + +Four things to know before touching it: + +- **Assertion 3 is stricter than it first reads, deliberately.** A *missing* `from` is the documented open form and both precedent charts use it on purpose, so banning it outright was not an option. It is accepted only where the render itself justifies being open: every port of the rule is addressed by a network probe, or is published by a `NodePort` Service in the same render. Anything else is an allow-all nobody chose. +- **`exec` probes are not network probes**, and that distinction carries real weight rather than being a technicality. An `exec` probe runs inside the container through the kubelet's runtime and puts no packet on the network, so a chart whose probes are all `exec` can restrict its port by source with **no `probeSources` at all** — which is why `valkey` and `minecraft-server` have the tightest policies in the repository and every web app has a looser one. +- **Subchart documents are skipped by the comment assertion**, for a structural reason and not a lenient one: a dependency is fetched from the Helm registry rather than from the copy in this repo, 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. The subchart's own PR is where it is caught, because this test runs over `valkey` too. +- **Anti-vacuum guard**, as in the tests above: `ci/networkpolicy/.yaml` is a render-only overlay applied **last**, whose job is to render the **source-restricted probe rule** — a branch no chart's own defaults ever reach, and the one containing the expensive mistake (a `namespaceSelector` and a `podSelector` written as two list entries instead of one would be an OR, opening the port to every pod in `kube-system`). A chart that *has* an overlay and still renders no restricted **probe** rule fails. That counter is specifically the probe rule and not "any restricted rule", because `keycloak` restricts two rules in its own defaults and would have satisfied a looser guard while the branch went unrendered. + +Measured locally: **30 charts, 206 assertions held, 0 failed**, no cluster, a few seconds. All twelve fail paths were exercised rather than assumed — and one earned its keep immediately: the `probeSources` check was a substring test, so a chart whose key had been **deleted** while the long comment above it still discussed the value passed it. It now looks for the key. + +**What this test does not and cannot cover, named so the gap is not mistaken for coverage:** that traffic still flows. No ingress controller runs in the install-test cluster, so the path from Traefik to a pod is proven nowhere in this pipeline — and, measured on the PR for #5, **k3s exempts the kubelet's probe traffic from policy**, so a blocked probe path does *not* turn the install-test red either (`cyberchef` became `Ready` in 69s with its probe port restricted to an address no probe can come from). What the install-test *does* prove is enforcement of everything the application itself needs: with `outline`'s DNS egress rule removed, its pod never became `Ready` and the job failed after 5m59s. So the net under this convention is real, but it is an **egress** net — which raises the stakes for assertion 2, since a wrong ingress port is caught mechanically here or not at all. + ### Install-test (PR pipeline stage 2) For every chart a PR changes, `ci/run-install-test.sh ` runs against a throwaway k3d cluster: it applies the stub `OnePasswordItem` CRD plus the chart's fixtures, `helm install`s the chart with its CI values, and waits for all Deployments/StatefulSets to roll out (per-chart timeout). Since probes and security hardening are active on all workloads, "Ready" means the app actually answers. On failure the job dumps pod descriptions, events and container logs. @@ -638,3 +664,70 @@ One more trap, and it is the reason `stalwart` gates its subchart on `valkeySubc Two implementation notes that cost time otherwise: a values key containing a dash is unreachable as `.Values.oauth2-proxy` (that parses as a subtraction) and needs `index .Values "oauth2-proxy"`, while `condition:` in `Chart.yaml` splits on dots only and needs no escaping. And inside a subchart `.Chart.Name` is the **subchart's** name — a value templated into it (`config.configFile` in this case) must spell the parent chart out literally; only `.Release.Name` is shared. Charts that need no dependency still have none, and `charts/` stays empty for all of them. `template-chart` remains dependency-free: the scaffold should not carry a `dependencies:` block a new chart has to remember to delete. + +### 15. NetworkPolicies (#5) + +**Every chart renders a `NetworkPolicy`, and `networkPolicy.enabled` defaults to `true`.** Reference implementation: `template-chart/templates/xxx.networkpolicy.yaml`. The default is the decision the whole convention rests on — a policy that ships switched off is reached only when somebody remembers to switch it on, and then the issue is closed while nothing is filtered. Adding one to an existing chart is therefore a **major** bump. + +**What the object is: a ceiling on reachability, not an authentication layer.** Three limits are real and worth having, and for most charts here the first two are where all of the value sits: + +1. **Only the declared ports are reachable at all.** Whatever else the process binds — a debug listener, a management interface the image opens by default, a port that appears in the next app version — is blocked without anyone having to notice it exists. +2. **Egress is capped by port**, so a compromised pod cannot open arbitrary outbound connections. +3. **Ingress is capped by source for every port no network probe touches.** + +#### The four port classes, and why the class decides the rule + +Every port of a workload falls into one of four, and this is the part to get right before writing anything: + +| Class | Rule | Why | +|---|---|---| +| **probe path** — a port a *network* probe addresses | **no `from`** by default; restricted once `networkPolicy.probeSources` is set | the kubelet probes from the IP of the **worker node running the pod**, an address no chart can know. A guessed CIDR that misses it drops every probe: the pod never becomes `Ready`, the rollout times out, and the symptom reads as a broken application rather than as a policy | +| **public** — published by a `NodePort` Service | **no `from`** | the senders are on the internet and, after NodePort masquerading, the source address is a cluster node anyway. A source selector could only ever be a list of node IPs — a restriction in appearance and a maintenance trap in fact | +| **closed** — no probe, not published outside | own namespace + `allowedNamespaces` (+ the ingress controller where the chart routes HTTP there) | this is the only class that yields a *real* inbound restriction, and the charts that have one are the ones whose probes use a *different* port: `keycloak`'s 8080, `garage`'s s3 and web, `minecraft-server`'s RCON, `teamspeak-server`'s query interface | +| **self** — a cluster bus between a release's own replicas | `podSelector` only, no `namespaceSelector` | the narrowest rule the object can express: "these labels, in this namespace". `keycloak`'s jgroups, `garage`'s rpc. Note that `policyTypes: Egress` means the **outbound** side needs its own rule, or the bus is open one way and the cluster never forms while every node looks healthy | + +**`exec` probes are not in the probe class**, and the distinction is load-bearing rather than pedantic: 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 accommodate. That is the entire reason `valkey` and `minecraft-server` have the tightest policies in the repository — every port restricted by source, no `probeSources` value at all — and every HTTP-probed web app has a looser one. + +#### Values + +`networkPolicy.enabled`, `.allowedNamespaces`, `.probeSources`, `.ingressController`, `.egress.extraPorts` — and 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 (every inbound rule is open to any source), and `teamspeak-server` has `allowedNamespaces` but no `probeSources`. + +**No chart ships `allowedNamespaces` pre-filled, ever.** A chart that named `mc-router`, or `paperless-jk`, or `stalwart` in its defaults would be baking in an assumption about *this* cluster's namespace layout — which is exactly the kind of 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 this produces a list of release-side one-liners: those are the expected procedure, not a defect list, and the breaking-change major on each chart is what announces them. + +`ingressController` is the one exception to that, and only because it names a **cluster component** rather than a tenant: its default is measured below, and it is still overridable. + +`ingressController` is a separate value with two keys 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. Written as two list entries they would be an OR, and putting `kube-system` into `allowedNamespaces` instead would open the port to every pod in the cluster's own namespace. Its default is **measured, not assumed**: `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 these policies do not do — and it belongs in every chart, not just here + +- **Egress is coarse: by port, never by destination.** A `NetworkPolicy` matches `ipBlock` CIDRs and pod/namespace selectors and **never hostnames**, so "only to the database host" is not expressible 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 every chart to Calico — the same argument that kept implementation-specific objects out of the Gateway-API sweep (#114) — so it is **not used**. This is a limit of the object, and each chart names what it costs *that* chart. +- **Most dependency ports are the standard ones, not the configured ones.** Databases, caches and OIDC issuers arrive as connection URLs or as fields inside 1Password items the charts never parse, so a chart cannot know where its dependency listens and cannot validate a guess. A dependency somewhere else is **blocked**, and `networkPolicy.egress.extraPorts` is the escape hatch. The exceptions are the two charts that hold their ports as values and therefore derive them: `keycloak` (`keycloak.db.port`) and `stalwart` (`dataStore.postgres.port`, `externalServices.*.port`); `minecraft-router` derives its backend ports from its own routing table the same way. +- **The probed port stays open inside the cluster by default.** For a single-port web app that means the policy is a port and egress ceiling, not pod-level isolation — say so in the chart rather than letting the object imply otherwise. +- **A subchart's pods are not covered.** The policy selects this chart's workload by label; a subchart renders its own pods with its own labels. `ytdlp-web-player`'s oauth2-proxy is the sharpest case — with the proxy on, the pod that actually faces the Ingress is unrestricted in both directions, and an upstream chart is consumed as it comes (section 14). `paperless-ngx`' `valkey` is the opposite: it brings its own, tighter policy. Name which case a chart is in. +- **And a policy is only a boundary where the CNI enforces one.** Unenforced, the API server still accepts it and nothing is filtered, so everything reads as configured. Verify by connecting from an unrelated namespace, not by reading the value. + +#### Where the convention says "Ingress only" + +`policyTypes` names only what there is something to restrict. `valkey` established it for a workload that opens **no** outbound connection: an egress rule would constrain nothing while making the object look stricter than it is. Three charts now invoke the same rule in the other direction, for outbound traffic nobody can enumerate — and each argues it in its own file rather than inheriting a blanket excuse: + +- **`homeassistant`**: outbound traffic is the union of every integration an operator enables (mDNS and SSDP discovery, MQTT, a Zigbee or Z-Wave bridge on whatever port its firmware chose, ESPHome, printers, speakers, inverters, cloud APIs, NTP). A cap would protect little — the devices sit on the node's own LAN — and would break integrations one at a time, months apart, each looking like a flaky device. +- **`satisfactory`**: Steam's content delivery is not a pinnable port set, *and* this is the one chart with no install-test at all (`ci/excluded-charts.txt`), so an egress cap here would be the only one in the sweep with no empirical check behind it, on the chart whose correct port set is least knowable. +- **`gotenberg`** goes the other way and is worth contrasting: it *keeps* a DNS-only cap deliberately, because it drives a headless Chromium and 3000 is its probe port — so an egress rule for 80/443 would turn a service the whole cluster can POST to into an SSRF pivot. The price is that HTML conversion silently loses remote assets, which is written into the chart because nothing will alert on it. + +#### Mechanics + +- **Pod ports, never NodePorts.** A `NetworkPolicy` is evaluated at the pod's network interface, *after* kube-proxy has translated a NodePort to the `targetPort`. `30025` where the pod listens on `25` matches nothing: accepted, strict-looking, and blocking every inbound connection instead. The render test asserts this mechanically against the selected pod spec's `containerPort`s. +- **Protocol as well as number.** `teamspeak-server`'s voice port is UDP and `satisfactory`'s game port is both — a TCP-only rule on either is accepted, looks complete, and drops the traffic that actually arrives. +- **`kubernetes.io/metadata.name`** is set by Kubernetes itself on every namespace (≥ 1.21), so namespace selectors need no labelling work on namespaces the chart does not own — and get no typo protection either. +- **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 goes in Go template comments, not `#` comments.** A `#` comment is part of the rendered object: it travels into the cluster, appears in `helm get manifest` and `kubectl get -o yaml`, and lands in every release diff. The render test fails on a rendered `#`. One second-order trap, found while writing the reference implementation: prose inside a template comment must not spell out the comment's own closing delimiter, or the parser ends the comment early and the rest is emitted as manifest content — the render then fails with `did not find expected node content`, pointing at the template and not at the sentence. +- **A conditional `containerPort` needs a conditional rule.** `minecraft-server`'s RCON port is rendered only when `server.rcon.enabled` is true; a rule without the same gate names a port the pod does not declare, and the render test rejects it. +- **A policy applies to traffic that arrived through a Service, and that is why the `ingressController` rule works at all.** A `NetworkPolicy` is evaluated at the pod's network interface and a Service is only destination NAT, so going through a ClusterIP does not exempt anything. What the Service *does* change is the **source address**: pod → ClusterIP → pod **preserves** it, which is what lets a `podSelector` match the Traefik pod or a `namespaceSelector` match 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 at all and are left open instead (`stalwart`'s template carries that second half; the first half is the load-bearing one). + +#### The one cluster setting this convention depends on and does not control + +**Traefik must not run with `hostNetwork`.** Measured on 2026-10-14: the `rke2-traefik` DaemonSet has no `hostNetwork`, and its pods carry real pod IPs (`10.42.3.120`, `10.42.4.94`, `10.42.0.187` — the pod CIDR is `10.42.0.0/16`, nodes are on entirely different addresses). **Only because of that does a `podSelector` match it**, and the `ingressController` value is built on exactly that. + +If Traefik were ever switched to host networking, its pods would share the node's network namespace, they would no longer be selectable as pods, the `ingressController` entry would match nothing — and **every web chart in this repository would go dark at once**, in the restricted configuration. The policies would still be accepted, still read as correct, and deny all web traffic. + +This is a dependency on a cluster setting this repository does not own, in the same class as the `--enable-certificate-owner-ref` case from #126: nothing here can detect it, and nothing here can fail usefully on it. It is written down in both places someone would look — here, and in the header of `template-chart/templates/xxx.networkpolicy.yaml` — so that whoever changes the ingress controller's networking finds the consequence attached to the thing they are changing. The symptom to recognise: every chart's web traffic stops at once while every pod stays `Ready`, because the probe path does not go through the controller. diff --git a/apache-tika/Chart.yaml b/apache-tika/Chart.yaml index 0a26a8a..c6e5b5c 100644 --- a/apache-tika/Chart.yaml +++ b/apache-tika/Chart.yaml @@ -5,5 +5,5 @@ icon: https://www.apache.org/logos/res/tika/default.png type: application -version: 10.0.2+up3.3.1.0.full +version: 11.0.0+up3.3.1.0.full appVersion: "3.3.1.0-full" diff --git a/apache-tika/templates/NOTES.txt b/apache-tika/templates/NOTES.txt index f898ddf..7a3896e 100644 --- a/apache-tika/templates/NOTES.txt +++ b/apache-tika/templates/NOTES.txt @@ -58,3 +58,57 @@ THE JVM ONLY GETS A QUARTER OF THE MEMORY LIMIT cheap regardless; the heap is what matters for parsers that have to build a whole document in memory, and for OCR. {{- 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 9998 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }} — Tika is handed documents and + fetches nothing. + + THIS CHART HAS NO INGRESS AND ITS CONSUMERS ARE OTHER PODS. While + networkPolicy.probeSources is empty the 9998 rule has no source selector, so + any pod in the cluster — including a paperless-ngx release in another + namespace — reaches it. Narrowing probeSources restricts that rule, and the + consumers' namespaces must then be listed in networkPolicy.allowedNamespaces + in the same change, or document extraction stops with a connection error on + the paperless side and nothing at all on this one. + + 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 }}-deployment + + 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/apache-tika/templates/apache-tika.networkpolicy.yaml b/apache-tika/templates/apache-tika.networkpolicy.yaml new file mode 100644 index 0000000..7d24aa0 --- /dev/null +++ b/apache-tika/templates/apache-tika.networkpolicy.yaml @@ -0,0 +1,149 @@ +{{- /* +NetworkPolicy for apache-tika (Gitea #5). + +Apache Tika here is a cluster-internal service: the chart creates no Ingress at +all, and its only consumers are other pods - paperless-ngx points at +http://..svc.cluster.local for text extraction. + +THAT MAKES THE SOURCE QUESTION THE INTERESTING ONE, AND IT HAS AN AWKWARD +ANSWER. 9998 is the port all three probes address, so it cannot be restricted +by source without the worker node's IP - and this chart's consumers live in +OTHER namespaces (paperless-cmk, paperless-jk, paperless-tb in this cluster). +So an operator who narrows probeSources has to list those namespaces in +allowedNamespaces in the same breath, or every document extraction stops. That +is written into values.yaml and NOTES.txt rather than left to be discovered. + +Egress is DNS only. Tika extracts text from documents it is handed; it fetches +nothing, and a text extractor that can reach the internet is a worse thing to +hand an untrusted document to. + +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 }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there is something real to restrict: Tika extracts + text from documents it is handed and fetches nothing of its own, so DNS is the + only rule. A text extractor that can reach the internet is a worse thing to + hand an untrusted document to. + */}} + - Egress + ingress: + {{- /* + 9998 - the only port this pod declares, the one the Service forwards 80 to, and + the one all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 9998 + {{- 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. + + NO ingress controller is added here, because this chart renders no Ingress: its + consumers are other pods, and they have to be named in allowedNamespaces. + */}} + 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 }} + {{- 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 + {{- 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/apache-tika/values.schema.json b/apache-tika/values.schema.json index 064a85e..f2d820e 100644 --- a/apache-tika/values.schema.json +++ b/apache-tika/values.schema.json @@ -9,6 +9,43 @@ "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" + } + }, + "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/apache-tika/values.yaml b/apache-tika/values.yaml index 9241506..b749e35 100644 --- a/apache-tika/values.yaml +++ b/apache-tika/values.yaml @@ -70,3 +70,63 @@ tika: cpu: 0.1 memory: 512Mi ephemeralStorage: 10Mi + +# --------------------------------------------------------------------------- +# 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 9998 only - the single port this pod + # declares - and outbound nothing but DNS. Tika is handed documents and + # fetches nothing, which is a property worth keeping: a text extractor that + # can reach the internet is a worse thing to hand an untrusted document to. + # + # WHAT IT DOES NOT DO, and read this before narrowing anything: 9998 is the + # probe port, so it is NOT restricted by source in the default. This chart + # has no Ingress and its consumers are other pods - paperless-ngx releases, + # which in this cluster live in their own namespaces. The moment probeSources + # is set, the 9998 rule is restricted and those namespaces must be in + # allowedNamespaces, or every document extraction stops. + 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 9998, 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 and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] + 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. + # + # Nothing is needed here for Tika itself. Add a port only if a parser is + # configured that fetches remote resources - and consider whether that is + # wanted at all. + extraPorts: [] diff --git a/bentopdf/Chart.yaml b/bentopdf/Chart.yaml index 9d9b1b1..a4d6091 100644 --- a/bentopdf/Chart.yaml +++ b/bentopdf/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/alam00000/bentopdf/main/public/images/fa type: application -version: 1.1.0+up2.8.8 +version: 2.0.0+up2.8.8 appVersion: "2.8.8" maintainers: diff --git a/bentopdf/templates/NOTES.txt b/bentopdf/templates/NOTES.txt index 7373a30..3dfc301 100644 --- a/bentopdf/templates/NOTES.txt +++ b/bentopdf/templates/NOTES.txt @@ -153,3 +153,49 @@ bentopdf.env HAS NO EFFECT ON THE APP — AND THREE VALUES BREAK THE POD starts. Using them means also setting securityContext.container.readOnlyRootFilesystem: false. {{- 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 8080 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }} — the documents are + processed in the browser, so the pod needs nothing more. + + 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 }}-deployment + + 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/bentopdf/templates/bentopdf.networkpolicy.yaml b/bentopdf/templates/bentopdf.networkpolicy.yaml new file mode 100644 index 0000000..55ad14a --- /dev/null +++ b/bentopdf/templates/bentopdf.networkpolicy.yaml @@ -0,0 +1,157 @@ +{{- /* +NetworkPolicy for bentopdf (Gitea #5). + +BentoPDF is a browser-side PDF tool: nginx serves the bundle, the documents are +processed in the visitor's browser, and the chart has no secrets and no +volumes. So the pod holds nothing and calls nothing, and the policy is as tight +as this object gets - inbound 8080, outbound DNS. + +The ingress direction is not restricted by source in the default, because 8080 +is the port all three probes address. See the rule below. + +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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because the restriction is close to total and therefore worth + having: a static file server needs name resolution and nothing else, so DNS is + the only rule. + */}} + - Egress + ingress: + {{- /* + 8080 - the image's EXPOSE and the port its nginx.conf listens on, the one the + Service targets, and the one all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 8080 + {{- 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 and the + HTTPRoute both go 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 + {{- 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/bentopdf/values.schema.json b/bentopdf/values.schema.json index 70e1a65..9cd201d 100644 --- a/bentopdf/values.schema.json +++ b/bentopdf/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/bentopdf/values.yaml b/bentopdf/values.yaml index 641f2fc..b86f830 100644 --- a/bentopdf/values.yaml +++ b/bentopdf/values.yaml @@ -299,3 +299,79 @@ httpRoute: # chart has no OnePasswordItem, and why the checksum limitation for # operator-filled secrets does not apply here: the only thing mounted is the # ConfigMap, and that one is covered by checksum/config. + +# --------------------------------------------------------------------------- +# 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 8080 only, outbound nothing but DNS. + # BentoPDF processes documents in the visitor's browser and the chart has no + # secrets and no volumes, so the pod has no reason to open an outbound + # connection at all. + # + # WHAT IT DOES NOT DO: the 8080 rule is not restricted by source in the + # default, because 8080 is the probe port - see probeSources. So this is a + # port and egress ceiling, not pod-level isolation. And it is only a boundary + # where the CNI enforces policies. + 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 8080, 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. + # + # Nothing is needed here for BentoPDF itself. + extraPorts: [] diff --git a/bulwark/Chart.yaml b/bulwark/Chart.yaml index c3afad3..2bae6a9 100644 --- a/bulwark/Chart.yaml +++ b/bulwark/Chart.yaml @@ -21,7 +21,7 @@ type: application # duplicated here. The short version: eight tag shapes, six of them things # nobody should pin, and plain `docker` versioning excludes all six - so that # block deliberately carries NO versioningTemplate. -version: 1.1.0+up1.12.0 +version: 2.0.0+up1.12.0 appVersion: "1.12.0" maintainers: diff --git a/bulwark/templates/NOTES.txt b/bulwark/templates/NOTES.txt index 49728d6..54cca6f 100644 --- a/bulwark/templates/NOTES.txt +++ b/bulwark/templates/NOTES.txt @@ -327,3 +327,56 @@ WHAT THIS CHART DOES NOT RENDER platform objects and live in cluster-config; this chart renders an HTTPRoute at most, which is the part that looks the same on every Gateway implementation. +{{- 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 3000 only. Outbound: DNS and TCP 443 — the JMAP server and the + OIDC provider{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + CHECK THE JMAP PORT. 443 is where a published JMAP endpoint lives. A + bulwark.jmapServerUrl pointing at Stalwart's in-cluster HTTP port (8080), or + at anything else, is BLOCKED by this policy — and because Bulwark's probes + only test its own port, the pod becomes Ready and then serves an empty, + broken webmail with nothing in the events to explain it. Add the real port to + networkPolicy.egress.extraPorts. + + 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/bulwark/templates/bulwark.networkpolicy.yaml b/bulwark/templates/bulwark.networkpolicy.yaml new file mode 100644 index 0000000..c8f60b5 --- /dev/null +++ b/bulwark/templates/bulwark.networkpolicy.yaml @@ -0,0 +1,175 @@ +{{- /* +NetworkPolicy for bulwark (Gitea #5). + +Bulwark is a JMAP webmail CLIENT: it holds no mail, has no database, no cache +and no broker. Everything it shows lives on a Stalwart server reached over +bulwark.jmapServerUrl, and that single outbound dependency is what shapes the +egress rules. + +AND IT IS THE ONE CHART WHERE THE EGRESS CAP CAN FAIL QUIETLY. Bulwark's probes +address its own HTTP port and say nothing about whether the JMAP server is +reachable - so a release whose JMAP URL sits on a port this policy does not +allow comes up Ready and then shows an empty, broken webmail. 443 is allowed +because that is where a published JMAP endpoint lives; a URL pointing straight +at Stalwart's in-cluster HTTP port (8080) is blocked and needs +networkPolicy.egress.extraPorts. That asymmetry is in values.yaml and NOTES.txt +because nothing else will report it. + +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 := .Values.ingress.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 because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 3000 - the only port this pod declares, targeted by both the regular and the + headless Service, and the port all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 3000 + {{- 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 + {{- /* + Outbound HTTPS. This covers both of Bulwark's outbound dependencies: the JMAP + server named by bulwark.jmapServerUrl, and the OAuth/OIDC provider. Ports only + - the URLs are hostnames, which this object cannot match. A JMAP server reached + on its in-cluster HTTP port instead (Stalwart's 8080) is NOT covered and needs + networkPolicy.egress.extraPorts. + */}} + - 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/bulwark/values.schema.json b/bulwark/values.schema.json index 0d8eb6e..e036389 100644 --- a/bulwark/values.schema.json +++ b/bulwark/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": { "description": "true renders the StatefulSet with replicas: 0 and takes precedence over bulwark.replicas. The PVC, Secrets, Service and Ingress stay in place.", "type": "boolean" diff --git a/bulwark/values.yaml b/bulwark/values.yaml index de050e0..aa294cf 100644 --- a/bulwark/values.yaml +++ b/bulwark/values.yaml @@ -816,3 +816,85 @@ httpRoute: # unregistered origin is rejected at the provider, which says nothing about # this chart. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 3000 only, and outbound DNS plus TCP 443 - + # which covers Bulwark's two outbound dependencies, the JMAP server + # (bulwark.jmapServerUrl) and the OIDC provider. + # + # WHAT IT DOES NOT DO, and this is the one to check before upgrading: 443 is + # where a PUBLISHED JMAP endpoint lives. If jmapServerUrl points straight at + # Stalwart's in-cluster HTTP port (8080) or anywhere else, that traffic is + # BLOCKED - and Bulwark's probes only test its own port, so the pod comes up + # Ready and serves a broken, empty webmail with nothing in the events to say + # why. Put the real port in egress.extraPorts below. + # + # Inbound 3000 is not restricted by source in the default (probe port), 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 3000, 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 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. + # + # 8080 is the entry this chart needs most often: a jmapServerUrl pointing at + # Stalwart's in-cluster HTTP Service rather than at its published HTTPS + # name. + extraPorts: [] diff --git a/calibre-web-automated/Chart.yaml b/calibre-web-automated/Chart.yaml index ced50e8..aee34f2 100644 --- a/calibre-web-automated/Chart.yaml +++ b/calibre-web-automated/Chart.yaml @@ -5,7 +5,7 @@ name: calibre-web-automated description: A Helm chart to deploy calibere-web-automated icon: https://raw.githubusercontent.com/crocodilestick/Calibre-Web-Automated/main/README_images/cwa-logo-round-dark.png -version: 7.1.0+upv4.0.6 +version: 8.0.0+upv4.0.6 appVersion: v4.0.6 maintainers: diff --git a/calibre-web-automated/templates/NOTES.txt b/calibre-web-automated/templates/NOTES.txt index 83f5da1..bc2bc2b 100644 --- a/calibre-web-automated/templates/NOTES.txt +++ b/calibre-web-automated/templates/NOTES.txt @@ -217,3 +217,51 @@ TIME, AND THE ENVIRONMENT THE CHART ALREADY OCCUPIES variable to disable WAL: NETWORK_SHARE_MODE=true" — so a library claim backed by NFS or SMB, which is what cwa.storageClass decides, runs into exactly the locking problems that setting exists to avoid. +{{- 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 8083 only. Outbound: DNS and TCP 443 (metadata providers){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, + plus TCP {{ join ", " . }}{{ end }}{{ end }}. A send-to-Kindle or e-mail setup needs its SMTP port + (587 or 465) in networkPolicy.egress.extraPorts — without it the send fails + silently from this side. + + 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/calibre-web-automated/templates/calibre-web-automated.networkpolicy.yaml b/calibre-web-automated/templates/calibre-web-automated.networkpolicy.yaml new file mode 100644 index 0000000..cff3cb5 --- /dev/null +++ b/calibre-web-automated/templates/calibre-web-automated.networkpolicy.yaml @@ -0,0 +1,168 @@ +{{- /* +NetworkPolicy for calibre-web-automated (Gitea #5). + +Calibre-Web-Automated keeps its library on a PVC and has no database and no +cache of its own, so the only outbound traffic that matters is metadata +lookups: cover art and book details fetched from public providers over HTTPS. + +That is why 443 is allowed and nothing else is. The failure mode if it were +not: metadata lookups return nothing, which looks like a provider being down +rather than like a policy - so the rule is here rather than left to +extraPorts. + +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 := .Values.ingress.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 because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 8083 - 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: 8083 + {{- 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 + {{- /* + Outbound HTTPS for metadata providers (covers, book details). To any + destination, because the providers are hostnames and this object matches CIDRs + and selectors only. + */}} + - 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/calibre-web-automated/values.schema.json b/calibre-web-automated/values.schema.json index 9629618..cfb24d7 100644 --- a/calibre-web-automated/values.schema.json +++ b/calibre-web-automated/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/calibre-web-automated/values.yaml b/calibre-web-automated/values.yaml index dec720a..dd8a5e5 100644 --- a/calibre-web-automated/values.yaml +++ b/calibre-web-automated/values.yaml @@ -189,3 +189,78 @@ httpRoute: # CWA is told its address by nothing, so a second name needs no second value # changed with it. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 8083 only, and outbound DNS plus TCP 443 + # for metadata lookups (covers and book details from public providers). + # + # WHAT IT DOES NOT DO: 443 goes to any destination - the providers are + # hostnames, which this object cannot match. Inbound 8083 is not restricted + # by source in the default, because it is the probe port. 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 8083, 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 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. + # + # Add a port only for a metadata provider or an e-mail/send-to-Kindle setup + # that does not use 443 - SMTP submission would be 587 or 465. + extraPorts: [] diff --git a/ci/bulwark/test-values.yaml b/ci/bulwark/test-values.yaml index aca2e3c..88b09c4 100644 --- a/ci/bulwark/test-values.yaml +++ b/ci/bulwark/test-values.yaml @@ -160,3 +160,15 @@ ingress: # also the annotation this chart's NOTES.txt tells operators to add, so the # fixture is the shape a real install has. nginx.ingress.kubernetes.io/proxy-body-size: "25m" + +# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD +# ports of its dependencies. The CI JMAP server does not: fixtures.yaml serves it over plain HTTP on 8080 rather than over HTTPS on 443 - which is exactly the asymmetry values.yaml warns about for an in-cluster jmapServerUrl. +# +# So this is not a workaround for the test: it is the chart's documented escape +# hatch, used for exactly the case values.yaml names. The fixtures are the one +# place in the repository where that case is exercised, and removing this entry +# shows what the cap costs a dependency on a non-standard port. +networkPolicy: + egress: + extraPorts: + - 8080 diff --git a/ci/networkpolicy/gotenberg.yaml b/ci/networkpolicy/gotenberg.yaml new file mode 100644 index 0000000..27237b0 --- /dev/null +++ b/ci/networkpolicy/gotenberg.yaml @@ -0,0 +1,32 @@ +# Overlay for the NetworkPolicy render test (Gitea #5), applied last. +# +# Two code paths this chart's own defaults never reach, and both are held +# against the four assertions on every pull request rather than checked once by +# hand: +# +# 1. networkPolicy.egress.remoteAssets: true - the named switch that lets +# Chromium fetch over 80/443. Its default is OFF, so the rule it renders +# existed in no render at all. It is an EGRESS rule, which is exactly why it +# is worth asserting: assertions 2 and 3 look at INGRESS rules, so a mistake +# on the egress side has no mechanical check except that the render still +# parses and the policy still holds together. +# +# 2. The source-restricted probe rule, which only appears once probeSources is +# non-empty - the same branch ci/networkpolicy/template-chart.yaml exists +# for, and the one the overlay guard demands of any chart that offers the +# value. It carries the expensive mistake: a namespaceSelector and a +# podSelector written as two list entries instead of one would be an OR. +# +# Note what this overlay does NOT do: it changes no release. gotenberg's +# install-test uses ci/gotenberg/test-values.yaml, which this file does not +# touch - the policy installed there keeps the restrictive default, because +# that is the default the convention ships. +networkPolicy: + egress: + remoteAssets: true + # Not the real node addresses of any cluster; documentation CIDRs + # (RFC 5737 TEST-NET-1) so nobody can mistake these for something to copy. + probeSources: + - 192.0.2.11/32 + allowedNamespaces: + - paperless-jk diff --git a/ci/networkpolicy/minecraft-server.yaml b/ci/networkpolicy/minecraft-server.yaml new file mode 100644 index 0000000..7456a5c --- /dev/null +++ b/ci/networkpolicy/minecraft-server.yaml @@ -0,0 +1,31 @@ +# Overlay for the NetworkPolicy render test (Gitea #5), applied last. +# +# This chart's policy has two branches that its own CI fixture never renders, +# and both carry a mistake worth catching mechanically. +# +# 1. THE NODEPORT BRANCH OF THE GAME-PORT RULE. The rule is OPEN when +# server.service.type is NodePort - players arrive from the internet, +# already masqueraded - and RESTRICTED to the namespace otherwise. The +# install-test fixture uses ClusterIP (as all four releases in this cluster +# do), so without this overlay the open branch was rendered NOWHERE: not by +# the defaults, not by the fixture, not by lint. It is also the branch where +# writing server.service.outwardPort instead of server.port would be the +# classic NodePort mistake, and assertion 2 only sees a rule it has been +# given. +# +# 2. THE RCON RULE, which is gated on server.rcon.enabled exactly as the +# containerPort is. That pairing was wrong once while this chart was being +# written - the rule had no gate, so it named a port the pod does not +# declare - and assertion 2 caught it. Switching RCON on here keeps the +# PAIR under test rather than just the gate. +# +# Note why this chart's overlay does NOT have to render a source-restricted +# probe rule, which is what the test demands of an overlay elsewhere: all three +# of this chart's probes are exec probes, so it has no probe rule at all and +# offers no networkPolicy.probeSources. The guard is conditional on that value +# for this reason. +server: + service: + type: NodePort + rcon: + enabled: true diff --git a/ci/networkpolicy/template-chart.yaml b/ci/networkpolicy/template-chart.yaml new file mode 100644 index 0000000..514a6d7 --- /dev/null +++ b/ci/networkpolicy/template-chart.yaml @@ -0,0 +1,30 @@ +# Overlay for the NetworkPolicy render test (Gitea #5), applied last. +# +# Its only job is to render a code path the chart's own defaults never reach: +# the SOURCE-RESTRICTED ingress rule. With probeSources empty - the default, and +# deliberately so - the probe port's rule carries no "from" at all, so the whole +# restricted branch of the template, the one containing the ingress-controller +# AND, the namespace list and the ipBlock peers, is never rendered and never +# checked. That branch is also the one with the expensive mistake in it: a +# namespaceSelector and a podSelector written as two list entries instead of one +# would be an OR, and would open the port to every pod in kube-system. +# +# ci/run-networkpolicy-test.sh FAILS a chart that has an overlay here and still +# renders no restricted rule. That guard is the point: without it the overlay +# could silently stop reaching the branch, and the test would stay green over a +# code path nobody entered. +# +# template-chart carries it rather than a real chart because it is installed +# nowhere (ci/excluded-charts.txt) - so the restricted form is exercised on +# every pull request with no release depending on these values. +networkPolicy: + # Not the real node addresses of any cluster; a documentation CIDR + # (RFC 5737 TEST-NET-1) so nobody can mistake these for something to copy. + probeSources: + - 192.0.2.11/32 + - 192.0.2.12/32 + allowedNamespaces: + - monitoring + egress: + extraPorts: + - 5432 diff --git a/ci/outline/test-values.yaml b/ci/outline/test-values.yaml index b9397ee..203cde7 100644 --- a/ci/outline/test-values.yaml +++ b/ci/outline/test-values.yaml @@ -8,3 +8,20 @@ ingress: # clusterIssuer is strictly required (no default); the Ingress just has to # apply - there is no cert-manager in the CI cluster. clusterIssuer: ci + +# The egress cap of the chart's NetworkPolicy (Gitea #5) allows 443 for the +# OIDC provider, because that is where an OIDC provider normally listens. The +# CI one does not: fixtures.yaml runs dex at +# http://dex.default.svc.cluster.local:5556/dex, and Outline fetches the +# issuer's discovery document at boot. +# +# So this entry is not a workaround for the test - it is the chart's documented +# escape hatch, used for exactly the case values.yaml names, and the fixture is +# the one place in the repository where that case is actually exercised. Remove +# it and the install-test shows what the cap does to a dependency on a +# non-standard port, which is the risk this convention carries into every +# release. +networkPolicy: + egress: + extraPorts: + - 5556 diff --git a/ci/patchmon/test-values.yaml b/ci/patchmon/test-values.yaml index 3abd0d9..7a38c6c 100644 --- a/ci/patchmon/test-values.yaml +++ b/ci/patchmon/test-values.yaml @@ -7,3 +7,15 @@ secrets: ingress: clusterIssuer: ci domain: patchmon.ci.local + +# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD +# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443. +# +# So this is not a workaround for the test: it is the chart's documented escape +# hatch, used for exactly the case values.yaml names. The fixtures are the one +# place in the repository where that case is exercised, and removing this entry +# shows what the cap costs a dependency on a non-standard port. +networkPolicy: + egress: + extraPorts: + - 5556 diff --git a/ci/rallly/test-values.yaml b/ci/rallly/test-values.yaml index f7badd1..818f7f4 100644 --- a/ci/rallly/test-values.yaml +++ b/ci/rallly/test-values.yaml @@ -10,3 +10,15 @@ rallly: ingress: clusterIssuer: ci domain: rallly.ci.local + +# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD +# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443. +# +# So this is not a workaround for the test: it is the chart's documented escape +# hatch, used for exactly the case values.yaml names. The fixtures are the one +# place in the repository where that case is exercised, and removing this entry +# shows what the cap costs a dependency on a non-standard port. +networkPolicy: + egress: + extraPorts: + - 5556 diff --git a/ci/run-networkpolicy-test.sh b/ci/run-networkpolicy-test.sh new file mode 100755 index 0000000..d5cf0be --- /dev/null +++ b/ci/run-networkpolicy-test.sh @@ -0,0 +1,874 @@ +#!/usr/bin/env bash +# NetworkPolicy render test: every NetworkPolicy a chart renders is held against +# the pod specs, probes and Services of the SAME render (Gitea #5). +# +# Usage: ci/run-networkpolicy-test.sh [chart ...] (no argument = all charts) +# +# WHY THIS EXISTS, AND WHY A GREEN INSTALL-TEST DOES NOT COVER IT +# +# Since #5 every chart in this repository ships a NetworkPolicy, on by default. +# That is 29 objects whose failure modes are all SILENT in the same way: the API +# server accepts a policy that filters nothing, and it accepts a policy that +# filters everything. Four mistakes are reachable by copy, and each one has a +# shape a render can decide: +# +# A SELECTOR THAT MATCHES NO POD. The object sits in the namespace, is listed +# by kubectl, reads as a boundary, and filters nothing whatsoever. The usual +# cause is a label renamed in the workload template - a different file, a +# different diff. Nothing in a rollout notices, because nothing is denied. +# A NODEPORT NUMBER WHERE A POD PORT BELONGS. A NetworkPolicy is evaluated at +# the POD's network interface, after kube-proxy has already translated the +# NodePort to the targetPort. A rule naming 30025 where the pod listens on +# 25 matches nothing: accepted, strict-looking, and - being the only rule +# for that port - blocking every inbound connection instead. Four charts +# here publish NodePorts, so this is not a hypothetical. +# AN EMPTY "from: []". Allows every source, exactly like no "from" at all, +# while reading in a diff and in kubectl output as a restriction. The one +# mistake in this list that makes a policy weaker while looking stricter. +# A RESTRICTED PROBE PORT. The kubelet's liveness/readiness/startup probes +# come from the IP of the WORKER NODE running the pod, an address no chart +# can know. A rule that restricts a probed port by source without that +# address drops every probe: the pod never becomes Ready, the rollout times +# out, and the symptom reads as a broken application rather than as a +# policy. This is the one the install-test does catch - after minutes, at +# the end of a 29-chart matrix, with its cause three steps away. +# +# The install-test is the real net under the rollout and it is not replaced +# here: it proves ENFORCEMENT, on a live k3s whose policy controller is +# running. What it cannot do is cover satisfactory and template-chart, which +# have no install-test at all (ci/excluded-charts.txt), and it cannot run on a +# pull request that touches no chart. This test is the cheap half: no cluster, +# no Docker, a few seconds for the whole repository. +# +# Over ALL charts and not the changed ones, for the same reason as +# ci/run-selector-match-test.sh and ci/run-quantity-render-test.sh: the mistake +# arrives by copy into a NEW chart, whose own diff nobody reads for it, and +# running over everything means there is no list to maintain - adding a chart +# enrols it. That property is what the first assertion below leans on, and it +# is not theoretical: garage (#81) and keycloak (#83) were both created AFTER +# #5 was filed and both arrived with no policy at all, keycloak with an +# unauthenticated management port on 9000. +# +# HOW THE YAML IS PARSED, AND WHY NOT WITH A PARSER WRITTEN HERE +# +# The runners have neither pyyaml nor yq - measured, the same constraint +# ci/run-trivy-config.sh, ci/run-quantity-render-test.sh and +# ci/run-selector-match-test.sh work under. So HELM is the parser: ci/yaml2json +# is a one-template chart that prints its values back as JSON, the rendered +# manifest stream is handed to it as a VALUES FILE, and python3 reads the result +# with its standard library. What IS done textually is only the document split, +# on a line that is exactly "---" in column one, and the consequence of getting +# that wrong is not a false pass: the fragment stops being valid YAML and helm +# exits 1. +# +# Assertion 5 is the exception and works on the raw rendered YAML on purpose: +# comments are gone by the time yaml2json has read the stream, and a comment is +# exactly what it looks for. +# +# WHAT IS DELIBERATELY NOT CHECKED, named so the gaps are not mistaken for +# coverage: +# +# - THAT TRAFFIC STILL FLOWS. Nothing here, and nothing in CI, proves the path +# from Traefik to a pod: no ingress controller runs in the install-test +# cluster. What is proven is the probe path, acceptance by the API server, +# and the render-side assertions below. +# - EGRESS DESTINATIONS. A NetworkPolicy matches CIDRs and selectors and never +# hostnames, so "reaches its database" is not expressible in the object and +# not checkable here. Egress ports are deliberately NOT held against the +# pod's containerPorts either - an egress port is a port on the DESTINATION, +# and that comparison would fail every chart that reaches a database. +# - SELECTORS EXPRESSED ONLY THROUGH matchExpressions. Evaluating a set-based +# requirement means reimplementing label-selector semantics, i.e. a second +# parser. Those are REPORTED as "not checked" and never counted as passing, +# because a green tick for a code path nobody entered is worse than an +# honest gap (README, "Two rules for every check added here"). +# - PORT RANGES (endPort). Same treatment: reported, never counted. +# +# AND THE ANTI-VACUUM GUARDS, because this test could be green over nothing: +# +# 1. A chart that renders NO NetworkPolicy fails. Since #5 that is a +# regression, not a style question - see the garage/keycloak note above. +# 2. ci/networkpolicy/.yaml is an overlay applied last, whose only job +# is to render a code path the chart's own defaults do not reach - above +# all the RESTRICTED probe rule, which is only rendered once probeSources +# is non-empty. A chart that HAS an overlay and still renders no restricted +# rule FAILS, because the overlay exists for exactly that. +# 3. If no assertion at all was evaluated across the whole run, the run fails. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CI_DIR="$ROOT/ci" +YAML2JSON="$CI_DIR/yaml2json" +OVERLAY_DIR="$CI_DIR/networkpolicy" +RELEASE="netpol" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +# --- Which charts ----------------------------------------------------------- +CHARTS=() +if [ "$#" -gt 0 ]; then + CHARTS=("$@") +else + for DIR in "$ROOT"/*/; do + [ -f "$DIR/Chart.yaml" ] || continue + CHARTS+=("$(basename "$DIR")") + done +fi +if [ "${#CHARTS[@]}" -eq 0 ]; then + echo "ERROR: no chart directories found under $ROOT" >&2 + exit 1 +fi + +FAILED="" +CHECKED="" +WITH_GAPS="" +TOTAL_ASSERTIONS=0 +SUMMARY_ROWS="" + +for CHART in "${CHARTS[@]}"; do + if [ ! -f "$ROOT/$CHART/Chart.yaml" ]; then + echo "ERROR: '$CHART' is not a chart directory" >&2 + exit 1 + fi + + # Same reason as in ci/run-selector-match-test.sh: a chart with a declared + # dependency cannot be rendered at all while charts/ is empty, and this job + # runs over EVERY chart on EVERY pull request - so without this line a PR + # touching nothing but a README would go red on the charts that have one. + "$ROOT/ci/helm-dependencies.sh" "$ROOT/$CHART" + + # Values: the same precedence the other render tests use, so a chart's CI + # values are maintained in one place and satisfy its required() values. + VALUES_ARGS=() + VALUES_LABEL="chart defaults" + if [ -f "$CI_DIR/$CHART/test-values.yaml" ]; then + VALUES_ARGS=(-f "$CI_DIR/$CHART/test-values.yaml") + VALUES_LABEL="ci/$CHART/test-values.yaml" + elif [ -f "$CI_DIR/trivy/values/$CHART.yaml" ]; then + VALUES_ARGS=(-f "$CI_DIR/trivy/values/$CHART.yaml") + VALUES_LABEL="ci/trivy/values/$CHART.yaml" + fi + HAS_OVERLAY="" + if [ -f "$OVERLAY_DIR/$CHART.yaml" ]; then + # Last, so it wins over the install-test values. + VALUES_ARGS+=(-f "$OVERLAY_DIR/$CHART.yaml") + VALUES_LABEL="$VALUES_LABEL + ci/networkpolicy/$CHART.yaml" + HAS_OVERLAY=1 + fi + + echo "::group::$CHART ($VALUES_LABEL)" + + RENDER="$WORK/$CHART.manifest.yaml" + if ! helm template "$RELEASE" "$ROOT/$CHART" \ + ${VALUES_ARGS[@]+"${VALUES_ARGS[@]}"} >"$RENDER" 2>"$WORK/err"; then + sed 's/^/ /' "$WORK/err" + echo "::endgroup::" + echo "::error title=networkpolicy $CHART::$CHART does not render with its CI values, so no policy could be checked. Reproduce: helm template $RELEASE $CHART $(printf '%s ' "${VALUES_ARGS[@]+${VALUES_ARGS[@]}}")" + FAILED="$FAILED $CHART" + continue + fi + + # --- The document split, and the only textual step before the parser ------- + # One YAML sequence item per rendered document, content indented by four and + # the item marker on a line of its own - so a document whose first line is a + # comment ("# Source: ...") needs no special case. A wrong split does not + # produce a false pass: the result stops being parsable and helm exits 1. + DOCS="$WORK/$CHART.docs.yaml" + awk ' + BEGIN { print "docs:"; started = 0 } + /^---[ \t]*$/ { print " -"; started = 1; next } + { + if (!started) { print " -"; started = 1 } + if (length($0) == 0) print ""; else print " " $0 + } + ' "$RENDER" >"$DOCS" + + JSON="$WORK/$CHART.json" + if ! helm template y2j "$YAML2JSON" -f "$DOCS" 2>"$WORK/err" \ + | awk '/^\{/ { print; exit }' >"$JSON"; then + sed 's/^/ /' "$WORK/err" + echo "::endgroup::" + echo "::error title=networkpolicy $CHART::the rendered manifests of $CHART could not be re-read as YAML by ci/yaml2json. That is a parse failure, not a policy finding: either the render contains a document this script's '---' split cut in the wrong place, or the manifest is not valid YAML. Helm's own message is in the job log." + FAILED="$FAILED $CHART" + continue + fi + if [ ! -s "$JSON" ]; then + echo "::endgroup::" + echo "::error title=networkpolicy $CHART::ci/yaml2json produced no JSON for $CHART - the manifests were not re-read, so nothing was checked." + FAILED="$FAILED $CHART" + continue + fi + + # --- The assertions -------------------------------------------------------- + # Inline and on the standard library only, as in ci/run-selector-match-test.sh + # and ci/run-quantity-render-test.sh: json, because the YAML is already gone + # by this point, plus re for the two textual checks that need the raw render. + # It writes one "VERDICT|detail" line per assertion and never decides the exit + # code itself - the loop below does, so a verdict can never be lost to a + # swallowed status. + set +e + python3 - "$CHART" "$JSON" "$RENDER" "$ROOT/$CHART" >"$WORK/$CHART.out" 2>"$WORK/$CHART.pyerr" <<'PY' +import json, re, sys + +chart, json_path, yaml_path, chart_dir = sys.argv[1:5] + +lines = [] +def ok(msg): lines.append("OK|%s: %s" % (chart, msg)) +def skip(msg): lines.append("SKIP|%s: %s" % (chart, msg)) +def fail(msg): lines.append("FAIL|%s: %s" % (chart, msg)) + +docs = [d for d in (json.load(open(json_path)).get("docs") or []) if isinstance(d, dict)] + + +def pod_templates(node, path="$"): + """Every pod template of the render, found by walking rather than by + enumerating kinds - so a CronJob's spec.jobTemplate.spec.template is covered + without a special case, and so is whatever workload kind a chart adds next. + Same shape as ci/run-selector-match-test.sh.""" + out = [] + if isinstance(node, dict): + for key, value in node.items(): + here = "%s.%s" % (path, key) + if (key == "template" and isinstance(value, dict) + and isinstance(value.get("spec"), dict) + and "containers" in value["spec"]): + out.append((here, value)) + out.extend(pod_templates(value, here)) + elif isinstance(node, list): + for i, value in enumerate(node): + out.extend(pod_templates(value, "%s[%d]" % (path, i))) + return out + + +def name_of(doc): + meta = doc.get("metadata") + if isinstance(meta, dict) and meta.get("name"): + return str(meta["name"]) + return "" % doc.get("kind", "object") + + +def labels_of(template): + meta = template.get("metadata") + labels = meta.get("labels") if isinstance(meta, dict) else None + return labels if isinstance(labels, dict) else {} + + +def matches(selector, labels): + """matchLabels only. Every key must be present with the same value; the + comparison is on the string form, because YAML true and the string "true" + are the same label value to Kubernetes.""" + for key, value in selector.items(): + if key not in labels or str(labels[key]) != str(value): + return False + return True + + +all_templates = [] +for doc in docs: + all_templates.extend(pod_templates(doc, "%s/%s" % (doc.get("kind", "?"), name_of(doc)))) + +policies = [d for d in docs if d.get("kind") == "NetworkPolicy"] +services = [d for d in docs if d.get("kind") == "Service"] + + +def container_ports(template): + """[(number, protocol, name)] over containers AND initContainers. Init + containers are included because a sidecar-style one (restartPolicy: Always) + keeps listening for the life of the pod, and its port is as real as any + other.""" + out = [] + spec = template.get("spec") or {} + for key in ("containers", "initContainers"): + for container in spec.get(key) or []: + if not isinstance(container, dict): + continue + for port in container.get("ports") or []: + if not isinstance(port, dict) or port.get("containerPort") is None: + continue + out.append((int(port["containerPort"]), + str(port.get("protocol") or "TCP"), port.get("name"))) + return out + + +def probe_ports(template): + """Ports a NETWORK probe addresses, as {number: [who]}. + + exec probes are deliberately absent, and that distinction is the whole + reason this is a function rather than a grep for "Probe": an exec probe runs + inside the container through the kubelet's runtime and puts no packet on the + network at all. A chart with exec-only probes (valkey, minecraft-server) can + therefore restrict its port by source with no probeSources whatsoever, and + treating its probe as network traffic would demand a value it does not need + and would weaken the policy it can have.""" + found = {} + by_name = dict((n, num) for num, _p, n in container_ports(template) if n) + spec = template.get("spec") or {} + for key in ("containers", "initContainers"): + for container in spec.get(key) or []: + if not isinstance(container, dict): + continue + for probe_key in ("livenessProbe", "readinessProbe", "startupProbe"): + probe = container.get(probe_key) + if not isinstance(probe, dict): + continue + for handler in ("httpGet", "tcpSocket", "grpc"): + handler_spec = probe.get(handler) + if not isinstance(handler_spec, dict): + continue + raw = handler_spec.get("port") + number = None + if isinstance(raw, int): + number = raw + elif isinstance(raw, str) and raw.isdigit(): + number = int(raw) + elif isinstance(raw, str) and raw in by_name: + number = by_name[raw] + elif isinstance(raw, str): + skip("%s of container %s names port %r, which is no " + "containerPort of the same pod spec - the probe port " + "could not be resolved, so the probe path was not " + "checked for it. Reported, not counted as passing." + % (probe_key, container.get("name"), raw)) + continue + if number is not None: + found.setdefault(number, []).append( + "%s.%s/%s" % (container.get("name"), probe_key, handler)) + return found + + +def nodeport_pod_ports(): + """Pod ports published through a NodePort Service of this render, as + {pod port: [service:nodePort]}. Traffic on these arrives from OUTSIDE the + cluster and is already masqueraded by the time it reaches the pod, so the + source address is a node and not the real client - which is why a rule for + such a port legitimately carries no source selector (assertion 3).""" + out = {} + for svc in services: + spec = svc.get("spec") or {} + if spec.get("type") != "NodePort" or not isinstance(spec.get("selector"), dict): + continue + hits = [t for _w, t in all_templates if matches(spec["selector"], labels_of(t))] + by_name = dict((n, num) for t in hits for num, _p, n in container_ports(t) if n) + for port in spec.get("ports") or []: + if not isinstance(port, dict) or not port.get("nodePort"): + continue + target = port.get("targetPort") + if target is None: + target = port.get("port") + number = None + if isinstance(target, int): + number = target + elif isinstance(target, str): + number = int(target) if target.isdigit() else by_name.get(target) + if number is not None: + out.setdefault(number, []).append( + "%s:%s" % (name_of(svc), port.get("nodePort"))) + return out + + +nodeports = nodeport_pod_ports() +raw = open(yaml_path, encoding="utf-8").read() + + +def read(path): + try: + return open(path, encoding="utf-8").read() + except IOError: + return "" + + +notes = read("%s/templates/NOTES.txt" % chart_dir) +values_yaml = read("%s/values.yaml" % chart_dir) +# The KEY, not the word. A substring test here passed a chart whose +# probeSources key had been deleted while the long comment above it still +# talked about the value - found by running this assertion's failure path, which +# is the reason the repository runs them (README, "Two rules for every check +# added here"). +offers_probe_sources = bool(re.search(r"^\s*probeSources\s*:", values_yaml, re.M)) +# The phrase the convention requires NOTES.txt to carry, so that an operator who +# narrows probeSources has been told what a wrong CIDR costs. A phrase and not a +# vague "mentions probes", because the sentence that matters is the consequence. +notes_warns = bool(re.search(r"never becomes? ready", notes, re.I)) and "probeSources" in notes + +RESTRICTED_RULES = 0 +# Counted separately from RESTRICTED_RULES, and the overlay guard uses THIS one: +# a chart like keycloak restricts other rules by source in its own defaults, so +# "some rule has a from" would satisfy the guard without the probe branch ever +# being rendered - which is the only branch an overlay exists to reach. +PROBE_RESTRICTED = 0 + +# --- Assertion 0: the chart ships a policy at all --------------------------- +if not policies: + fail("renders no NetworkPolicy at all. Since Gitea #5 every chart in this " + "repository ships one, on by default, and this is the guard that keeps a " + "NEW chart from arriving without one - which is exactly how garage and " + "keycloak came to have none, both created after #5 was filed. Copy " + "templates/xxx.networkpolicy.yaml and the networkPolicy block from " + "template-chart, or change this test on purpose if the chart truly has " + "nothing to restrict.") + +for policy in policies: + tag = "NetworkPolicy %s" % name_of(policy) + spec = policy.get("spec") if isinstance(policy.get("spec"), dict) else {} + policy_types = spec.get("policyTypes") + if not isinstance(policy_types, list): + policy_types = [] + + # --- Assertion 1: the podSelector hits a pod spec of the SAME render ---- + selector = spec.get("podSelector") + selected = [] + if selector is None: + fail("%s has no podSelector. That field is required by the API - a policy " + "without it is rejected - and 'podSelector: {}' is the spelling that " + "selects EVERY pod in the namespace." % tag) + elif not isinstance(selector, dict): + fail("%s podSelector is not a mapping (%r)" % (tag, selector)) + elif selector.get("matchExpressions") and not selector.get("matchLabels"): + skip("%s selects only through matchExpressions, which this test does not " + "evaluate - doing so would mean reimplementing label-selector " + "semantics, i.e. a second parser. Reported, not counted as passing." + % tag) + selected = [t for _w, t in all_templates] + else: + match_labels = selector.get("matchLabels") or {} + hits = [(w, t) for w, t in all_templates if matches(match_labels, labels_of(t))] + selected = [t for _w, t in hits] + if not match_labels: + ok("%s has an empty podSelector, which selects every pod in the " + "namespace - legitimate for a namespace-wide policy, as long as " + "that is what was meant." % tag) + selected = [t for _w, t in all_templates] + elif hits: + ok("%s podSelector %s matches %d pod template(s): %s" + % (tag, ", ".join("%s=%s" % kv for kv in sorted(match_labels.items())), + len(hits), ", ".join(w for w, _t in hits))) + else: + known = sorted(set("%s=%s" % kv for _w, t in all_templates + for kv in labels_of(t).items())) + fail("%s podSelector selects %s, which matches NONE of the %d pod " + "template(s) in the same render. The object is then accepted, is " + "listed by kubectl, reads as a boundary and filters nothing at " + "all - and nothing else in this pipeline notices, because nothing " + "is denied. The usual cause is a label renamed in the workload " + "template. Labels actually present on the pod templates: %s" + % (tag, ", ".join("%s=%s" % kv for kv in sorted(match_labels.items())), + len(all_templates), "; ".join(known) or "none")) + + declared = {} + for t in selected: + for number, proto, pname in container_ports(t): + declared.setdefault(number, set()).add(proto) + if pname: + declared.setdefault(pname, set()).add(proto) + probed = {} + for t in selected: + for number, who in probe_ports(t).items(): + probed.setdefault(number, []).extend(who) + + def rule_port_numbers(rule): + """The pod ports a rule names, resolved through the selected pods' port + names. Returns (numbers, unresolved_names).""" + numbers, unresolved = set(), [] + for port in rule.get("ports") or []: + if not isinstance(port, dict): + continue + value = port.get("port") + if isinstance(value, int): + numbers.add(value) + elif isinstance(value, str) and value.isdigit(): + numbers.add(int(value)) + elif isinstance(value, str): + hit = [num for t in selected for num, _p, n in container_ports(t) + if n == value] + if hit: + numbers.update(hit) + else: + unresolved.append(value) + return numbers, unresolved + + # --- Assertion 2: every INGRESS port is a containerPort of those pods --- + # Ingress only, and that restriction is not an oversight: an egress rule's + # port is a port on the DESTINATION and has nothing to do with this pod's + # containerPorts, so holding egress against them would fail every chart that + # reaches a database. + for i, rule in enumerate(spec.get("ingress") or []): + if not isinstance(rule, dict): + fail("%s ingress[%d] is not a mapping" % (tag, i)) + continue + for j, port in enumerate(rule.get("ports") or []): + if not isinstance(port, dict): + fail("%s ingress[%d].ports[%d] is not a mapping" % (tag, i, j)) + continue + if port.get("endPort") is not None: + skip("%s ingress[%d].ports[%d] is a port RANGE (endPort %s), which " + "this test does not expand. Reported, not counted as passing." + % (tag, i, j, port.get("endPort"))) + continue + value = port.get("port") + if value is None: + skip("%s ingress[%d].ports[%d] names no port, which allows EVERY " + "port for the given protocol. Reported, not counted as passing " + "- and worth a second look, because it is a far wider rule " + "than it looks." % (tag, i, j)) + continue + key = int(value) if isinstance(value, int) or ( + isinstance(value, str) and value.isdigit()) else value + if not selected: + fail("%s ingress[%d].ports[%d] names port %r, but the policy selects " + "no pod template at all, so no containerPort could be checked." + % (tag, i, j, value)) + continue + if key in declared: + proto = str(port.get("protocol") or "TCP") + if proto in declared[key]: + ok("%s ingress[%d] port %s/%s is a containerPort of the selected " + "pod spec" % (tag, i, value, proto)) + else: + fail("%s ingress[%d].ports[%d] allows %s/%s, but the selected pod " + "spec declares that port only as %s. A policy written for " + "the wrong protocol filters none of the traffic that " + "actually arrives." + % (tag, i, j, value, proto, "/".join(sorted(declared[key])))) + else: + numeric = sorted(k for k in declared if isinstance(k, int)) + named = sorted(str(k) for k in declared if not isinstance(k, int)) + hint = "" + if isinstance(key, int) and 30000 <= key <= 32767: + hint = (" THAT NUMBER IS IN THE NODEPORT RANGE, which makes this " + "almost certainly the NodePort mistake: a NetworkPolicy " + "is evaluated at the POD's interface, after kube-proxy " + "has already translated the NodePort to the targetPort. " + "The rule therefore matches nothing while looking strict " + "- and being the only rule for that port, it blocks every " + "inbound connection instead. Use the containerPort.") + fail("%s ingress[%d].ports[%d] allows port %r, which is NOT a " + "containerPort of any pod spec this policy selects.%s " + "containerPorts actually declared: %s%s" + % (tag, i, j, value, hint, + ", ".join(str(n) for n in numeric) or "none", + (" (names: %s)" % ", ".join(named)) if named else "")) + + # --- Assertion 3: no ingress rule that silently allows every source ----- + # The plan's wording was "no rule with a missing or empty from". Implemented + # in the two halves it really has, because the precedents deliberately use + # one of them: + # + # from: [] - present but EMPTY. Always a failure. It allows every source, + # exactly like no key at all, while reading as a restriction. + # There is no case where it is the right way to write either + # intention. + # no "from" - the documented open form; valkey and stalwart both use it on + # purpose. Accepted ONLY where the render itself justifies + # being open: every port of the rule is addressed by a network + # probe of the selected pods (the probe comes from the node's + # IP, which no chart can know), or is published by a NodePort + # Service of the same render (the source is outside the cluster + # and already masqueraded). Anything else is an allow-all + # nobody chose, which is the case this assertion exists for. + if "Ingress" in policy_types or (spec.get("ingress") and not policy_types): + for i, rule in enumerate(spec.get("ingress") or []): + if not isinstance(rule, dict): + continue + if "from" in rule and rule["from"] == []: + fail("%s ingress[%d] has an EMPTY \"from: []\". That allows every " + "source - identically to having no \"from\" at all - while " + "reading in a diff and in kubectl output as a restriction. It " + "is the one mistake in this class that makes a policy weaker " + "while looking stricter. Write no \"from\" key where open is " + "meant, and say so in a template comment, or spell the sources " + "out." % (tag, i)) + continue + if "from" in rule: + sources = rule["from"] + if not isinstance(sources, list): + fail("%s ingress[%d].from is not a list (%r)" % (tag, i, sources)) + continue + empty = [k for k, s in enumerate(sources) if isinstance(s, dict) and not s] + if empty: + fail("%s ingress[%d].from contains the empty peer {} at index %s, " + "which matches every pod in every namespace and makes the " + "whole rule open while the surrounding list looks specific." + % (tag, i, ", ".join(str(k) for k in empty))) + else: + RESTRICTED_RULES += 1 + ok("%s ingress[%d] restricts by source (%d peer(s))" + % (tag, i, len(sources))) + continue + numbers, unresolved = rule_port_numbers(rule) + if unresolved: + skip("%s ingress[%d] names port(s) %s that resolve to no " + "containerPort, so this rule's openness could not be judged. " + "Reported, not counted as passing." + % (tag, i, ", ".join(unresolved))) + continue + if not numbers: + fail("%s ingress[%d] has neither \"from\" nor \"ports\". That is the " + "widest rule the object can express - every port from every " + "source - and it makes every other rule in the policy " + "decorative." % (tag, i)) + continue + unjustified = sorted(n for n in numbers + if n not in probed and n not in nodeports) + if unjustified: + fail("%s ingress[%d] carries no \"from\", so it is open to every " + "source the CNI routes to, and port(s) %s justify neither: no " + "network probe of the selected pods addresses them, and no " + "NodePort Service of this render publishes them. Those are the " + "only two reasons this convention leaves a rule open - the " + "kubelet probes from the node's own IP, which a chart cannot " + "know, and NodePort traffic arrives already masqueraded. " + "Restrict this rule by source, or split the genuinely open " + "ports into their own rule and say in a template comment why " + "they are open." % (tag, i, ", ".join(str(n) for n in unjustified))) + else: + why = [] + for n in sorted(numbers): + if n in probed: + why.append("%d (probe path: %s)" % (n, ", ".join(probed[n]))) + else: + why.append("%d (NodePort %s)" % (n, ", ".join(nodeports[n]))) + ok("%s ingress[%d] is deliberately open, and the render justifies " + "it: %s" % (tag, i, "; ".join(why))) + + # --- Assertion 4: the probe path ----------------------------------------- + # For every port a NETWORK probe addresses, the policy either does not + # restrict it by source, or the chart offers probeSources AND its NOTES.txt + # says what a wrong CIDR costs. The most dangerous trap over 29 charts, and + # the only one the install-test shows only after minutes - as a rollout + # timeout whose cause is three steps away. + if selected and not probed: + ok("%s selects pods that have no network probe at all (exec probes, or " + "none), so no probe traffic crosses the network and every port may be " + "restricted by source without probeSources." % tag) + for port_number, who in sorted(probed.items()): + restricted, covered = [], False + for i, rule in enumerate(spec.get("ingress") or []): + if not isinstance(rule, dict): + continue + numbers, _unresolved = rule_port_numbers(rule) + if not rule.get("ports"): + numbers = set([port_number]) # a rule without ports covers all of them + if port_number not in numbers: + continue + covered = True + if rule.get("from"): + restricted.append(i) + if "Ingress" not in policy_types and not (spec.get("ingress") and not policy_types): + ok("%s does not restrict the Ingress direction at all, so the probe on " + "port %d (%s) is unaffected." % (tag, port_number, ", ".join(who))) + continue + if not covered: + fail("%s selects a pod whose probes address port %d (%s), but NO ingress " + "rule of this policy allows that port. With policyTypes Ingress the " + "kubelet's probe is then dropped and THE POD NEVER BECOMES READY - " + "the rollout times out and the symptom reads as a broken " + "application rather than as a policy." + % (tag, port_number, ", ".join(who))) + continue + if not restricted: + ok("%s leaves probe port %d (%s) unrestricted by source, which is the " + "safe default: the kubelet probes from the IP of the node running " + "the pod, and no chart can know it." + % (tag, port_number, ", ".join(who))) + continue + if offers_probe_sources and notes_warns: + PROBE_RESTRICTED += 1 + ok("%s restricts probe port %d (%s) by source in ingress[%s], and the " + "chart carries what the convention requires with it: " + "networkPolicy.probeSources in values.yaml and the consequence " + "spelled out in NOTES.txt." + % (tag, port_number, ", ".join(who), + ", ".join(str(r) for r in restricted))) + continue + missing = [] + if not offers_probe_sources: + missing.append("values.yaml offers no networkPolicy.probeSources") + if not notes_warns: + missing.append("templates/NOTES.txt does not say that a probeSources list " + "missing the node's IP leaves the pod permanently un-Ready " + "(this test looks for the phrase \"never becomes Ready\" " + "together with the value's name)") + fail("%s restricts probe port %d (%s) by source in ingress[%s] while %s. A " + "restricted probe port is only defensible when the operator can widen " + "it AND has been told what happens if the CIDR is wrong: the kubelet " + "probes from the IP of the WORKER NODE running the pod, so a list that " + "misses it drops every probe and the pod never becomes Ready." + % (tag, port_number, ", ".join(who), + ", ".join(str(r) for r in restricted), " and ".join(missing))) + +# --- Assertion 5: no reasoning left in the rendered manifest ---------------- +# Not one of the original four, and added because it hit several rounds of this +# rollout: 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 puts +# 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() == "---": + 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:"): + offenders.append(stripped) + current = [] + else: + current.append(line) + if offenders: + fail("the rendered NetworkPolicy manifest(s) contain %d \"#\" comment line(s), " + "which are part of the OBJECT and are shipped to the cluster: they appear " + "in `helm get manifest` and in `kubectl get -o yaml`, and they put the " + "chart's reasoning into every release diff. Move them into Go template " + "comments, which render nothing. First offender: %s" + % (len(offenders), offenders[0][:160])) + else: + ok("the rendered NetworkPolicy manifest(s) carry no \"#\" comment - all " + "reasoning stayed in template comments") + +print("OFFERSPS|%d" % (1 if offers_probe_sources else 0)) +print("RESTRICTED|%d" % RESTRICTED_RULES) +print("PROBERESTRICTED|%d" % PROBE_RESTRICTED) +for line in lines: + print(line) +PY + RC=$? + set -e + if [ "$RC" -gt 1 ]; then + sed 's/^/ /' "$WORK/$CHART.pyerr" + echo "::endgroup::" + echo "::error title=networkpolicy $CHART::the policy comparison itself failed for $CHART (python exit $RC). Treated as a failure, never as a pass." + FAILED="$FAILED $CHART" + continue + fi + + CHART_FAIL="" + CHART_OK=0 + CHART_SKIP=0 + CHART_RESTRICTED=0 + CHART_PROBE_RESTRICTED=0 + CHART_OFFERS_PS=0 + while IFS='|' read -r VERDICT DETAIL; do + case "$VERDICT" in + OK) + CHART_OK=$((CHART_OK + 1)) + echo " ok $DETAIL" + ;; + SKIP) + CHART_SKIP=$((CHART_SKIP + 1)) + echo " gap $DETAIL" + ;; + RESTRICTED) + CHART_RESTRICTED="$DETAIL" + ;; + PROBERESTRICTED) + CHART_PROBE_RESTRICTED="$DETAIL" + ;; + OFFERSPS) + CHART_OFFERS_PS="$DETAIL" + ;; + FAIL) + CHART_FAIL=1 + echo " FAIL $DETAIL" + echo "::error title=networkpolicy $CHART::$DETAIL" + ;; + *) + [ -n "$VERDICT" ] && echo " $VERDICT $DETAIL" + ;; + esac + done <"$WORK/$CHART.out" + echo "::endgroup::" + + TOTAL_ASSERTIONS=$((TOTAL_ASSERTIONS + CHART_OK)) + + if [ -n "$CHART_FAIL" ]; then + FAILED="$FAILED $CHART" + SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK | $CHART_SKIP |"$'\n' + echo "$CHART: FAILED" + elif [ -n "$HAS_OVERLAY" ] && [ "$CHART_OFFERS_PS" -eq 1 ] && [ "$CHART_PROBE_RESTRICTED" -eq 0 ]; then + # The overlay's ONE job is to render a code path the chart's defaults do not + # reach - above all the RESTRICTED probe rule, which only appears once + # probeSources is non-empty. If no restricted rule came out, this test is + # vacuum-green for the very chart the overlay was written for. + FAILED="$FAILED $CHART" + SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | **failed** | $CHART_OK | $CHART_SKIP |"$'\n' + echo "::error title=networkpolicy $CHART::$CHART has an overlay at ci/networkpolicy/$CHART.yaml but rendered no source-restricted PROBE rule, so the restricted branch - the one the chart's own defaults never reach - was not checked. That overlay exists for exactly this; either it no longer sets networkPolicy.probeSources where the chart reads it, or the template stopped rendering the restricted form. A green tick here would be a tick for a check that ran over nothing." + echo "$CHART: FAILED (overlay present, no restricted probe rule rendered)" + else + CHECKED="$CHECKED $CHART" + [ "$CHART_SKIP" -gt 0 ] && WITH_GAPS="$WITH_GAPS $CHART" + SUMMARY_ROWS="${SUMMARY_ROWS}| \`$CHART\` | ok | $CHART_OK | $CHART_SKIP |"$'\n' + echo "$CHART: ok ($CHART_OK assertion(s) held, $CHART_SKIP not checked, $CHART_RESTRICTED source-restricted rule(s), $CHART_PROBE_RESTRICTED of them on a probe port)" + fi +done + +# --- Report ------------------------------------------------------------------ +count() { set -- $1; echo "$#"; } +listing() { set -- $1; [ "$#" -eq 0 ] && { echo "–"; return; }; printf '`%s`' "$1"; shift; for C in "$@"; do printf ', `%s`' "$C"; done; echo; } +SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}" +{ + echo "### NetworkPolicy render test (Gitea #5)" + echo + echo "Every \`NetworkPolicy\` a chart renders, held against the pod specs, probes and" + echo "Services of the **same** render. All of these failures are silent: the API server" + echo "accepts a policy that filters nothing just as readily as one that filters" + echo "everything." + echo + echo "| Result | Count | Charts |" + echo "|---|---|---|" + echo "| Policies checked | $(count "$CHECKED") | $(listing "$CHECKED") |" + echo "| Failed | $(count "$FAILED") | $(listing "$FAILED") |" + echo + echo "$TOTAL_ASSERTIONS assertion(s) held in total." + echo + echo "**Not covered here, and not by anything else in CI:** that traffic still flows." + echo "No ingress controller runs in the install-test cluster, so the path from Traefik" + echo "to a pod is proven nowhere. What *is* proven is the probe path (install-test)," + echo "acceptance by the API server, and the render-side assertions above." + if [ -n "$WITH_GAPS" ]; then + echo + echo "Charts with code paths this test does **not** evaluate (\`matchExpressions\`-only selectors, port ranges, rules naming no port — reported, never counted as passing): $(listing "$WITH_GAPS")." + fi + if [ -n "$SUMMARY_ROWS" ]; then + echo + echo "| Chart | Result | Held | Not checked |" + echo "| --- | --- | --- | --- |" + printf '%s' "$SUMMARY_ROWS" + fi +} >>"$SUMMARY" + +echo "===================== networkpolicy test =======================" +echo "checked: $(count "$CHECKED")${CHECKED:+ -$CHECKED}" +echo "failed: $(count "$FAILED")${FAILED:+ -$FAILED}" +echo "assertions held: $TOTAL_ASSERTIONS" +echo "================================================================" + +if [ -n "$FAILED" ]; then + echo "ERROR: NetworkPolicy findings in:$FAILED" >&2 + exit 1 +fi + +# The third anti-vacuum guard. Reaching zero means no chart rendered a policy +# this script could say anything about, which is indistinguishable from a broken +# harness - and it is the state this test would silently decay into. +if [ "$TOTAL_ASSERTIONS" -eq 0 ]; then + echo "ERROR: not a single assertion was evaluated in this run. Either no chart renders a NetworkPolicy any more, or the harness stopped reading the manifests. A pass over zero assertions is not a pass." >&2 + exit 1 +fi diff --git a/ci/urlaubsverwaltung/test-values.yaml b/ci/urlaubsverwaltung/test-values.yaml index 8b2dd38..1732158 100644 --- a/ci/urlaubsverwaltung/test-values.yaml +++ b/ci/urlaubsverwaltung/test-values.yaml @@ -7,3 +7,15 @@ ingress: # clusterIssuer is strictly required (no default); the Ingress just has to # apply - there is no cert-manager in the CI cluster. clusterIssuer: ci + +# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD +# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443 - and this application has no login path besides OIDC. +# +# So this is not a workaround for the test: it is the chart's documented escape +# hatch, used for exactly the case values.yaml names. The fixtures are the one +# place in the repository where that case is exercised, and removing this entry +# shows what the cap costs a dependency on a non-standard port. +networkPolicy: + egress: + extraPorts: + - 5556 diff --git a/ci/voucher-vault/test-values.yaml b/ci/voucher-vault/test-values.yaml index f15e77e..2aaf667 100644 --- a/ci/voucher-vault/test-values.yaml +++ b/ci/voucher-vault/test-values.yaml @@ -6,3 +6,15 @@ secrets: ingress: clusterIssuer: ci domain: vv.ci.local + +# The egress cap of this chart's NetworkPolicy (Gitea #5) allows the STANDARD +# ports of its dependencies. The CI OIDC provider does not: fixtures.yaml runs dex on 5556 rather than on 443. +# +# So this is not a workaround for the test: it is the chart's documented escape +# hatch, used for exactly the case values.yaml names. The fixtures are the one +# place in the repository where that case is exercised, and removing this entry +# shows what the cap costs a dependency on a non-standard port. +networkPolicy: + egress: + extraPorts: + - 5556 diff --git a/cyberchef/Chart.yaml b/cyberchef/Chart.yaml index 05c1740..7b6dec9 100644 --- a/cyberchef/Chart.yaml +++ b/cyberchef/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/gchq/CyberChef/master/src/web/static/ima type: application -version: 9.1.0+up11.4.0 +version: 10.0.0+up11.4.0 appVersion: "11.4.0" maintainers: diff --git a/cyberchef/templates/NOTES.txt b/cyberchef/templates/NOTES.txt index 21d0f91..1e4dd6d 100644 --- a/cyberchef/templates/NOTES.txt +++ b/cyberchef/templates/NOTES.txt @@ -108,3 +108,49 @@ cyberchef.env HAS NO EFFECT ON THE APP files, but the served CyberChef bundle is static and reads none of them — configuration has to happen in the browser. {{- 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 8080 only, the single port this pod declares. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} + plus TCP {{ join ", " . }}{{ end }}{{ end }} — a static file server needs nothing more. + + 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 }}-deployment + + 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/cyberchef/templates/cyberchef.networkpolicy.yaml b/cyberchef/templates/cyberchef.networkpolicy.yaml new file mode 100644 index 0000000..5f95b85 --- /dev/null +++ b/cyberchef/templates/cyberchef.networkpolicy.yaml @@ -0,0 +1,167 @@ +{{- /* +NetworkPolicy for cyberchef (Gitea #5). + +CyberChef is a static web app: nginx serves files, the recipes run in the +visitor's browser, and the pod holds no state and talks to nothing. That makes +this the simplest policy in the repository and a good one to read first. + +What it is worth here is almost entirely the two CEILINGS rather than any +source restriction: only 8080 is reachable, and outbound traffic is capped at +DNS. The pod has no reason to open a single outbound connection - it serves +static files - so the egress cap is as tight as the object allows. Were this +image ever to gain a second listener, or were the container ever compromised, +neither could be reached or could reach out. + +The ingress direction is NOT restricted by source in the default, and that is +not an oversight: 8080 is the port all three probes address. See the probe note +on the rule below. + +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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict, and here the + restriction is close to total: a static file server needs name resolution and + nothing else, so DNS is the only rule. That is as tight as this object can be + made without breaking the pod. + */}} + - Egress + ingress: + {{- /* + 8080 - the only port this pod declares, the one the Service targets, and the + one all three probes address. It is therefore the probe path, and the shape + below is the whole reason networkPolicy.probeSources exists. + */}} + - ports: + - protocol: TCP + port: 8080 + {{- 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 (and the + HTTPRoute - the same Traefik serves both here) 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 + {{- 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/cyberchef/values.schema.json b/cyberchef/values.schema.json index 934bab5..6aace01 100644 --- a/cyberchef/values.schema.json +++ b/cyberchef/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/cyberchef/values.yaml b/cyberchef/values.yaml index 1469dcc..b2113d0 100644 --- a/cyberchef/values.yaml +++ b/cyberchef/values.yaml @@ -125,3 +125,81 @@ httpRoute: # is how a migration from the Ingress runs: the HTTPRoute takes a second # hostname while the Ingress keeps ingress.domain. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 8080 only - the single port this pod + # declares - and outbound nothing but DNS. CyberChef is a static web app + # whose recipes run in the browser, so the pod has no reason to open an + # outbound connection at all, which makes the egress cap unusually tight. + # + # WHAT IT DOES NOT DO: the 8080 rule is NOT restricted by source in the + # default, because 8080 is the port all three probes address - see + # probeSources below. So this policy is a port and egress ceiling, not + # pod-level isolation. And it is only a boundary where the CNI enforces + # policies; unenforced, it is accepted and filters nothing. + 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 8080, 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. + # + # Nothing is needed here for CyberChef itself. Add 443 only if something + # is put in front of the pod that has to call out. + extraPorts: [] 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/garage/Chart.yaml b/garage/Chart.yaml index 6d236ec..a262667 100644 --- a/garage/Chart.yaml +++ b/garage/Chart.yaml @@ -19,7 +19,7 @@ type: application # offer those as "updates", which is why the garage block in renovate.json5 # carries a versioningTemplate anchored to ^v..$ - same # shape as the stalwart block, for the same reason. -version: 1.1.0+upv2.4.1 +version: 2.0.0+upv2.4.1 appVersion: "v2.4.1" maintainers: diff --git a/garage/templates/NOTES.txt b/garage/templates/NOTES.txt index 5bf2122..4e093ba 100644 --- a/garage/templates/NOTES.txt +++ b/garage/templates/NOTES.txt @@ -204,3 +204,63 @@ TRAPS WORTH KNOWING BEFORE YOU NEED THEM - THE RPC SECRET IS IN THE PROCESS ENVIRONMENT, not in a file. That is forced, not chosen: Garage rejects a secret file with any group bit set, and the kubelet sets one. values.yaml has the mechanism. +{{- 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, and the four ports split three ways: + - s3 ({{ $settings.ports.s3 | int64 }}) and web ({{ $settings.ports.web | int64 }}): RESTRICTED BY SOURCE — this namespace, + networkPolicy.allowedNamespaces, and the ingress controller. + - admin ({{ $settings.ports.admin | int64 }}): open inside the cluster, because all three probes address + it. This is the ADMIN API, so read that as a smaller ceiling rather than + a closed door; networkPolicy.probeSources closes the rest. + - rpc ({{ $settings.ports.rpc | int64 }}): this release's own pods only. It has no Service. + Outbound: DNS and TCP {{ $settings.ports.rpc | int64 }} (the cluster bus, outbound as well as in){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, + plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + CHECK YOUR S3 CONSUMERS. Anything in another namespace that talks to this + endpoint on its in-cluster Service address — a backup job, a Zipline storing + uploads, a Stalwart blob store — is blocked until that namespace is in + networkPolicy.allowedNamespaces. Consumers using the published Ingress name + arrive through the controller and are already covered. The failure is an + application-level error with nothing pointing at this object. + + 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/garage/templates/garage.networkpolicy.yaml b/garage/templates/garage.networkpolicy.yaml new file mode 100644 index 0000000..f5a989b --- /dev/null +++ b/garage/templates/garage.networkpolicy.yaml @@ -0,0 +1,232 @@ +{{- /* +NetworkPolicy for garage (Gitea #5). + +Garage declares four ports and they split three ways, which makes this the +second-clearest illustration of the convention after keycloak: + + s3 (3900) the S3 API. The Ingress points at it and no probe touches it, + so it is RESTRICTED by source. + web (3902) static website serving, same treatment as s3. + admin (3903) the port all three probes address - tcpSocket twice and a + httpGet /health - so it stays open inside the cluster. This is + also the admin API, which is the uncomfortable part and is + named in values.yaml: narrowing it means probeSources. + rpc (3901) the node-to-node bus. It has NO Service at all, deliberately + (see garage.service.yaml), and it gets the narrowest rule this + object can express: the pods this policy itself selects. + +All four numbers are derived from garage.settings, so moving a port in values +moves the policy with it. + +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 }} +{{- $settings := fromYaml (include "garage.settings" .) }} +{{- $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 it is narrow, which is the point: a storage node's only + outbound traffic is DNS and its own cluster bus. Not permitted, and named in + values.yaml because it is the plausible surprise: outbound HTTPS. Garage does + not need it for S3 serving, but a deployment that configures remote replication + or an external metrics sink does. + */}} + - Egress + ingress: + {{- /* + THE S3 API AND THE STATIC WEB PORT, both genuinely restricted by source, + because no probe addresses either of them: this release's own namespace, + whatever allowedNamespaces adds, and the ingress controller. + + THE SOURCE LIST IS THE THING TO GET RIGHT HERE. Every consumer of this S3 + endpoint that lives in another namespace - a backup job, a Zipline using it for + uploads, a Stalwart blob store - is a pod in that namespace and is BLOCKED + unless the namespace is in allowedNamespaces. Consumers reaching the published + Ingress name instead arrive through the controller and are already covered. A + blocked S3 consumer fails at the application level, with nothing naming this + object. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.s3 | int64 }} + - protocol: TCP + port: {{ $settings.ports.web | int64 }} + 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 }} + {{- /* + THE ADMIN PORT, and it is the probe path: liveness and startup open a TCP + socket against it and readiness fetches /health from it. So it stays open + inside the cluster by default. + + That is worth stating rather than glossing, because this is the ADMIN API: key + management and cluster layout live here. The policy caps it at "reachable from + inside the cluster", not at "closed", and probeSources is what closes the rest - + at the usual price of needing the worker node IPs. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.admin | int64 }} + {{- 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. + + No ingress-controller entry on this rule: nothing should route to the admin API + from outside. + */}} + 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 }} + {{- 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 }} + {{- /* + THE NODE-TO-NODE RPC BUS, restricted to the pods this policy already selects - + a podSelector with no namespaceSelector, which means "these labels, in this + namespace". + + This port deliberately has no Service (garage.service.yaml says why: a Service + port would advertise a cluster interface that is not one), so this rule is the + only description of it anywhere in the chart. The matching egress rule below is + what lets a node dial OUT to its peers. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.rpc | int64 }} + from: + - podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs + 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 + {{- /* + Outbound RPC to the other nodes of this cluster. Needed as well as the inbound + rule: policyTypes lists Egress, so a node dialling its peer is subject to this + policy too, and with only the inbound rule the bus would be permitted in one + direction - a layout that never converges while every node looks healthy. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.rpc | int64 }} + {{- 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/garage/values.schema.json b/garage/values.schema.json index f93c353..3248d4b 100644 --- a/garage/values.schema.json +++ b/garage/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": { "description": "true renders the StatefulSet with replicas: 0 and takes precedence over garage.replicas.", "type": "boolean" diff --git a/garage/values.yaml b/garage/values.yaml index 8603506..36c5069 100644 --- a/garage/values.yaml +++ b/garage/values.yaml @@ -501,3 +501,92 @@ httpRoute: # garage.s3.rootDomain - the two still have to be set in step, as they do for # the Ingress. hostnames: [] + +# --------------------------------------------------------------------------- +# 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, and Garage's four ports split three ways: + # - s3 (3900) and web (3902): RESTRICTED BY SOURCE to this namespace, + # allowedNamespaces and the ingress controller. No probe touches them, + # which is what makes the restriction possible. + # - admin (3903): open inside the cluster, because all three probes address + # it. This is the ADMIN API - key management and cluster layout - so the + # cap is "reachable from inside the cluster", not "closed". probeSources + # closes the rest. + # - rpc (3901): this release's own pods only. It has no Service at all. + # Outbound: DNS and the rpc bus, nothing else. + # + # THE SOURCE LIST FOR s3 IS THE THING TO GET RIGHT. Every S3 consumer in + # ANOTHER namespace - a backup job, a Zipline storing uploads, a Stalwart + # blob store - is blocked unless that namespace is in allowedNamespaces. + # Consumers that use the published Ingress name arrive through the controller + # and are already covered. A blocked consumer fails at the application level, + # with nothing naming this object. + # + # NOT allowed, and the plausible surprise: outbound HTTPS. Garage does not + # need it to serve S3, but remote replication or an external metrics sink + # does - see egress.extraPorts. + 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 admin port (garage.ports.admin, 3903 by default), 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. + # + # 443 is the entry to add for remote replication or an external metrics + # sink. Nothing is needed for plain S3 serving. + extraPorts: [] diff --git a/gotenberg/Chart.yaml b/gotenberg/Chart.yaml index c0161d3..ec61951 100644 --- a/gotenberg/Chart.yaml +++ b/gotenberg/Chart.yaml @@ -5,7 +5,7 @@ icon: https://gotenberg.dev/img/logo.png type: application -version: 8.0.3+up8.37.0 +version: 9.0.0+up8.37.0 appVersion: "8.37.0" maintainers: diff --git a/gotenberg/templates/NOTES.txt b/gotenberg/templates/NOTES.txt index 5532a23..b41b9e2 100644 --- a/gotenberg/templates/NOTES.txt +++ b/gotenberg/templates/NOTES.txt @@ -80,3 +80,90 @@ REQUESTS TIME OUT AFTER 30 SECONDS cannot be changed here. This is the ceiling large conversions actually meet: an 18 MB HTML needs about 22 seconds, and a 25 MB one does not finish inside the 30. Callers that need longer must split the document instead. +{{- 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 3000 only. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }}. + +{{- /* Null-safe on purpose: networkPolicy.egress may be erased to null (the + schema permits it), and a bare .egress.remoteAssets then fails the whole + render with "nil pointer evaluating interface {}.remoteAssets" - an + error naming this file while the operator's value appears nowhere in it. + Same rule as issue #99: an erased block means "the default applies", not + a broken render. */}} +{{- if (.Values.networkPolicy.egress | default dict).remoteAssets }} + REMOTE ASSETS ARE ALLOWED IN THIS RELEASE (networkPolicy.egress.remoteAssets: + true), so Chromium may fetch over TCP 80 and 443 to any destination. Know + what that opens: this service is reachable from the whole cluster by default + (3000 is the probe port), so anything that can POST here can now have + Chromium fetch an arbitrary URL — including one inside the cluster. Switch it + back off if nothing in this release renders HTML that references remote + images. +{{- else }} + EGRESS IS DNS ONLY HERE, AND IT COSTS SOMETHING SILENTLY. Gotenberg drives a + headless Chromium, so an egress rule for 80/443 would turn a service the + whole cluster can POST to into a way of fetching arbitrary URLs — which is + why the default is off. + + What it costs: in a rendered HTML document, EMBEDDED images are MIME parts + inside the file and convert fine, but REMOTE images () + are fetched by Chromium at render time and are SILENTLY DROPPED. The PDF is + produced and is simply missing them. Nothing fails, nothing is logged here, + no probe notices. + + If this release needs them — a paperless-ngx rendering mail bodies that + reference remote pictures is the case — set + + networkPolicy: + egress: + remoteAssets: true + + and not extraPorts: [80, 443]. The named value says why the ports are open; + a port list cannot. +{{- end }} + + THIS CHART HAS NO INGRESS AND ITS CONSUMERS ARE OTHER PODS. While + probeSources is empty the 3000 rule has no source selector; narrowing it + means listing the consumers' namespaces in allowedNamespaces in the same + change, or every conversion stops. + + 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 }}-deployment + + 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/gotenberg/templates/gotenberg.networkpolicy.yaml b/gotenberg/templates/gotenberg.networkpolicy.yaml new file mode 100644 index 0000000..8ad7d24 --- /dev/null +++ b/gotenberg/templates/gotenberg.networkpolicy.yaml @@ -0,0 +1,195 @@ +{{- /* +NetworkPolicy for gotenberg (Gitea #5). + +Gotenberg here is a cluster-internal service: no Ingress, and its consumers are +other pods (paperless-ngx converts mail HTML to PDF through it). + +EGRESS IS DNS ONLY BY DEFAULT, AND THAT IS A DELIBERATE, VISIBLE TRADE rather +than an oversight. Gotenberg drives a headless Chromium, so an egress rule for +80 and 443 would let anything that can POST to this service fetch arbitrary +URLs through it - a textbook SSRF pivot, and the pod is reachable from the +whole cluster by default because 3000 is the probe port. Blocking it costs the +remote-asset half of HTML conversion: a converted page keeps its EMBEDDED +content and loses the images it would have FETCHED, which degrades a PDF rather +than failing a pod. The degradation is silent, which is why it is named here, in +values.yaml and in NOTES.txt. + +A deployment that needs those images sets networkPolicy.egress.remoteAssets: +true - a named switch rather than a line in extraPorts, so the trade is a +decision someone made and not a port number nobody can place. Its default is +off, because restrictive-by-default is the convention and the deployment is +where reality is accommodated. The long form of what it buys, and of the one +thing about it that was NOT verified, is on the rule itself. + +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 }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because this is the chart where the cap matters most: a + headless Chromium that can reach the internet turns a service anyone in the + cluster can POST to into an SSRF pivot. DNS is the only rule. + */}} + - Egress + ingress: + {{- /* + 3000 - the only port this pod declares, the one the Service forwards 80 to, and + the one all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 3000 + {{- 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. + + NO ingress controller is added here, because this chart renders no Ingress: its + consumers are other pods, and they have to be named in allowedNamespaces. + */}} + 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 }} + {{- 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 + {{- with $np.egress }} + {{- if .remoteAssets }} + {{- /* + REMOTE ASSETS: the web, on both ports, to any destination. Off by default, + and the switch exists because the cost of leaving it off is invisible while + the cost of removing it is not. + + WHAT IT BUYS, measured on the live cluster rather than assumed. paperless-ngx + splits a mail into two steps with two separate endpoints, both + cluster-internal: 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 stays + DNS-only and why this switch is not needed for a mail that carries its + pictures with it. + - REMOTE images ( in the mail body) are fetched by + CHROMIUM, here, at render time. With this switch off they are silently + dropped: the PDF is produced, it is just missing them. Nothing fails, + nothing is logged by this chart, and no probe notices. + + NOT CHECKED, and 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. + + WHY IT IS A SWITCH AND NOT THE DEFAULT. Gotenberg drives a headless + Chromium and 3000 is its probe port, so the service is reachable from the + whole cluster by default. Allowing 80/443 outbound therefore turns anything + that can POST here into a way of fetching arbitrary URLs - an SSRF pivot + with the cluster's whole internal address space behind it. That argument is + unchanged by the switch; the switch only makes the trade a NAMED decision + with a default of "no" instead of a line in extraPorts that reads like + nothing in particular. + */}} + - ports: + - protocol: TCP + port: 80 + - protocol: TCP + port: 443 + {{- end }} + {{- end }} + {{- 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/gotenberg/values.schema.json b/gotenberg/values.schema.json index a68706b..e450c7a 100644 --- a/gotenberg/values.schema.json +++ b/gotenberg/values.schema.json @@ -9,6 +9,47 @@ "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" + } + }, + "egress": { + "type": ["object", "null"], + "additionalProperties": false, + "properties": { + "remoteAssets": { + "description": "Lets Chromium fetch over TCP 80 and 443 to any destination, for the REMOTE images in a rendered HTML document (). Off by default: Gotenberg drives a headless Chromium and 3000 is its probe port, so the service is reachable from the whole cluster, and allowing outbound web access turns anything that can POST here into a way of fetching arbitrary URLs. EMBEDDED images are MIME parts inside the file and need no network at all, which is the case this switch is NOT for. With it off, remote images are silently dropped - the PDF is produced and is simply missing them.", + "type": "boolean" + }, + "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/gotenberg/values.yaml b/gotenberg/values.yaml index 5562d9a..9794e8b 100644 --- a/gotenberg/values.yaml +++ b/gotenberg/values.yaml @@ -92,3 +92,100 @@ gotenberg: limits: memory: 3Gi ephemeralStorage: 256Mi + +# --------------------------------------------------------------------------- +# 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 3000 only and outbound nothing but DNS. + # + # THE EGRESS CAP IS THE POINT OF THIS ONE. Gotenberg drives a headless + # Chromium, so allowing 80/443 outbound would let anything that can POST to + # this service fetch arbitrary URLs through it - and 3000 is the probe port, + # so the whole cluster can POST to it by default. The cost is the + # remote-asset half of HTML conversion: a converted page keeps its inline + # content and silently loses the images it would have FETCHED. That is a + # degraded PDF, not a dead pod, which is exactly why it is written down here. + # A deployment that needs those images sets egress.remoteAssets: true below - + # a named switch, knowingly, rather than a port pair in extraPorts. + # + # And like every chart here: 3000 is not restricted by source in the default + # (probe port), 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 3000, 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 and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] + egress: + # REMOTE ASSETS: lets Chromium fetch over TCP 80 and 443, to any + # destination. OFF by default, and both halves of that are deliberate. + # + # WHAT IT BUYS, measured rather than assumed. 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 stays + # DNS-only, and why a mail that carries its pictures with it needs + # nothing from this switch. + # - REMOTE images ( in the body) are fetched by + # CHROMIUM, here, at render time. With this 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. + # + # NOT CHECKED, and 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. Do not read this switch as a fix + # for it. + # + # WHY IT IS OFF BY DEFAULT. Gotenberg drives a headless Chromium and 3000 is + # its probe port, so the service is reachable from the whole cluster by + # default. Allowing 80/443 outbound therefore turns anything that can POST + # here into a way of fetching arbitrary URLs - an SSRF pivot with the + # cluster's internal address space behind it. Switching this on is a trade, + # and it should be one somebody made on purpose. + remoteAssets: false + # 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. + # + # Use remoteAssets above rather than putting 80 and 443 here: the named + # value says WHY the ports are open, and this list cannot. + # + # 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. + # + # Normally empty. Remote assets have their own switch above + # (egress.remoteAssets) and do NOT belong here, because a bare 80/443 in + # this list records no reason. Add a port here only for something neither + # the switch nor the chart covers. + extraPorts: [] diff --git a/homeassistant/Chart.yaml b/homeassistant/Chart.yaml index 03e0d1b..9804892 100644 --- a/homeassistant/Chart.yaml +++ b/homeassistant/Chart.yaml @@ -4,7 +4,7 @@ description: A Helm chart to deploy Home Assistant on Kubernetes icon: https://www.home-assistant.io/images/home-assistant-logo.svg type: application -version: 6.2.0+up2026.8.3 +version: 7.0.0+up2026.8.3 appVersion: "2026.8.3" diff --git a/homeassistant/templates/NOTES.txt b/homeassistant/templates/NOTES.txt index 02795fd..32e8852 100644 --- a/homeassistant/templates/NOTES.txt +++ b/homeassistant/templates/NOTES.txt @@ -372,3 +372,55 @@ env IS APPENDED, AND HOME ASSISTANT WILL NOT READ IT ON ITS OWN value: a value is stored in the pod spec and readable by anyone who can read pods in {{ .Release.Namespace }}. {{- 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 8123 only — anything else this image or an add-on binds is not + reachable. THERE IS NO EGRESS RESTRICTION AT ALL, deliberately: outbound + traffic here is the union of every integration you enable (mDNS and SSDP + discovery, MQTT, a Zigbee or Z-Wave bridge, ESPHome, printers, speakers, + inverters, cloud APIs, NTP), a chart cannot know that set, and a cap would + break integrations one at a time — months apart, each looking like a flaky + device rather than like a policy. An installation that wants one should write + it at release level, against its own integration list. + + 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 }}" + + 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/homeassistant/templates/homeassistant.networkpolicy.yaml b/homeassistant/templates/homeassistant.networkpolicy.yaml new file mode 100644 index 0000000..069404f --- /dev/null +++ b/homeassistant/templates/homeassistant.networkpolicy.yaml @@ -0,0 +1,124 @@ +{{- /* +NetworkPolicy for homeassistant (Gitea #5). + +INGRESS ONLY, AND THE MISSING EGRESS SECTION IS THE DECISION IN THIS FILE. + +Home Assistant's outbound traffic is the union of every integration an operator +enables, and that set is unknowable to a chart: mDNS and SSDP discovery on +5353/UDP and 1900/UDP, MQTT on 1883 or 8883, a Zigbee or Z-Wave bridge on +whatever port its firmware chose, ESPHome on 6053, printers, speakers, TVs, +inverters, cloud APIs on 443, an NTP client on 123/UDP. A port cap here would +not protect anything meaningful - the devices are on the same LAN as the node - +and it would break integrations ONE AT A TIME, months apart, each failure +looking like a flaky device rather than like this object. There is no version +of this list that stays correct. + +So the rule from valkey applies, in the other direction: policyTypes names only +what there is something to restrict, because an egress rule that has to be +widened until it permits everything makes the object look stricter than it is +while costing real outages to get there. If a deployment wants an egress cap, +it belongs in a release-level policy written against that installation's actual +integrations - not in a chart that cannot see them. + +What the Ingress half still buys is real and worth having on its own: only 8123 +is reachable, so anything else this image or an add-on binds is not. + +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 }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects this chart's workload. Note the label: the bare release-and-chart name, + QUOTED, with no -sfs suffix - unlike most charts here. Copying a selector from + a neighbouring chart would match nothing and leave this policy filtering + nothing, which is what ci/run-networkpolicy-test.sh holds it against. + */}} + podSelector: + matchLabels: + app: "{{ .Release.Name }}-{{ .Chart.Name }}" + policyTypes: + - Ingress + ingress: + {{- /* + 8123 - the only port this pod declares, the one the Service forwards 80 to, and + the port all three probes address. The probe path. + + No ingress-controller entry: this chart keeps its flexible upstream ingress + style and renders no Ingress of its own (a documented exception in README), + so the sources to name are namespaces, through allowedNamespaces. + */}} + - ports: + - protocol: TCP + port: 8123 + {{- 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. + */}} + 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 }} + {{- 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 }} +{{- end }} diff --git a/homeassistant/values.schema.json b/homeassistant/values.schema.json index d9e6b04..d148ceb 100644 --- a/homeassistant/values.schema.json +++ b/homeassistant/values.schema.json @@ -9,6 +9,30 @@ "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" + } + } + } + }, "scaleDown": { "type": "boolean" }, diff --git a/homeassistant/values.yaml b/homeassistant/values.yaml index 985e563..656bc78 100644 --- a/homeassistant/values.yaml +++ b/homeassistant/values.yaml @@ -235,3 +235,56 @@ core: # kubectl rollout restart statefulset -homeassistant-sfs secrets: configSecretRef: null + +# --------------------------------------------------------------------------- +# 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 8123 only. THERE IS NO EGRESS SECTION, and + # that is the decision in this chart rather than an omission. + # + # Home Assistant's outbound traffic is the union of every integration enabled + # in a release: mDNS/SSDP discovery (5353/UDP, 1900/UDP), MQTT (1883/8883), a + # Zigbee or Z-Wave bridge on whatever port its firmware chose, ESPHome (6053), + # printers, speakers, TVs, inverters, cloud APIs on 443, NTP on 123/UDP. A + # chart cannot know that set, a cap would protect little - the devices sit on + # the node's own LAN - and it would break integrations one at a time, months + # apart, each looking like a flaky device rather than like a policy. So + # policyTypes names Ingress only, for the same reason valkey names Ingress + # only: a rule that has to be widened until it permits everything makes the + # object look stricter than it is. + # + # An installation that wants an egress cap should write one against its own + # integrations, at release level, where they are visible. + # + # Inbound 8123 is not restricted by source in the default (probe port), 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 8123, 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 and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] diff --git a/keycloak/Chart.yaml b/keycloak/Chart.yaml index 8f6a805..5cafd20 100644 --- a/keycloak/Chart.yaml +++ b/keycloak/Chart.yaml @@ -43,7 +43,7 @@ type: application # procedure lives in NOTES.txt and on the wiki page instead of in a # packageRules entry. Read the "MINOR UPGRADES" section of NOTES.txt before # merging an app-version PR that changes the minor. -version: 1.3.0+up26.8.0 +version: 2.0.0+up26.8.0 appVersion: "26.8.0" maintainers: diff --git a/keycloak/templates/NOTES.txt b/keycloak/templates/NOTES.txt index f1a2735..5fddbcb 100644 --- a/keycloak/templates/NOTES.txt +++ b/keycloak/templates/NOTES.txt @@ -363,3 +363,80 @@ WHAT THIS CHART DOES NOT RENDER platform objects and live in cluster-config; this chart renders an HTTPRoute at most, which is the part that looks the same on every Gateway implementation. +{{- 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, and all three ports differ: + - http ({{ $settings.ports.http | int64 }}): RESTRICTED BY SOURCE — this namespace, anything in + networkPolicy.allowedNamespaces, and the ingress controller. No probe uses + this port, which is what makes the restriction possible. + - management ({{ $settings.ports.management | int64 }}): open inside the cluster, because all three probes + address it. Its health and metrics surface is UNAUTHENTICATED, and before + this object the only thing protecting it was that no Ingress pointed at + it. Narrow it with networkPolicy.probeSources. + - jgroups ({{ $settings.ports.jgroups | int64 }}): this release's own pods only. + Outbound: DNS, TCP {{ $settings.db.port | int64 }} (the CONFIGURED database port — this chart derives it + rather than guessing), {{ $settings.ports.jgroups | int64 }} (the cluster bus, outbound as well as in) and 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, + plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + TWO THINGS TO CHECK AFTER THE FIRST UPGRADE, because nothing else will. + + 1. LOG IN. This release is the cluster's identity provider, and the http rule + is the only source-restricted inbound rule in the repository. If the + ingress-controller source is wrong for this cluster, every login + everywhere stops — and no part of CI can see it, because no ingress + controller runs there and the probes use the management port, so the pod + becomes Ready either way. The default was measured against the live + cluster (DaemonSet rke2-traefik in kube-system, which serves the Gateway + API here too), but measured labels are not proven traffic. + 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. + + AND MAIL IS NOT ALLOWED. Keycloak's SMTP server is configured per realm, + which this chart never sees, so verification and password-reset mail needs its + port (587 or 465) in networkPolicy.egress.extraPorts. The symptom otherwise is + mail that is never delivered, with a healthy pod behind it. + + 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 }}-deployment + + 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/keycloak/templates/keycloak.networkpolicy.yaml b/keycloak/templates/keycloak.networkpolicy.yaml new file mode 100644 index 0000000..e58887a --- /dev/null +++ b/keycloak/templates/keycloak.networkpolicy.yaml @@ -0,0 +1,281 @@ +{{- /* +NetworkPolicy for keycloak (Gitea #5). + +THE RICHEST POLICY OF THIS ROLLOUT, AND THE ONE WITH THE LARGEST BLAST RADIUS. +Read both halves of that before changing anything here. + +Keycloak is the only chart in the repository whose three ports fall into three +DIFFERENT classes, so it is the one place the convention is visible in full: + + http (8080) NOT addressed by any probe, so it CAN be restricted by + source - and it is. Own namespace, allowedNamespaces, and + the ingress controller. This is the only real inbound + restriction in the sweep, and it is possible only because + the probes use a different port. + management (9000) the port all three probes address, so it stays open by + default. That is unfortunate here specifically: 9000 + carries the health and metrics surface UNAUTHENTICATED, + and "no Ingress" was its only protection before this + object existed. 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 - + at the price of needing the worker node IPs. + jgroups (7800) the cluster bus between this release's own replicas. + Restricted to the pods this policy itself selects, which + is the narrowest rule this object can express. + +All three port numbers and the database port are DERIVED from the chart's own +values through keycloak.settings, not written down twice: an operator who moves +keycloak.ports.http moves the policy with it. The database port is +keycloak.db.port, so unlike most charts in this sweep the egress rule here is +the CONFIGURED port and not a guess. + +THE BLAST RADIUS, stated plainly because it is the largest in this issue: this +release is the cluster's identity provider. If the ingress-controller source +below is wrong, every login everywhere breaks, and NOTHING IN CI CAN CATCH IT - +no ingress controller runs in the install-test, and the probes use 9000, so the +pod becomes Ready either way. The source was measured on the live cluster +(DaemonSet rke2-traefik in kube-system, which also serves the Gateway API +here), but measured labels are not proven traffic. After the first upgrade, +verify with an actual login rather than with kubectl. + +AND THE SECOND TRAP, which is the same shape: a consumer that reaches Keycloak +on its IN-CLUSTER Service address rather than through its public URL is a pod +in another namespace, and the http rule blocks it. Those namespaces belong in +allowedNamespaces. Consumers that use the public issuer URL arrive through +Traefik and are already covered. + +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 }} +{{- $settings := fromYaml (include "keycloak.settings" .) }} +{{- $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 Deployment's pods, and the same label the PodDisruptionBudget and + the two Services use. ci/run-networkpolicy-test.sh fails if it matches no pod + spec of the same render. + */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there is a great deal to restrict and almost all of it + is known: the database on its configured port, the cluster bus, and HTTPS. What + is NOT permitted and is the likely first surprise: SMTP. Keycloak sends mail + (verification, password reset) through a server configured per realm, which + this chart cannot see - so it needs networkPolicy.egress.extraPorts. + */}} + - Egress + ingress: + {{- /* + THE HTTP PORT, AND THE ONE GENUINELY RESTRICTED INBOUND RULE IN THIS SWEEP. + + It can be restricted because no probe addresses it - the probes use the + management port - so there is no kubelet traffic to accommodate and no need for + probeSources here. Allowed: this release's own namespace, whatever + allowedNamespaces adds, and the ingress controller. + + Both selectors of the controller entry sit in ONE list item, which makes them + an AND: those pods in that namespace. Split into two items they would be an OR + and would open Keycloak's login endpoint to every pod in kube-system. + + If this rule is wrong, every login in the cluster breaks and no part of CI will + say so. See the header. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.http | int64 }} + 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 }} + {{- /* + THE MANAGEMENT PORT - health, readiness and metrics, and all three probes + address it. So it is the probe path and stays open by default. + + That is worth naming rather than glossing: this surface is UNAUTHENTICATED, and + before this object existed its only protection was that no Ingress pointed at + it. What this policy adds is a smaller ceiling, not a closed door - the port is + reachable from inside the cluster. Closing it means listing the worker node IPs + in networkPolicy.probeSources, with the usual cost of getting them wrong. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.management | int64 }} + {{- 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. + + No ingress-controller entry on this rule: nothing routes to the management port + from outside, and it should stay that way. + */}} + 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 }} + {{- 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 }} + {{- /* + THE CLUSTER BUS between this release's own replicas, and the narrowest rule + this object can express: a podSelector with no namespaceSelector, which means + "pods with these labels IN THIS NAMESPACE" - i.e. exactly the pods this policy + already selects. + + Infinispan discovery here is jdbc-ping (see values.yaml), so no multicast and + no extra port is involved; the peers find each other through the database and + then connect on this port. The matching egress rule below is what lets them + dial OUT - without it the cluster would form in one direction only. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.jgroups | int64 }} + from: + - podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + 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 external PostgreSQL, on THE CONFIGURED PORT - keycloak.db.port, derived + through keycloak.settings. This chart is the exception in the sweep: its + database port is a value rather than a field inside an opaque secret, so this + rule follows the configuration instead of guessing at 5432. + + It also carries Infinispan's jdbc-ping discovery, which is why a blocked + database here costs the cluster as well as the data. + */}} + - ports: + - protocol: TCP + port: {{ $settings.db.port | int64 }} + {{- /* + Outbound cluster bus. Needed as well as the ingress rule above: policyTypes + lists Egress, so a replica dialling its peer is subject to this policy too, and + without this rule the bus would be permitted inbound and blocked outbound - a + cluster that never forms, with two nodes that each look healthy. + */}} + - ports: + - protocol: TCP + port: {{ $settings.ports.jgroups | int64 }} + {{- /* + Outbound HTTPS. Keycloak needs it for identity brokering and for external + identity-provider metadata, both configured inside REALMS this chart never + sees - so the port has to be permitted here or a federated login fails with no + hint pointing at this object. Also covers an outbound webhook or a remote JWKS. + Ports only: these 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/keycloak/values.schema.json b/keycloak/values.schema.json index 42cc533..2c56f73 100644 --- a/keycloak/values.schema.json +++ b/keycloak/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": { "description": "true renders the Deployment with replicas: 0 and takes precedence over keycloak.replicas. Also the first half of the minor-upgrade procedure, because Keycloak supports a rolling update only inside a minor line.", "type": "boolean" diff --git a/keycloak/values.yaml b/keycloak/values.yaml index aabbfc1..c3fe0b3 100644 --- a/keycloak/values.yaml +++ b/keycloak/values.yaml @@ -884,3 +884,101 @@ httpRoute: # Empty means [ingress.admin.domain]. Required (via that value) while # ingress.admin.enabled is true and httpRoute.enabled is true. hostnames: [] + +# --------------------------------------------------------------------------- +# 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: + # THE RICHEST POLICY IN THIS REPOSITORY, AND THE ONE WITH THE LARGEST BLAST + # RADIUS. Both halves matter. + # + # WHAT IT ALLOWS, inbound, and the three ports are three different cases: + # - http (8080): RESTRICTED BY SOURCE to this namespace, allowedNamespaces + # and the ingress controller. The only real inbound restriction in the + # whole sweep, and possible only because no probe uses this port. + # - management (9000): open inside the cluster, because all three probes + # address it. This is the port whose health and metrics surface is + # UNAUTHENTICATED; before this object its only protection was that no + # Ingress pointed at it. probeSources closes the rest. + # - jgroups (7800): this release's own pods only. + # Outbound: DNS, the CONFIGURED database port (keycloak.db.port - this chart + # does not have to guess), the cluster bus, and 443 for identity brokering. + # + # THE BLAST RADIUS: this release is the cluster's identity provider. If + # ingressController below is wrong, EVERY LOGIN EVERYWHERE BREAKS, and no + # part of CI can catch it - no ingress controller runs in the install-test, + # and the probes use 9000 so the pod goes Ready either way. The source was + # measured on the live cluster, but measured labels are not proven traffic: + # after the first upgrade, verify with a real login. + # + # AND THE SECOND TRAP: a consumer that reaches Keycloak on its IN-CLUSTER + # Service address instead of its public URL is a pod in another namespace, + # and the http rule blocks it. Put those namespaces in allowedNamespaces. + # Consumers using the public issuer URL come through Traefik and are covered. + # + # NOT allowed, and the likely first surprise: SMTP. Mail is configured per + # REALM, which this chart never sees - see egress.extraPorts. + 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 management port (keycloak.ports.management, 9000 by default), 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. + # + # SMTP is the entry this chart needs most: 587 or 465, for the mail server + # configured inside a realm. Also an LDAP user federation (389 or 636), or + # a database that is somehow not on keycloak.db.port. + 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/minecraft-router/Chart.yaml b/minecraft-router/Chart.yaml index 6e72d83..9ac204a 100644 --- a/minecraft-router/Chart.yaml +++ b/minecraft-router/Chart.yaml @@ -5,7 +5,7 @@ icon: https://avatars.githubusercontent.com/u/988985 type: application -version: 4.1.4+up1.47.2 +version: 5.0.0+up1.47.2 appVersion: "1.47.2" maintainers: diff --git a/minecraft-router/templates/NOTES.txt b/minecraft-router/templates/NOTES.txt index 0b19a06..d5e7696 100644 --- a/minecraft-router/templates/NOTES.txt +++ b/minecraft-router/templates/NOTES.txt @@ -138,3 +138,57 @@ server.env IS APPENDED TO WHAT THE CHART SETS in clear text, readable by everyone who can read pods in {{ .Release.Namespace }}; anything sensitive belongs in valueFrom.secretKeyRef. {{- 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 proxy port (TCP {{ .Values.server.port }}, open — it is published as NodePort + {{ .Values.server.outwardPort }} and the senders are players on the internet) and the API port + (TCP {{ .Values.server.apiPort }}, the probe path). Outbound: DNS plus the backend ports taken from + the routing table itself — TCP {{ $ports := list (int .Values.server.backendPort) }}{{ range .Values.mappings }}{{ if .port }}{{ $ports = append $ports (int .port) }}{{ end }}{{ end }}{{ join ", " ($ports | uniq) }}{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THE BACKEND RULE IS BY PORT, NOT BY DESTINATION. mappings name hosts, and + this object cannot match a hostname — so the rule permits those ports to any + address, which is wider than your routing table. Adding a backend on a + non-standard port needs no change here: the policy reads the same value the + router does. + + 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 }}-deployment + + 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/minecraft-router/templates/minecraft-router.networkpolicy.yaml b/minecraft-router/templates/minecraft-router.networkpolicy.yaml new file mode 100644 index 0000000..47e903a --- /dev/null +++ b/minecraft-router/templates/minecraft-router.networkpolicy.yaml @@ -0,0 +1,184 @@ +{{- /* +NetworkPolicy for minecraft-router (Gitea #5). + +The router is the one chart here whose whole job is to forward traffic to OTHER +releases, so its egress rule is the interesting one and it is DERIVED from the +routing table rather than guessed: server.backendPort, plus the per-mapping +"port" override for every entry of mappings that has one. Add a backend on a +non-standard port and the policy follows it, because both come from the same +value. + +What it does NOT express is the destination. The mappings name hosts +(internalUrl), and a NetworkPolicy matches CIDRs and selectors and never +hostnames - so the rule permits those PORTS to any address, not those services. +A backend in another namespace is reachable through this rule; so is anything +else listening on 25565. + +Inbound splits in two: the proxy port is a NodePort and faces the internet, the +API port is the probe path. + +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 }} +{{- $backendPorts := list (int .Values.server.backendPort) }} +{{- range .Values.mappings }} +{{- if .port }}{{- $backendPorts = append $backendPorts (int .port) }}{{- end }} +{{- end }} +{{- $np := .Values.networkPolicy }} +{{- $ns := .Release.Namespace }} +{{- $probeSources := $np.probeSources | default list }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects the Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because the router's outbound traffic is exactly known for + once - DNS, and the backend ports from its own routing table. That makes this + one of the few egress caps in the sweep that is derived rather than guessed. + */}} + - Egress + ingress: + {{- /* + THE PROXY PORT, and deliberately open with no "from" at all: it is published + as a NodePort (server.outwardPort) and the senders are Minecraft clients on the + internet. After NodePort masquerading the source address is a cluster node + anyway, so a source selector here could only ever be a list of node IPs - which + would be a restriction in appearance and a maintenance trap in fact. + + NOTE THE NUMBER: this is the POD port (server.port), not server.outwardPort. + The NodePort is translated by kube-proxy before the packet reaches the pod, so + writing 30565 here would match nothing while looking strict - and being the + only rule for this port, it would block every player instead. + ci/run-networkpolicy-test.sh asserts exactly this. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.port | int64 }} + {{- /* + THE API PORT - the router's own HTTP interface, and the port all three probes + address. So it is the probe path and stays open inside the cluster by default. + + No ingress-controller entry: this chart renders no Ingress, and nothing should + route to the router's API from outside. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.apiPort | int64 }} + {{- 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. + */}} + 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 }} + {{- 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 + {{- /* + THE BACKENDS, by port, derived from the routing table: server.backendPort plus + the "port" override of every mapping that sets one. So a backend on a + non-standard port is covered automatically, and there is no second list to keep + in step with mappings. + + Ports and not destinations: the mappings name hosts (internalUrl) and this + object cannot match a hostname, so this rule permits those ports to ANY + address. That is wider than the routing table and is the honest description of + what the object can do. + */}} + - ports: + {{- range $backendPorts | uniq }} + - protocol: TCP + port: {{ . }} + {{- end }} + {{- 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/minecraft-router/values.schema.json b/minecraft-router/values.schema.json index 310575c..51a8346 100644 --- a/minecraft-router/values.schema.json +++ b/minecraft-router/values.schema.json @@ -9,6 +9,43 @@ "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" + } + }, + "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/minecraft-router/values.yaml b/minecraft-router/values.yaml index 520a22d..2ea58ef 100644 --- a/minecraft-router/values.yaml +++ b/minecraft-router/values.yaml @@ -150,3 +150,70 @@ server: # name: "some-configmap" # key: "someKey" env: [] + +# --------------------------------------------------------------------------- +# 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 the proxy port (server.port, OPEN - it is + # a NodePort and the senders are players on the internet) and the API port + # (the probe path, open inside the cluster). Outbound: DNS plus the backend + # ports DERIVED from the routing table - server.backendPort and the "port" + # override of every mapping that sets one. + # + # So this is one of the few egress caps here that follows the configuration + # instead of guessing at it: add a backend on a non-standard port and the + # policy moves with it. + # + # WHAT IT DOES NOT DO: the rule permits those PORTS to any destination, not + # those SERVICES. mappings name hosts (internalUrl), and a NetworkPolicy + # matches CIDRs and selectors but never hostnames - so anything else + # listening on a backend port is reachable through this rule too. + # + # And the proxy port carries the POD port, not server.outwardPort: a + # NetworkPolicy is evaluated after kube-proxy has translated the NodePort, so + # the NodePort number would match nothing while looking strict. + 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 API port (server.apiPort), 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 and allowedNamespaces to the same rule for exactly that reason. + probeSources: [] + 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. + # + # Normally empty: the backend ports already come from mappings. Add a port + # only for something outside the routing table - a status API, for + # instance. + extraPorts: [] diff --git a/minecraft-server/Chart.yaml b/minecraft-server/Chart.yaml index 9aaaa46..74e393d 100644 --- a/minecraft-server/Chart.yaml +++ b/minecraft-server/Chart.yaml @@ -23,7 +23,7 @@ type: application # continuously, so a pod restart could pull a different image than the last one # without anything being recorded anywhere. The dated tags are immutable, so the # image is now pinned by the chart version like every other chart in this repo. -version: 6.0.1+up2026.8.2.java25 +version: 7.0.0+up2026.8.2.java25 appVersion: "2026.8.2-java25" maintainers: diff --git a/minecraft-server/templates/NOTES.txt b/minecraft-server/templates/NOTES.txt index 3d80928..7549ae1 100644 --- a/minecraft-server/templates/NOTES.txt +++ b/minecraft-server/templates/NOTES.txt @@ -364,3 +364,65 @@ server env IS APPENDED, NOT MERGED everyone who can read pods in {{ .Release.Namespace }}; anything sensitive belongs in valueFrom.secretKeyRef. {{- 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 game port (TCP {{ .Values.server.port }}){{ if eq .Values.server.service.type "NodePort" }} — OPEN, because the Service is a + NodePort and the senders are players on the internet{{ else }} — RESTRICTED to this namespace + and networkPolicy.allowedNamespaces, because the Service is {{ .Values.server.service.type }} and the only + path to it is in-cluster{{ end }}; RCON (TCP {{ .Values.server.rcon.port }}) and metrics (TCP 19565), both always + restricted by source. Outbound: DNS and TCP 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THIS POLICY IS UNUSUALLY TIGHT, and the reason is that all three probes are + exec probes: they run inside the container and put no packet on the network, + so no port has to stay open for the kubelet and networkPolicy.probeSources is + not needed at all. +{{- if not (eq .Values.server.service.type "NodePort") }} + + SO CHECK ONE THING: players reach this server through minecraft-router, which + runs in ITS OWN namespace. That namespace must be in + networkPolicy.allowedNamespaces, or the server is unreachable and the symptom + is a connection timeout in a game client with a perfectly healthy pod behind + it. The same goes for a Prometheus scraping port 19565 from elsewhere. +{{- end }} + + 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/minecraft-server/templates/minecraft-server.networkpolicy.yaml b/minecraft-server/templates/minecraft-server.networkpolicy.yaml new file mode 100644 index 0000000..c71c01d --- /dev/null +++ b/minecraft-server/templates/minecraft-server.networkpolicy.yaml @@ -0,0 +1,210 @@ +{{- /* +NetworkPolicy for minecraft-server (Gitea #5). + +THIS IS THE CHART WHERE THE POLICY GETS GENUINELY TIGHT, and the reason is its +probes: all three 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 accommodate on any port - which means every port here can be +restricted by source, with no probeSources needed at all. valkey is tight for +the same reason. + +The one rule that cannot be fixed is the game port, and it depends on a value: + + server.service.type == NodePort players arrive from the internet, already + masqueraded, so the rule must be OPEN. + anything else (the default) the only reachable path is in-cluster, i.e. + minecraft-router, so the rule is RESTRICTED + to this namespace plus allowedNamespaces - + and the router's namespace has to be in that + list. + +Both branches are rendered from the same value the Service reads, so they cannot +disagree. The RCON and metrics ports are restricted in either case. + +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 }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +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 because there is something real to restrict: a Minecraft + server needs DNS and HTTPS and nothing else in the normal case. Mod packs that + call out on other ports are the exception, and extraPorts is for them. + */}} + - Egress + ingress: + {{- if eq .Values.server.service.type "NodePort" }} + {{- /* + THE GAME PORT, NODEPORT BRANCH (server.service.type is NodePort). Open with no + "from": the senders are Minecraft clients on the internet and, after NodePort + masquerading, the source address is a cluster node anyway. + + THE NUMBER IS THE POD PORT (server.port), not server.service.outwardPort. A + NetworkPolicy is evaluated after kube-proxy has translated the NodePort, so the + NodePort number would match nothing here while looking strict - and being the + only rule for the port, it would block every player instead. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.port | int64 }} + {{- end }} + {{- if not (eq .Values.server.service.type "NodePort") }} + {{- /* + THE GAME PORT, CLUSTER-INTERNAL BRANCH (the default): the Service is not a + NodePort, so nothing outside the cluster can reach this port through this + chart, and the rule is restricted to this namespace plus allowedNamespaces. + + PUT THE ROUTER'S NAMESPACE IN allowedNamespaces. In this cluster players reach + the server through minecraft-router, which runs in its own namespace - so + without that entry the server is unreachable and the symptom is a connection + timeout in a game client, with a healthy pod behind it. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.port | int64 }} + 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 }} + {{- end }} + {{- if .Values.server.rcon.enabled }} + {{- /* + RCON - remote console, i.e. full administrative control of the server. No + Service and no probe, so it is restricted to this namespace plus + allowedNamespaces: the rule with the most security value in this chart, and it + costs nothing. + + GATED ON server.rcon.enabled, AND THAT GATE IS NOT COSMETIC. The containerPort + itself is rendered only when RCON is on (see the StatefulSet), so without the + same condition here the policy would name a port the pod does not declare - + which is not merely untidy: ci/run-networkpolicy-test.sh rejects it, and it + rejected exactly this while the chart was being written. A rule for a port + nothing listens on is a rule nobody can verify. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.rcon.port | int64 }} + 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 }} + {{- end }} + {{- /* + The metrics port. No Service, no probe, so restricted like RCON. A Prometheus + that scrapes this release from another namespace needs that namespace in + allowedNamespaces - and the failure is a target that silently stops reporting, + which is worth knowing before it happens. + */}} + - ports: + - protocol: TCP + port: 19565 + 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 + {{- /* + Outbound HTTPS. Needed for Mojang's session server when online-mode is on - a + server that cannot reach it rejects every login - and for resource-pack and mod + downloads. 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/minecraft-server/values.schema.json b/minecraft-server/values.schema.json index 68ecdc5..8816d55 100644 --- a/minecraft-server/values.schema.json +++ b/minecraft-server/values.schema.json @@ -9,6 +9,36 @@ "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" + } + }, + "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/minecraft-server/values.yaml b/minecraft-server/values.yaml index cc9cb45..bd1b6e7 100644 --- a/minecraft-server/values.yaml +++ b/minecraft-server/values.yaml @@ -313,3 +313,56 @@ curseforgeServerpack: # Longhorn. downloadUrl: null workingDirectory: '/data/serverpack' + +# --------------------------------------------------------------------------- +# 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: + # THIS IS ONE OF THE TIGHTEST POLICIES IN THE REPOSITORY, and the reason is + # the probes: all three are EXEC probes, which run inside the container and + # put no packet on the network. So there is no kubelet traffic to + # accommodate, every port can be restricted by source, and no probeSources + # value is needed at all. + # + # WHAT IT ALLOWS, inbound: + # - the game port (server.port): OPEN when server.service.type is NodePort + # (players arrive from the internet, already masqueraded), and + # RESTRICTED to this namespace plus allowedNamespaces otherwise - which + # is the default. Both branches read the same value the Service does. + # - RCON (server.rcon.port) and metrics (19565): always restricted. RCON is + # full administrative control of the server, so this is the rule with the + # most security value here, and it costs nothing. + # Outbound: DNS and TCP 443 (Mojang's session server for online-mode, plus + # resource packs and mods). + # + # PUT THE ROUTER'S NAMESPACE IN allowedNamespaces when the Service is not a + # NodePort. In this cluster players reach the server through + # minecraft-router, in its own namespace; without that entry the server is + # unreachable and the symptom is a timeout in a game client with a healthy + # pod behind it. The same applies to a Prometheus scraping 19565. + 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: [] + 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. + # + # Add a port for a mod pack that calls out somewhere other than 443 - an + # authentication proxy, a map renderer upload, a Discord bridge. + extraPorts: [] diff --git a/outline/Chart.yaml b/outline/Chart.yaml index af61541..8557ea4 100644 --- a/outline/Chart.yaml +++ b/outline/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/outline/outline/main/public/images/icon- type: application -version: 7.2.0+up1.10.1 +version: 8.0.0+up1.10.1 appVersion: 1.10.1 maintainers: diff --git a/outline/templates/NOTES.txt b/outline/templates/NOTES.txt index 703f2d3..2bc21a3 100644 --- a/outline/templates/NOTES.txt +++ b/outline/templates/NOTES.txt @@ -235,3 +235,57 @@ FORCE_HTTPS IS ON header. Turned on, Outline redirects to https itself — which loops endlessly if that header does not reach the pod. {{- 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 3000 only. Outbound: DNS, TCP 5432 (Postgres), 6379 (Redis), + 443 (OIDC discovery){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THOSE EGRESS PORTS ARE THE STANDARD ONES, NOT YOUR CONFIGURED ONES. The + database, Redis and OIDC URLs all live inside 1Password items that this chart + never parses, so it cannot know where your dependencies listen. A dependency + on another port is blocked — and since all three probes hit /_health, which + checks Postgres and Redis, the symptom is a pod that never becomes Ready + rather than a connection error anyone sees. Add the real port to + networkPolicy.egress.extraPorts. + + 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/outline/templates/outline.networkpolicy.yaml b/outline/templates/outline.networkpolicy.yaml new file mode 100644 index 0000000..3be11b3 --- /dev/null +++ b/outline/templates/outline.networkpolicy.yaml @@ -0,0 +1,192 @@ +{{- /* +NetworkPolicy for outline (Gitea #5). + +Outline is the first chart of this rollout whose policy has real work to do in +BOTH directions, and it is also the one that proves the egress cap bites: all +three of its probes hit /_health, which checks Postgres AND Redis, so a pod +that cannot reach its data stores never becomes Ready. + +THE EGRESS PORTS ARE THE CHART'S ONLY KNOWLEDGE OF ITS DEPENDENCIES, AND THEY +ARE A GUESS AT THE STANDARD ONES. Outline's database, Redis and OIDC provider +are configured through connection URLs inside 1Password items - the chart never +sees a host or a port, cannot derive one, and cannot validate one. So the rules +below name the standard ports (5432, 6379, 443) and nothing else. A deployment +whose Postgres listens anywhere else is BLOCKED, and it finds out through an +application-level connection error, not through this object. That is the +upgrade risk of this chart's policy, stated plainly; the escape hatch is +networkPolicy.egress.extraPorts. + +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 because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 3000 - the only port this pod declares, the one the Service targets, and the + one all three probes address (/_health). It is therefore the probe path. + */}} + - ports: + - protocol: TCP + port: 3000 + {{- 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 + {{- /* + Postgres. The port, not the host - outline.secrets.dbSecretRef carries a + connection URL the chart never parses, so this is the STANDARD port and not + the configured one. A Postgres on any other port is blocked and must be added + to networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Redis/Valkey, same reasoning as Postgres above: the standard port, because + outline.secrets.redisSecretRef holds a URL this chart does not read. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- /* + Outbound HTTPS. Needed for the OIDC provider: Outline fetches the issuer's + discovery document at boot, and the discovery URL also lives inside a secret. + 443 to any destination is as narrow as this object gets for that - a hostname + cannot appear in a NetworkPolicy. An OIDC provider on a non-standard port (a + CI dex on 5556, for instance) needs extraPorts. + */}} + - 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/outline/values.schema.json b/outline/values.schema.json index 433dfec..5087fe9 100644 --- a/outline/values.schema.json +++ b/outline/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/outline/values.yaml b/outline/values.yaml index 281e340..4921a97 100644 --- a/outline/values.yaml +++ b/outline/values.yaml @@ -171,3 +171,90 @@ httpRoute: # link, every share URL and the OIDC redirect URI still name the old host # until ingress.domain is changed too. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 3000 only - the single port this pod + # declares - and outbound DNS plus TCP 5432 (Postgres), 6379 (Redis) and 443 + # (the OIDC provider's discovery document, fetched at boot). + # + # WHAT IT DOES NOT DO, and this is the one to read before upgrading: THOSE + # THREE EGRESS PORTS ARE THE STANDARD ONES, NOT THE CONFIGURED ONES. Outline's + # database, Redis and OIDC provider all arrive as connection URLs inside + # 1Password items, which this chart never parses - so it cannot know your + # ports and cannot validate them. A dependency on a non-standard port is + # BLOCKED, and because all three probes hit /_health (which checks Postgres + # and Redis), that shows up as a pod which never becomes Ready. Put the real + # port in egress.extraPorts below. + # + # The 3000 rule is NOT restricted by source in the default - 3000 is the + # probe port, see probeSources. And a policy is only a boundary where the CNI + # enforces one; unenforced, it is accepted and filters nothing. + 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 3000, 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. + # + # Outline needs one entry here more often than most charts: a Postgres or + # Redis on a non-standard port, an OIDC provider that is not on 443 (a CI + # dex on 5556), an S3 endpoint on a custom port, or SMTP - the chart sets + # no SMTP_* variable itself, but a deployment that adds them through + # outline.env needs 587 or 465 here too. + 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/patchmon/Chart.yaml b/patchmon/Chart.yaml index 5d8340c..74bde87 100644 --- a/patchmon/Chart.yaml +++ b/patchmon/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/PatchMon/PatchMon/main/frontend/public/a type: application -version: 7.1.0+up2.1.3 +version: 8.0.0+up2.1.3 appVersion: "2.1.3" maintainers: diff --git a/patchmon/templates/NOTES.txt b/patchmon/templates/NOTES.txt index 16d7aae..b2ca033 100644 --- a/patchmon/templates/NOTES.txt +++ b/patchmon/templates/NOTES.txt @@ -147,3 +147,54 @@ SCALED TO ZERO retrying, so nothing is lost; they simply report nothing until you set it back and upgrade again. {{- 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 3000 only. Outbound: DNS, TCP 5432 (Postgres), 6379 (Redis) and + 443 (OIDC){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THOSE ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES — all three + dependencies arrive as connection details inside 1Password items this chart + never parses. A dependency elsewhere is blocked; add its port to + networkPolicy.egress.extraPorts. + + 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 }} + + 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/patchmon/templates/patchmon.networkpolicy.yaml b/patchmon/templates/patchmon.networkpolicy.yaml new file mode 100644 index 0000000..bcef2e2 --- /dev/null +++ b/patchmon/templates/patchmon.networkpolicy.yaml @@ -0,0 +1,188 @@ +{{- /* +NetworkPolicy for patchmon (Gitea #5). + +PatchMon has three outbound dependencies and all three arrive as connection +details inside 1Password items the chart never parses: Postgres, Redis and an +OIDC provider. So the egress rules name the STANDARD ports (5432, 6379, 443) +and nothing else, and a deployment whose dependency sits elsewhere is blocked +until networkPolicy.egress.extraPorts says so. + +Note the pod label this policy selects: the bare release-and-chart name, +without the -deployment suffix most charts here use. + +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 := .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + labels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy +spec: + {{- /* + Selects this chart's workload. Note the label: it is the bare + release-and-chart name with NO -deployment suffix, unlike most charts + here - copying a selector from a neighbouring chart would match nothing + and leave this policy filtering nothing at all, which is why + ci/run-networkpolicy-test.sh holds it against the render. + */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }} + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 3000 - 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: 3000 + {{- 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 the chart never parses, so this is the conventional port and not the + configured one; a Postgres anywhere else is blocked and belongs in + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Redis/Valkey on the STANDARD port, same reasoning as Postgres above: the URL + sits in a secret this chart does not read. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- /* + Outbound HTTPS, for the OIDC provider: the chart fetches the issuer's + configuration and exchanges tokens over it, and the issuer URL lives inside a + 1Password item the chart never parses. 443 to any destination is as narrow as + this object gets for that - a hostname cannot appear in a NetworkPolicy. An + issuer on another port (a CI dex on 5556) needs + networkPolicy.egress.extraPorts. + */}} + - 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/patchmon/values.schema.json b/patchmon/values.schema.json index 2a539d5..233df81 100644 --- a/patchmon/values.schema.json +++ b/patchmon/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/patchmon/values.yaml b/patchmon/values.yaml index bf21b2d..f471153 100644 --- a/patchmon/values.yaml +++ b/patchmon/values.yaml @@ -224,3 +224,81 @@ secrets: postgresSecretRef: null oidcSecretRef: null patchmonSecretRef: null + +# --------------------------------------------------------------------------- +# 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 3000 only, and outbound DNS plus TCP 5432 + # (Postgres), 6379 (Redis) and 443 (the OIDC provider). + # + # WHAT IT DOES NOT DO: those three are the STANDARD ports, not the configured + # ones. All three dependencies arrive as connection details inside 1Password + # items this chart never parses, so it cannot know where they listen. A + # dependency on another port is BLOCKED and belongs in egress.extraPorts. + # + # Inbound 3000 is not restricted by source in the default (probe port), 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 3000, 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 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. + # + # Add the real port whenever Postgres, Redis or the OIDC issuer is not on + # 5432 / 6379 / 443 - a self-hosted issuer on 8443, for instance. + extraPorts: [] diff --git a/rallly/Chart.yaml b/rallly/Chart.yaml index 40a4cd5..9b8afa6 100644 --- a/rallly/Chart.yaml +++ b/rallly/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/lukevella/rallly/main/apps/landing/publi type: application -version: 8.2.0+up4.15.4 +version: 9.0.0+up4.15.4 appVersion: "4.15.4" maintainers: diff --git a/rallly/templates/NOTES.txt b/rallly/templates/NOTES.txt index 7875718..9bd9e69 100644 --- a/rallly/templates/NOTES.txt +++ b/rallly/templates/NOTES.txt @@ -244,3 +244,56 @@ SCALED TO ZERO until you set it back and upgrade again. Secrets and Service{{ if .Values.ingress.enabled }}, and the Ingress with its certificate,{{ else if $httpRoute.enabled }}, and the HTTPRoute,{{ end }} stay in place. {{- 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 3000 only. Outbound: DNS, TCP 5432 (Postgres) and 443 (OIDC){{ if .Values.rallly.smtp.enabled }}, + plus 587 and 465 for mail submission (rallly.smtp.enabled is true){{ else }}. + No SMTP rule is rendered, because rallly.smtp.enabled is false{{ end }}{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus + TCP {{ join ", " . }}{{ end }}{{ end }}. + + THOSE ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES. A mail server on 25 + or 2525, or an OIDC issuer that is not on 443, is blocked until its port is in + networkPolicy.egress.extraPorts — and a blocked mail server shows up as + invitations that are never delivered, with a Ready pod behind it. + + 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 }}-deployment + + 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/rallly/templates/rallly.networkpolicy.yaml b/rallly/templates/rallly.networkpolicy.yaml new file mode 100644 index 0000000..1ac8d3e --- /dev/null +++ b/rallly/templates/rallly.networkpolicy.yaml @@ -0,0 +1,197 @@ +{{- /* +NetworkPolicy for rallly (Gitea #5). + +Rallly reaches a Postgres, an OIDC provider and - when rallly.smtp.enabled is +true - a mail server. The SMTP rule is the one thing in this sweep that is +DERIVED from an existing value rather than always rendered: with SMTP switched +off there is no mail traffic to permit, and a rule that permits nothing makes +the object look stricter than it is. + +The ports are the standard ones, because every one of these dependencies +arrives as connection details inside a 1Password item the chart never parses. + +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 := .Values.ingress.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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 3000 - 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: 3000 + {{- 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 the chart never parses, so this is the conventional port and not the + configured one; a Postgres anywhere else is blocked and belongs in + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Outbound HTTPS, for the OIDC provider: the chart fetches the issuer's + configuration and exchanges tokens over it, and the issuer URL lives inside a + 1Password item the chart never parses. 443 to any destination is as narrow as + this object gets for that - a hostname cannot appear in a NetworkPolicy. An + issuer on another port (a CI dex on 5556) needs + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 443 + {{- if .Values.rallly.smtp.enabled }} + {{- /* + SMTP submission, both conventional ports - 587 (STARTTLS) and 465 (implicit + TLS) - because the chart cannot tell which one the configured server uses: the + host and port live in secrets.smtpSecretRef, which this chart never parses. + + RENDERED ONLY WHILE rallly.smtp.enabled IS TRUE. With mail switched off there + is nothing to permit, and a rule that permits nothing makes the object look + stricter than it is. A mail server on 25 or 2525 needs + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 587 + - protocol: TCP + port: 465 + {{- end }} + {{- 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/rallly/values.schema.json b/rallly/values.schema.json index c77757d..0038530 100644 --- a/rallly/values.schema.json +++ b/rallly/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/rallly/values.yaml b/rallly/values.yaml index b6ad8ba..ea1ae68 100644 --- a/rallly/values.yaml +++ b/rallly/values.yaml @@ -186,3 +186,83 @@ httpRoute: # the provider is built from ingress.domain - a new name needs both sides # updated or logins fail on a redirect-uri mismatch while the pod stays green. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 3000 only, and outbound DNS plus TCP 5432 + # (Postgres), 443 (the OIDC provider) and - only while rallly.smtp.enabled is + # true - 587 and 465 for mail submission. + # + # WHAT IT DOES NOT DO: all of those are STANDARD ports, not configured ones. + # The database, the issuer and the mail server all arrive as connection + # details inside 1Password items this chart never parses, so a dependency + # anywhere else is BLOCKED - a mail server on 25 or 2525 above all. Put it in + # egress.extraPorts. + # + # Inbound 3000 is not restricted by source in the default (probe port), 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 3000, 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 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. + # + # The usual entries here are a mail server on 25 or 2525, or an OIDC issuer + # that is not on 443. + extraPorts: [] diff --git a/rss-bridge/Chart.yaml b/rss-bridge/Chart.yaml index f2d9259..65d1d7d 100644 --- a/rss-bridge/Chart.yaml +++ b/rss-bridge/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/RSS-Bridge/rss-bridge/master/static/logo type: application -version: 2.1.1+up2026.09.30 +version: 3.0.0+up2026.09.30 appVersion: "2026-09-30" maintainers: diff --git a/rss-bridge/templates/NOTES.txt b/rss-bridge/templates/NOTES.txt index c066390..06a138a 100644 --- a/rss-bridge/templates/NOTES.txt +++ b/rss-bridge/templates/NOTES.txt @@ -114,3 +114,55 @@ Useful to know: WARNING: scaleDown is true, so this release is running with 0 replicas. Set scaleDown=false to bring it back up. {{- 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 80 only. Outbound: DNS plus TCP 80 and 443 to ANY destination{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, + plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THE EGRESS RULE IS THE OPEN INTERNET ON TWO PORTS, and it has to be: fetching + other people's websites is what RSS-Bridge does, and a NetworkPolicy matches + CIDRs and selectors but never hostnames, so "only the configured bridges" + cannot be written. What the cap buys is that this pod can open NO other + outbound port — not a database, not SMTP, not SSH. + + 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 }}-deployment + + 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/rss-bridge/templates/rss-bridge.networkpolicy.yaml b/rss-bridge/templates/rss-bridge.networkpolicy.yaml new file mode 100644 index 0000000..0c0b988 --- /dev/null +++ b/rss-bridge/templates/rss-bridge.networkpolicy.yaml @@ -0,0 +1,178 @@ +{{- /* +NetworkPolicy for rss-bridge (Gitea #5). + +RSS-Bridge's whole job is to fetch other people's websites and turn them into +feeds, so the egress cap can only ever be coarse here: 80 and 443 to ANY +destination. A NetworkPolicy matches CIDRs and selectors and never hostnames, +so "only the sites in the bridge list" is not expressible, and with Canal there +is no FQDN rule to reach for. What the cap still buys is narrow but real: this +pod cannot open a connection on any OTHER port - no SSH, no database, no mail - +so a compromised bridge cannot be used to reach the rest of the cluster on the +ports that matter. + +Said plainly, because it would otherwise look stricter than it is: this chart's +egress rule permits the open internet on the two web ports. + +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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 80 - the container's port (the port NAME rss-bridge-port is load-bearing and is + what the Service and the probes address). It is the probe path. Note that this + is the POD's 80, not the Service's: they happen to be the same number here, + which is exactly the kind of coincidence that makes the NodePort mistake easy + elsewhere. + */}} + - ports: + - protocol: TCP + port: 80 + {{- 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 and the + HTTPRoute both go 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 + {{- /* + The web, on both ports, to any destination. This IS the application: a feed + bridge that cannot reach the sites it bridges does nothing. 80 as well as 443, + because plenty of the bridged sources are still plain HTTP. Ports only - a + hostname cannot appear in this object, so this rule cannot be narrowed to the + configured bridges. + */}} + - ports: + - protocol: TCP + port: 80 + - 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/rss-bridge/values.schema.json b/rss-bridge/values.schema.json index bcc4317..1be3172 100644 --- a/rss-bridge/values.schema.json +++ b/rss-bridge/values.schema.json @@ -10,6 +10,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/rss-bridge/values.yaml b/rss-bridge/values.yaml index b5a4066..b765e20 100644 --- a/rss-bridge/values.yaml +++ b/rss-bridge/values.yaml @@ -352,3 +352,84 @@ httpRoute: # - rss-bridge.example.com # - feeds.example.com hostnames: [] + +# --------------------------------------------------------------------------- +# 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 80 only (the container's port), and + # outbound DNS plus TCP 80 and 443 to ANY destination. + # + # WHAT IT DOES NOT DO, and this one needs saying out loud: the egress rule + # permits the open internet on both web ports, because that IS the + # application - a feed bridge that cannot reach the sites it bridges does + # nothing, and a NetworkPolicy cannot express "only these hosts". What the + # cap still buys is real but narrow: this pod cannot open any OTHER outbound + # port, so a compromised bridge cannot reach a database, a mail server or an + # SSH port anywhere in the cluster. + # + # Inbound 80 is not restricted by source in the default (it is the probe + # port), 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 80, 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. + # + # 80 and 443 are already allowed, so most bridges need nothing here. Add a + # port only for a source that serves feeds somewhere else. + extraPorts: [] diff --git a/samba/Chart.yaml b/samba/Chart.yaml index ca2f845..19f2e28 100644 --- a/samba/Chart.yaml +++ b/samba/Chart.yaml @@ -5,6 +5,6 @@ icon: https://avatars.githubusercontent.com/u/13281359 type: application -version: 7.1.1+up4.23.10 +version: 8.0.0+up4.23.10 appVersion: 4.23.10 diff --git a/samba/templates/NOTES.txt b/samba/templates/NOTES.txt index 8c67a48..bab5065 100644 --- a/samba/templates/NOTES.txt +++ b/samba/templates/NOTES.txt @@ -245,3 +245,59 @@ samba.env IS THE CONTAINER'S WHOLE ENVIRONMENT fully rendered by the chart, so image variables that would generate a config do not override it. {{- 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 445 and 139 — the POD ports, open to any source, because they + are published as NodePorts ({{ .Values.samba.smbNodePort }} and {{ .Values.samba.netbiosNodePort }}) and the clients are machines + on the LAN. Outbound: DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THE POLICY NAMES 445 AND 139, NOT THE NODEPORTS. A NetworkPolicy is evaluated + at the pod's network interface, after kube-proxy has already translated the + NodePort — so the NodePort numbers would match nothing there. This is the + easiest mistake to make in this chart, and it would look like a strict policy + while blocking every SMB client. + + A DOMAIN-JOINED SAMBA NEEDS MORE: Kerberos (88) and LDAP (389 or 636) in + networkPolicy.egress.extraPorts. A standalone one needs nothing. + + 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/samba/templates/samba.networkpolicy.yaml b/samba/templates/samba.networkpolicy.yaml new file mode 100644 index 0000000..853ee04 --- /dev/null +++ b/samba/templates/samba.networkpolicy.yaml @@ -0,0 +1,118 @@ +{{- /* +NetworkPolicy for samba (Gitea #5). + +Samba publishes both of its ports as NodePorts and the clients are machines on +the LAN, so both inbound rules are deliberately open: after NodePort +masquerading the source address is a cluster node anyway, and a source selector +could only ever be a list of node IPs - a restriction in appearance and a +maintenance trap in fact. 445 is additionally the port the probes address. + +THE NUMBERS HERE ARE 445 AND 139, NOT samba.smbNodePort AND +samba.netbiosNodePort. That distinction is this chart's easiest mistake, which +is why it is spelled out: a NetworkPolicy is evaluated at the pod's interface +after kube-proxy has translated the NodePort, so writing 30445 would match +nothing while looking strict - and, being the only rule for the port, would +block every SMB client instead. + +Egress is DNS only, which is as tight as a file server gets. Note what that +does NOT cover and why it does not matter: the NetBIOS name-service broadcasts +on 137 and 138/UDP are not published by this chart's Service at all, and +Windows clients here reach the share by address rather than by browse list. + +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 }} +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 because the restriction is nearly total and therefore worth + having: a standalone file server needs name resolution and nothing else. A + domain-joined one would need Kerberos and LDAP, which is what extraPorts is + for. + */}} + - Egress + ingress: + {{- /* + SMB (445) and NetBIOS session service (139), both POD ports, both published as + NodePorts (samba.smbNodePort and samba.netbiosNodePort) and both deliberately + open: the clients are machines on the LAN, and after masquerading the source is + a cluster node. 445 is also the port all three probes open a TCP socket + against, so it is the probe path as well - justified twice over. + + Writing the NodePort numbers here instead would match nothing and block every + client. See the header. + */}} + - ports: + - protocol: TCP + port: 445 + - protocol: TCP + port: 139 + 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 + {{- 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/samba/values.schema.json b/samba/values.schema.json index 2f128ce..633eb17 100644 --- a/samba/values.schema.json +++ b/samba/values.schema.json @@ -9,6 +9,29 @@ "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" + }, + "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/samba/values.yaml b/samba/values.yaml index 2e8fc92..dd0ca82 100644 --- a/samba/values.yaml +++ b/samba/values.yaml @@ -96,3 +96,49 @@ samba: - SETUID storageClass: longhorn + +# --------------------------------------------------------------------------- +# 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: + # THERE IS ONLY ONE VALUE HERE BESIDES enabled, AND THAT IS DELIBERATE. + # Both of this chart's inbound rules are open to any source (see below), so + # neither allowedNamespaces nor probeSources would reach a rule - and a value + # that silently does nothing is worse than a value that is absent: it reads + # as a control. If a source restriction is ever wanted here it needs a rule + # to attach to first. + # + # WHAT THIS POLICY ALLOWS: inbound 445 and 139 - the POD ports - open to any + # source, and outbound nothing but DNS. + # + # BOTH INBOUND RULES ARE OPEN ON PURPOSE. The ports are published as + # NodePorts and the clients are machines on the LAN; after NodePort + # masquerading the source address is a cluster node, so a source selector + # could only be a list of node IPs - a restriction in appearance and a + # maintenance trap in fact. 445 is also the probe port. + # + # AND THE NUMBERS ARE 445 AND 139, NOT samba.smbNodePort AND + # samba.netbiosNodePort. A NetworkPolicy is evaluated at the pod's interface + # after kube-proxy has translated the NodePort, so the NodePort numbers would + # match nothing while looking strict - and would block every client. + # + # Not covered, and it does not matter here: the NetBIOS name-service + # broadcasts on 137/138 UDP are not published by this chart's Service at all, + # and clients reach the share by address rather than by browse list. + enabled: true + 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. + # + # A domain-joined Samba needs Kerberos (88) and LDAP (389 or 636) here. A + # standalone one needs nothing. + extraPorts: [] diff --git a/satisfactory/Chart.yaml b/satisfactory/Chart.yaml index 01f89cc..d30ef8f 100644 --- a/satisfactory/Chart.yaml +++ b/satisfactory/Chart.yaml @@ -3,7 +3,7 @@ name: satisfactory description: A Helm chart to deploy a Satisfactory Gameserver on Kubernetes icon: https://raw.githubusercontent.com/wolveix/satisfactory-server/main/.github/logo.png type: application -version: 7.0.2+upv1.9.10 +version: 8.0.0+upv1.9.10 appVersion: "v1.9.10" maintainers: - name: jklein \ No newline at end of file diff --git a/satisfactory/templates/NOTES.txt b/satisfactory/templates/NOTES.txt index f00465d..a6acb83 100644 --- a/satisfactory/templates/NOTES.txt +++ b/satisfactory/templates/NOTES.txt @@ -323,3 +323,60 @@ env IS APPENDED, NOT MERGED stored in the pod spec and readable by everyone who can read pods in {{ .Release.Namespace }}. {{- 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 three declared game ports only — TCP and UDP {{ include "satisfactory.formatNumber" .Values.server.gamePort }} and TCP + {{ include "satisfactory.formatNumber" .Values.server.messagingPort }} — open to any source, because they are NodePorts and the clients + are players on the internet. Anything else the image binds is NOT reachable. + + THERE IS NO EGRESS RESTRICTION AT ALL, deliberately. What this server needs + outbound is Steam's content delivery, which is not a pinnable port list + (SteamCMD over TCP 80, 443 and 27015-27050; master servers over UDP + 27000-27100 and 4380; Valve changes it; the server re-downloads on every + update). And this is the one chart in the repository with NO install-test — + the image is multiple GB and the first start pulls over 10GB of game files — + so an egress cap here would be the only one in the sweep with no empirical + check behind it. If you want one, write it at release level where Steam's + behaviour can be observed. + + 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/satisfactory/templates/satisfactory.networkpolicy.yaml b/satisfactory/templates/satisfactory.networkpolicy.yaml new file mode 100644 index 0000000..4278d78 --- /dev/null +++ b/satisfactory/templates/satisfactory.networkpolicy.yaml @@ -0,0 +1,101 @@ +{{- /* +NetworkPolicy for satisfactory (Gitea #5). + +INGRESS ONLY, AND THE MISSING EGRESS SECTION IS THE DECISION IN THIS FILE. + +Two reasons, and they compound. + +First, what this server needs outbound is Steam's content delivery, and that is +not a port list anyone can pin down: the dedicated server downloads and UPDATES +the game through SteamCMD over TCP 80 and 443 plus the 27015-27050 range, and +talks to the Steam master servers over UDP 27000-27100 and 4380. Valve changes +that set, the server re-downloads on every update, and a cap that is wrong +leaves a server that cannot start - or worse, one that starts and never appears +in the server browser. + +Second, AND THIS IS WHY THE DECISION GOES THIS WAY RATHER THAN THE OTHER: +satisfactory is the ONE chart in this repository with no install-test at all. +It is in ci/excluded-charts.txt, because the image is multiple GB and the first +start pulls over 10GB of game files - impractical on a runner. So an egress cap +here would be the only policy in the sweep with no empirical check whatsoever +behind it, on the chart whose correct port set is least knowable. Review is the +only net this chart has, and a review cannot verify a Steam port range. + +So valkey's rule applies: policyTypes names only what there is something to +restrict. A deployment that wants an egress cap should write one at release +level, where the Steam behaviour can actually be observed. + +What the Ingress half still buys is real: only the three declared game ports +are reachable, so anything else the image binds is not. + +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 }} +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 + ingress: + {{- /* + THE GAME PORTS, all three published as NodePorts and all deliberately open: the + clients are players on the internet and, after NodePort masquerading, the source + address is a cluster node anyway. + + The game port appears TWICE, once for each protocol, because the server binds + it as both - and a policy that named only TCP would be accepted, would look + complete, and would drop the UDP half of the game traffic. The TCP side is + additionally what the readiness probe opens a socket against. + + The numbers go through satisfactory.formatNumber for the same reason they do in + the Service and the StatefulSet: a port read from values as a float64 renders + in exponential notation above a certain size, and a NetworkPolicy with "3.0777e + +04" in it is not valid. + */}} + - ports: + - protocol: TCP + port: {{ include "satisfactory.formatNumber" .Values.server.gamePort }} + - protocol: UDP + port: {{ include "satisfactory.formatNumber" .Values.server.gamePort }} + - protocol: TCP + port: {{ include "satisfactory.formatNumber" .Values.server.messagingPort }} +{{- end }} diff --git a/satisfactory/values.schema.json b/satisfactory/values.schema.json index 25a9d92..c34ae43 100644 --- a/satisfactory/values.schema.json +++ b/satisfactory/values.schema.json @@ -9,6 +9,16 @@ "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" + } + } + }, "scaleDown": { "type": "boolean" }, diff --git a/satisfactory/values.yaml b/satisfactory/values.yaml index e990d0d..b056b27 100644 --- a/satisfactory/values.yaml +++ b/satisfactory/values.yaml @@ -148,3 +148,40 @@ securityContext: drop: - ALL add: [] + +# --------------------------------------------------------------------------- +# 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: + # enabled IS THE ONLY VALUE HERE, and that is deliberate. The single inbound + # rule is open to any source and there is no egress section at all (see + # below), so allowedNamespaces and probeSources would both reach no rule - + # and a value that silently does nothing is worse than one that is absent, + # because it reads as a control. + # + # WHAT THIS POLICY ALLOWS: inbound the three declared game ports only, open + # to any source - they are NodePorts and the clients are players on the + # internet. THERE IS NO EGRESS SECTION, and that is the decision in this + # chart rather than an omission. + # + # TWO REASONS, AND THEY COMPOUND. First, the outbound requirement is Steam's + # content delivery, which is not a pinnable port list: SteamCMD uses TCP 80, + # 443 and 27015-27050, the master servers UDP 27000-27100 and 4380, Valve + # changes the set, and the server re-downloads on every update. Second, and + # decisively: satisfactory is the ONE chart here with no install-test at all + # (ci/excluded-charts.txt - the image is multiple GB and the first start + # pulls over 10GB). An egress cap here would be the only one in the sweep + # with no empirical check behind it, on the chart whose correct port set is + # least knowable, and a review cannot verify a Steam port range. + # + # So policyTypes names Ingress only, for the same reason valkey does: a rule + # that has to be widened until it permits everything makes the object look + # stricter than it is. A deployment that wants an egress cap should write one + # at release level, where Steam's behaviour can be observed. + # + # The game port is allowed as BOTH TCP and UDP, because the server binds it + # as both - a TCP-only rule would be accepted and would drop half the game + # traffic. + enabled: true diff --git a/searxng/Chart.yaml b/searxng/Chart.yaml index 19bf68c..e7110d8 100644 --- a/searxng/Chart.yaml +++ b/searxng/Chart.yaml @@ -9,7 +9,7 @@ type: application # (2026.8.22-9fea41204) and "latest" moves daily. appVersion is therefore the # exact tag, and the +up suffix normalizes the dash to a dot as the versioning # scheme requires - the same shape rss-bridge and minecraft-server use. -version: 1.1.1+up2026.10.13.22c64b8d3 +version: 2.0.0+up2026.10.13.22c64b8d3 appVersion: "2026.10.13-22c64b8d3" maintainers: diff --git a/searxng/templates/NOTES.txt b/searxng/templates/NOTES.txt index fe0793a..3491d69 100644 --- a/searxng/templates/NOTES.txt +++ b/searxng/templates/NOTES.txt @@ -149,3 +149,54 @@ NOTHING TO BACK UP the signing key from the Secret, and the SQLite caches under /tmp plus /var/cache/searxng are emptyDirs that refill on their own. App-version upgrades and rollbacks therefore carry no data risk. +{{- 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 8080 only. Outbound: DNS, TCP 443 (the search engines) and 6379 + (external Valkey){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + 443 GOES TO ANY DESTINATION, because querying search engines is the + application and this object cannot name a host. 6379 is the STANDARD Valkey + port: the URL sits in a 1Password item the chart never parses, so a Valkey on + another port is blocked — put it in networkPolicy.egress.extraPorts. + + 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 }}-deployment + + 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/searxng/templates/searxng.networkpolicy.yaml b/searxng/templates/searxng.networkpolicy.yaml new file mode 100644 index 0000000..f44b1ab --- /dev/null +++ b/searxng/templates/searxng.networkpolicy.yaml @@ -0,0 +1,178 @@ +{{- /* +NetworkPolicy for searxng (Gitea #5). + +SearXNG is a metasearch engine, so like rss-bridge its egress can only be +coarse: 443 to ANY destination, because querying search engines is the +application and a NetworkPolicy cannot name a host. 6379 is allowed as well, +for the EXTERNAL Valkey the limiter needs - this chart ships none of its own, +and the URL lives in a 1Password item the chart never parses, so 6379 is the +standard port rather than the configured one. + +What the cap buys here is the same narrow but real thing: no other outbound +port, so a compromised instance cannot reach a database or a mail server. + +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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 8080 - the image's own GRANIAN_PORT and its EXPOSE, the port the Service + targets, and the one all three probes address. The probe path. + */}} + - ports: + - protocol: TCP + port: 8080 + {{- 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 and the + HTTPRoute both go 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 + {{- /* + HTTPS to any destination. This IS the application: a metasearch engine that + cannot reach the engines returns nothing. Cannot be narrowed - the engine list + is hostnames, and this object matches CIDRs and selectors only. + */}} + - ports: + - protocol: TCP + port: 443 + {{- /* + Valkey/Redis for the limiter, on the STANDARD port. This chart deploys no + Valkey; secrets.valkeySecretRef points at an external one through a URL the + chart never parses, so a Valkey on another port needs + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- 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/searxng/values.schema.json b/searxng/values.schema.json index f73c480..0182a86 100644 --- a/searxng/values.schema.json +++ b/searxng/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/searxng/values.yaml b/searxng/values.yaml index ab86fb9..6ea00f4 100644 --- a/searxng/values.yaml +++ b/searxng/values.yaml @@ -399,3 +399,83 @@ httpRoute: # with it: signed image-proxy URLs and the OpenSearch description keep naming # ingress.domain. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 8080 only, and outbound DNS plus TCP 443 + # (the search engines - this IS the application) and 6379 (the external + # Valkey the limiter uses). + # + # WHAT IT DOES NOT DO: 443 goes to ANY destination, because the engine list + # is hostnames and this object matches CIDRs and selectors only. And 6379 is + # the STANDARD Valkey port, not the configured one - the URL lives in a + # 1Password item this chart never parses, so a Valkey elsewhere is blocked + # and belongs in egress.extraPorts. + # + # Inbound 8080 is not restricted by source in the default (probe port), 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 8080, 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. + # + # Add 80 if an engine or a proxy in use is plain HTTP, or the real Valkey + # port if it is not 6379. + extraPorts: [] diff --git a/stalwart/Chart.yaml b/stalwart/Chart.yaml index 990fc0c..2c97412 100644 --- a/stalwart/Chart.yaml +++ b/stalwart/Chart.yaml @@ -18,17 +18,26 @@ type: application # all three of latest / v0.16 / v0.16.24 resolved to one digest pushed seconds # apart, so either floating name would change the running image with no commit # in this repository and nothing to report it. -# MINOR, not major, and the evidence is a diff rather than a judgement. That -# held for the per-group spread constraint and PodDisruptionBudget of Gitea #96 -# (both gated on the COMPUTED replica count being >= 2, and the chart ships -# full: 1 / mx: 0) and it holds again for the MTA-STS routes of Gitea #123: the -# list is the switch and it ships EMPTY, so no object is added and no value -# becomes required in either routing mode. `helm template` against the previous -# version renders byte-for-byte identically for the defaults AND for -# 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.1+upv0.16.26 +# MAJOR for Gitea #5, and the reason is that the test the two MINOR rounds +# below passed is the one this round FAILS. Those rounds argued from a diff: +# the spread constraint and PodDisruptionBudget of #96 (both gated on the +# COMPUTED replica count being >= 2, with the chart shipping full: 1 / mx: 0) +# and the MTA-STS routes of #123 (the list is the switch and it ships EMPTY) +# added no object and made no value required, so `helm template` against the +# previous version rendered byte-for-byte identically for the defaults AND for +# ci/stalwart/test-values.yaml - measured, both empty diffs. New optional +# values with unchanged defaults is exactly the minor case. +# +# #5 is the opposite on every count. networkPolicy.ingressController is new, +# the policy's rendered form changes, and - the part that decides it - the +# object reaches a RUNNING release on the next upgrade and changes what can +# reach the pod. A wrong source there makes a service mute, which is precisely +# the breaking-change case (README, "Versioning"). The app-version patch +# Renovate landed as 1.3.1 is absorbed by this major rather than added to it. +# +# Note for whoever bumps this next: the suffix tracks appVersion, so a major +# here keeps the +up it was merged with. +version: 2.0.0+upv0.16.26 appVersion: "v0.16.26" 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/sure/Chart.yaml b/sure/Chart.yaml index 774fa45..d4d1a76 100644 --- a/sure/Chart.yaml +++ b/sure/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/we-promise/sure/main/public/android-chro type: application -version: 1.1.0+up0.7.4 +version: 2.0.0+up0.7.4 appVersion: "0.7.4" maintainers: diff --git a/sure/templates/NOTES.txt b/sure/templates/NOTES.txt index 1089999..32c37f9 100644 --- a/sure/templates/NOTES.txt +++ b/sure/templates/NOTES.txt @@ -213,3 +213,56 @@ THE CHART CONFIGURES NO MAIL AND NO AI An entry repeating a name the chart already sets wins at install time and is silently lost on the next helm upgrade — read it back off the StatefulSet. {{- 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 3000 only, declared by the web container. Outbound: DNS, TCP + 5432 (Postgres), 6379 (Redis) and 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }} — and those egress + rules apply to the WORKER container as well, because a NetworkPolicy selects + pods and not containers. + + 5432 AND 6379 ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES: the Postgres + host and port are fields of the 1Password item, passed through without being + read. A dependency elsewhere is blocked; add its port to + networkPolicy.egress.extraPorts. + + 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/sure/templates/sure.networkpolicy.yaml b/sure/templates/sure.networkpolicy.yaml new file mode 100644 index 0000000..b3a2d2b --- /dev/null +++ b/sure/templates/sure.networkpolicy.yaml @@ -0,0 +1,190 @@ +{{- /* +NetworkPolicy for sure (Gitea #5). + +Sure runs two containers in one pod - a web container and a worker - and the +policy covers both, because a NetworkPolicy selects PODS and not containers. +That is worth knowing here: the worker declares no port at all and its probes +are exec probes, so it contributes nothing to the ingress rules while being +fully subject to the egress ones. + +Its dependencies are a Postgres (host and port are FIELDS of the 1Password +item, which the chart passes through without reading) and a Redis. 443 is +allowed for outbound HTTPS, which the worker needs for the external data this +application fetches. + +One asymmetry worth noting: liveness uses /up, which touches neither Postgres +nor Redis - deliberately, so a database outage does not restart the pod. The +startup and readiness probes do more. Either way the probe port is 3000 and +the probe path is open by default. + +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 := .Values.ingress.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 because there is something real to restrict, and it applies to + BOTH containers of this pod: a NetworkPolicy selects pods, not containers, so + the worker is covered by these rules even though it declares no port and + contributes nothing to the ingress side. + */}} + - Egress + ingress: + {{- /* + 3000 - declared by the web container (the worker declares no port at all), the + port the Service forwards 80 to, and the one the probes address. The probe + path. + */}} + - ports: + - protocol: TCP + port: 3000 + {{- 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. Here the host and the port are FIELDS of + secrets.dbSecretRef, which the chart passes through to the application without + reading - so it still cannot know the port, and a Postgres elsewhere is blocked + until networkPolicy.egress.extraPorts names it. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Redis/Valkey on the STANDARD port, same reasoning as Postgres above: the URL + sits in a secret this chart does not read. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- /* + Outbound HTTPS, which the worker container needs for the external data this + application fetches. To any destination - hostnames cannot appear here. + */}} + - 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/sure/values.schema.json b/sure/values.schema.json index 07a5e61..81041bb 100644 --- a/sure/values.schema.json +++ b/sure/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/sure/values.yaml b/sure/values.yaml index f964a4b..fe05e49 100644 --- a/sure/values.yaml +++ b/sure/values.yaml @@ -374,3 +374,86 @@ httpRoute: # docs/hosting/webauthn.md). APP_DOMAIN and WEBAUTHN_ALLOWED_ORIGINS follow # ingress.domain as well. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 3000 only (declared by the web container; + # the worker declares no port), and outbound DNS plus TCP 5432 (Postgres), + # 6379 (Redis) and 443 (the external data the worker fetches). + # + # IT COVERS BOTH CONTAINERS. A NetworkPolicy selects pods, not containers, so + # the worker is subject to the egress rules too - which is where it does all + # of its work. + # + # WHAT IT DOES NOT DO: 5432 and 6379 are the STANDARD ports. The Postgres + # host and port are FIELDS of the 1Password item, which the chart passes + # through without reading, so it cannot know them. A dependency elsewhere is + # BLOCKED and belongs in egress.extraPorts. + # + # Inbound 3000 is not restricted by source in the default (probe port), 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 3000, which is the port the web container's 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 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. + # + # Add the real port whenever Postgres or Redis is not on 5432 / 6379, or + # for an SMTP server (587 or 465) if mail is configured. + extraPorts: [] diff --git a/teamspeak-server/Chart.yaml b/teamspeak-server/Chart.yaml index e43a5ca..1ec1d89 100644 --- a/teamspeak-server/Chart.yaml +++ b/teamspeak-server/Chart.yaml @@ -5,5 +5,5 @@ icon: https://avatars.githubusercontent.com/u/136759148 type: application -version: 5.1.3+up6.0.0.beta13.1 +version: 6.0.0+up6.0.0.beta13.1 appVersion: "6.0.0-beta13.1" diff --git a/teamspeak-server/templates/NOTES.txt b/teamspeak-server/templates/NOTES.txt index 4566f25..5e33d83 100644 --- a/teamspeak-server/templates/NOTES.txt +++ b/teamspeak-server/templates/NOTES.txt @@ -239,3 +239,57 @@ env IS APPENDED, NOT MERGED — AND CAN SHADOW THE CHART'S PORTS stored in the pod spec and readable by everyone who can read pods in {{ .Release.Namespace }}. {{- 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: voice (UDP {{ .Values.server.voicePort }}) and file transfer (TCP {{ .Values.server.fileTransferPort }}), both NodePorts and both + open — the clients are on the internet; and the web query interface (TCP + 10080), RESTRICTED to this namespace plus networkPolicy.allowedNamespaces, + because it has no Service, no probe, and is administrative. Outbound: DNS and + TCP 443{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + IF THE SERVER WILL NOT START OR WILL NOT REGISTER AFTER THIS UPGRADE, widen + the egress rule: TeamSpeak's accounting service has historically used ports of + its own (TCP 2008, UDP 2010 on the 3.x line), and this chart cannot know what + the 6.x beta does. Put them in networkPolicy.egress.extraPorts. + + 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/teamspeak-server/templates/teamspeak-server.networkpolicy.yaml b/teamspeak-server/templates/teamspeak-server.networkpolicy.yaml new file mode 100644 index 0000000..771fb16 --- /dev/null +++ b/teamspeak-server/templates/teamspeak-server.networkpolicy.yaml @@ -0,0 +1,161 @@ +{{- /* +NetworkPolicy for teamspeak-server (Gitea #5). + +Three ports, two of them NodePorts facing the internet and one that is purely +internal - so this chart shows the public/internal split without the probe +problem getting in the way of either. + + voice (UDP) NodePort, open. The clients are on the internet. + fileTransfer (TCP) NodePort, open, and also the port all three probes open + a socket against - justified twice. + web query (10080) NO Service at all, no probe, so it is RESTRICTED to this + namespace plus allowedNamespaces. That is the rule worth + having here: the query interface is administrative. + +NOTE THE PORT NUMBERS. This chart happens to use the SAME number for the pod +port and the NodePort (server.voicePort, server.fileTransferPort are both), so +the usual NodePort mistake is invisible here - the number is right either way. +That is a coincidence of this chart's values and not a rule; do not copy the +pattern to a chart where the two differ. + +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 }} +{{- $allowedNs := $np.allowedNamespaces | default list }} +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 because a voice server's outbound needs are small: DNS and + HTTPS. The uncertainty is named in the rule itself rather than hidden - see the + note on TeamSpeak's accounting ports. + */}} + - Egress + ingress: + {{- /* + THE VOICE PORT, UDP, published as a NodePort and deliberately open: the clients + are on the internet, and after masquerading the source address is a cluster + node anyway. + + PROTOCOL MATTERS AS MUCH AS THE NUMBER HERE. Voice is UDP, and a rule written + for TCP on the same number would be accepted, would look complete, and would + drop every voice packet - which is why ci/run-networkpolicy-test.sh checks the + protocol against the containerPort's and not just the number. + */}} + - ports: + - protocol: UDP + port: {{ .Values.server.voicePort | int64 }} + {{- /* + THE FILE-TRANSFER PORT, TCP, also a NodePort and also open. It is additionally + the port all three probes open a TCP socket against, so it is the probe path as + well - open for two independent reasons. + */}} + - ports: + - protocol: TCP + port: {{ .Values.server.fileTransferPort | int64 }} + {{- /* + THE WEB QUERY INTERFACE, and the one rule in this chart with real security + value. It has no Service and no probe, so it can be restricted, and it is: this + namespace plus allowedNamespaces. The query interface is administrative, and + before this object the only thing keeping it off the network was that nothing + published it. + */}} + - ports: + - protocol: TCP + port: 10080 + 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 + {{- /* + Outbound HTTPS, for the server's own update and accounting calls. To any + destination - hostnames cannot appear here. + + IF THE SERVER REFUSES TO START AFTER THIS POLICY ARRIVES, THIS IS THE RULE TO + WIDEN: TeamSpeak's accounting service has historically used ports of its own + (TCP 2008, UDP 2010 on the 3.x line), and this chart cannot know what the 6.x + beta does. The install-test covers this chart, so a startup that depends on a + blocked port shows up there rather than in production - but a license or + accounting check that merely degrades would not. + */}} + - 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/teamspeak-server/values.schema.json b/teamspeak-server/values.schema.json index 700b4e2..5db644f 100644 --- a/teamspeak-server/values.schema.json +++ b/teamspeak-server/values.schema.json @@ -9,6 +9,36 @@ "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" + } + }, + "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/teamspeak-server/values.yaml b/teamspeak-server/values.yaml index 6e23bcb..13d6fb1 100644 --- a/teamspeak-server/values.yaml +++ b/teamspeak-server/values.yaml @@ -89,3 +89,60 @@ securityContext: storage: size: 25Gi storageClass: longhorn + +# --------------------------------------------------------------------------- +# 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: + # NOTE WHAT IS NOT HERE: probeSources. The port the probes address is the + # file-transfer port, and its rule is open for an independent reason - it is + # a NodePort facing the internet. So probeSources would reach no rule, and a + # value that silently does nothing reads as a control it is not. + # allowedNamespaces IS here, because the web-query rule uses it. + # + # WHAT THIS POLICY ALLOWS, and the three ports differ: + # - voice (server.voicePort, UDP): NodePort, OPEN - clients are on the + # internet. Note the protocol: a TCP rule on the same number would be + # accepted and would drop every voice packet. + # - fileTransfer (server.fileTransferPort, TCP): NodePort, open, and also + # the probe port. + # - web query (10080): RESTRICTED to this namespace plus + # allowedNamespaces. No Service, no probe, and administrative - this is + # the rule with real value here. + # Outbound: DNS and TCP 443. + # + # IF THE SERVER REFUSES TO START, WIDEN THE EGRESS RULE FIRST. TeamSpeak's + # accounting service has historically used ports of its own (TCP 2008, UDP + # 2010 on the 3.x line) and this chart cannot know what the 6.x beta does. + # The install-test covers this chart, so a startup that depends on a blocked + # port shows up there - but a check that merely degrades would not. + # + # A coincidence worth not copying: this chart uses the same number for the + # pod port and the NodePort, so the usual NodePort mistake is invisible here. + # That is a property of these values, not a rule. + 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: [] + 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 2008 and UDP 2010 are the entries to try if the server will not start + # or will not register - see the note above. + extraPorts: [] diff --git a/template-chart/templates/NOTES.txt b/template-chart/templates/NOTES.txt index 4541cee..0993867 100644 --- a/template-chart/templates/NOTES.txt +++ b/template-chart/templates/NOTES.txt @@ -182,6 +182,57 @@ THIS CHART IS A SCAFFOLD — IT IS NOT MEANT TO RUN "{{ .Release.Name }}-{{ .Chart.Name }}-config-map" while the ConfigMap this chart creates is "{{ .Release.Name }}-{{ .Chart.Name }}-configmap": replace the whole block with your app's real variables rather than repairing the example. +{{- 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. Only the + container ports this chart declares are reachable at all; anything else the + process binds is blocked without anyone having to notice it exists. Outbound + traffic is capped at DNS{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }} plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + Three limits belong in the same breath, because none of them is visible in + the object: + + - EGRESS IS BY PORT, NEVER BY DESTINATION. A NetworkPolicy matches CIDRs and + selectors and cannot express a hostname, so every rule above permits its + port to ANY address. This is a limit of the object, not a shortcut — and + with this cluster's CNI (Canal: Flannel plus Calico) there is no FQDN rule + to reach for. 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. Check with + + kubectl -n {{ .Release.Namespace }} describe pod -l app={{ .Release.Name }}-{{ .Chart.Name }}-deployment + + and compare "Readiness probe failed" against `kubectl get node -o wide`. +{{- else }} + - THE PROBED PORT IS OPEN INSIDE THE CLUSTER. networkPolicy.probeSources is + empty, so the rule for that port carries no source selector at all: any + pod the CNI routes to can reach it. Not from the internet — the port has no + NodePort — 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. Prove it rather than assume it: + + kubectl -n {{ .Release.Namespace }} get networkpolicy {{ .Release.Name }}-{{ .Chart.Name }}-networkpolicy + + shows the object exists; only a connection attempt from an unrelated + namespace shows that it bites. + + Switch it off with networkPolicy.enabled: false{{ if .Values.networkPolicy.allowedNamespaces }}; the namespaces currently + allowed in addition to {{ .Release.Namespace }} are {{ join ", " .Values.networkPolicy.allowedNamespaces }}{{ end }}. +{{- end }} {{- if .Values.ingress.enabled }} {{- with .Values.ingress.annotations }} diff --git a/template-chart/templates/xxx.networkpolicy.yaml b/template-chart/templates/xxx.networkpolicy.yaml new file mode 100644 index 0000000..023f1fd --- /dev/null +++ b/template-chart/templates/xxx.networkpolicy.yaml @@ -0,0 +1,200 @@ +{{- /* +NetworkPolicy — the reference implementation for Gitea #5. + +Copy this file with the rest of template-chart and replace every xxx: the +values paths (.Values.networkPolicy.*), the helper prefix and the podSelector +label, exactly as in the other template files. + +EVERY COMMENT IN THIS FILE IS A TEMPLATE COMMENT, and that is not a style +choice. A "#" comment is part of the RENDERED manifest: it travels into the +cluster, shows up in `helm get manifest` and `kubectl get -o yaml`, and bloats +every diff a reviewer reads. Reasoning belongs in a Go template comment, which +renders nothing. This caught several rounds of this rollout. + +And a second-order trap found while writing this header: the prose inside a +template comment must not spell out the comment's own closing delimiter. The +parser ends the comment at the first occurrence, and the remaining prose is +then emitted as manifest content - the render fails with "did not find +expected node content", pointing at the template and not at the sentence. +Describe the delimiters instead of quoting them. + +WHAT THIS OBJECT DOES, AND - MORE IMPORTANTLY - WHAT IT DOES NOT + +It is a CEILING ON REACHABILITY, not an authentication layer. Three limits are +real and worth having: + + 1. ONLY THE DECLARED PORTS ARE REACHABLE AT ALL. Whatever else the process + binds - a debug listener, a management interface the image opens by + default, a port that appears in the next app version - is blocked without + anyone having to notice it exists. This is the largest part of what a + default-on policy buys in this repository. + 2. EGRESS IS CAPPED BY PORT, so a compromised pod cannot open arbitrary + outbound connections. + 3. INGRESS IS CAPPED BY SOURCE for every port no network probe touches. + +And three things it does NOT do, each of which has to be written down per +chart rather than quietly assumed: + + a. EGRESS IS COARSE. A NetworkPolicy matches CIDRs and selectors and NEVER + hostnames, so "only to the database host" cannot be expressed. This + cluster's CNI is Canal (Flannel plus Calico for policy), which has no + FQDN rule; Calico's GlobalNetworkSet could express one but would bind + every chart to Calico - the same argument that kept implementation- + specific objects out of the Gateway-API sweep (Gitea #114). So each + egress rule below permits its PORT to any destination. That is a limit of + the object, not a shortcut. + b. THE PROBED PORT IS OPEN INSIDE THE CLUSTER BY DEFAULT. See the long note + on networkPolicy.probeSources in values.yaml. Narrowing it needs the + worker node IPs, which this chart cannot know. + c. A POLICY IS ONLY A BOUNDARY WHERE THE CNI ENFORCES ONE. An unenforced + policy is accepted by the API server and filters nothing, so everything + looks configured while nothing is filtered. Measured for this cluster + (Canal, 6/6) and for the CI cluster (k3d, which brings k3s' own policy + controller) - verify it rather than reading it off a value. + +ONE CLUSTER SETTING THIS WHOLE CONVENTION DEPENDS ON AND CANNOT CHECK: +TRAEFIK MUST NOT RUN WITH hostNetwork. + +Measured on 2026-10-14: the rke2-traefik DaemonSet has no hostNetwork, and its +pods carry real pod IPs (10.42.3.120, 10.42.4.94, 10.42.0.187; the pod CIDR is +10.42.0.0/16, while the nodes sit on entirely different addresses). ONLY +BECAUSE OF THAT does the podSelector in networkPolicy.ingressController match +it. + +Switch the ingress controller to host networking and its pods share the node's +network namespace. They are then not selectable as pods at all, 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 exactly +that shape: all web traffic stops together and every pod stays Ready, because +the probe path does not run through the controller. + +Nothing here can detect this and nothing here can fail usefully on it, which is +the same class of dependency as --enable-certificate-owner-ref in #126. It is +written here, on the thing that depends on it, so whoever changes the +controller's networking meets the consequence. + +AND A POLICY DOES APPLY TO TRAFFIC THAT CAME THROUGH A SERVICE, which is why +the ingressController rule works in the first place. 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 or a namespaceSelector can +still match the sender. Traffic arriving from outside through a NodePort with +the default externalTrafficPolicy: Cluster is MASQUERADED to a node, which is +why such rules cannot be restricted by source at all and are left open instead. + +THE PORT NUMBERS HERE 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. Writing 30080 where the pod listens +on 8080 matches nothing: the object is accepted, looks strict, and blocks +every inbound connection instead. ci/run-networkpolicy-test.sh asserts this +mechanically - every port named here must exist as a containerPort of the pod +spec this policy selects. +*/ -}} +{{- if .Values.networkPolicy.enabled }} +{{- $np := .Values.networkPolicy }} +{{- $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 Deployment's pods. A chart that keeps the StatefulSet + instead has to move this selector along with the Service's, and + ci/run-networkpolicy-test.sh fails if it selects no pod spec of the + same render - which is the mistake that would otherwise leave a + NetworkPolicy in the namespace filtering nothing at all. */}} + podSelector: + matchLabels: + app: {{ .Release.Name }}-{{ .Chart.Name }}-deployment + policyTypes: + - Ingress + {{- /* Egress is listed because there IS something to restrict here: this + workload needs DNS and nothing else by default. A chart whose + workload opens no outbound connection of its own must NOT list + Egress - valkey is the example - because an egress rule that + constrains nothing makes the object look stricter than it is. */}} + - Egress + ingress: + {{- /* The http port. It is the port all three probes address, so it is + the probe path, and the shape below is the whole reason + networkPolicy.probeSources exists. */}} + - ports: + {{- range .Values.xxx.ports }} + - protocol: {{ .protocol | default "TCP" }} + port: {{ .containerPort }} + {{- end }} + {{- 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 here too, 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 namespaces this chart does + not own. */}} + kubernetes.io/metadata.name: {{ .Release.Namespace }} + {{- 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 the whole + controller namespace - kube-system here, i.e. to everything in + the cluster's own 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 }} + {{- /* 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. Note what an + EMPTY "from: []" would do instead: it also allows everything, but + while looking like a restriction. Hence no key at all, and + ci/run-networkpolicy-test.sh rejects the empty form. */}} + {{- 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 + {{- with $np.egress }} + {{- with .extraPorts }} + {{- /* Ports only - see note (a) in the header. Any destination. */}} + - ports: + {{- range . }} + - protocol: TCP + port: {{ int . }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/template-chart/values.schema.json b/template-chart/values.schema.json index c278ae2..fcbcdef 100644 --- a/template-chart/values.schema.json +++ b/template-chart/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/template-chart/values.yaml b/template-chart/values.yaml index f40b321..85ef0de 100644 --- a/template-chart/values.yaml +++ b/template-chart/values.yaml @@ -174,3 +174,120 @@ httpRoute: # is how a migration from the Ingress runs: the HTTPRoute takes a second # hostname while the Ingress keeps ingress.domain. hostnames: [] + +# --------------------------------------------------------------------------- +# NetworkPolicy (Gitea #5) — the reference block. Copy it with the template. +# --------------------------------------------------------------------------- +networkPolicy: + # ON BY DEFAULT, which is the decision this whole rollout rests on: a + # NetworkPolicy that ships switched off is reached only when somebody + # remembers to switch it on, and then the issue is closed while nothing is + # filtered. + # + # READ THIS BEFORE RELYING ON IT: a NetworkPolicy is only a boundary if the + # cluster's CNI enforces policies. Where it does not, the API server still + # accepts the object and everything looks configured while nothing is + # filtered - an unenforced policy is a false sense of security, not a weak + # one. Verify on the cluster (try to connect from an unrelated namespace) + # rather than reading it off this value. + # + # WHAT IS ACTUALLY CAPPED, and what is not, is written out in the template's + # header - read it once before assuming this value means "the workload is + # isolated". In short: only the declared ports are reachable, egress is + # capped by PORT (never by host - the object cannot express a hostname), and + # the probed port stays open inside the cluster until probeSources is set. + 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 by the "kubernetes.io/metadata.name" label, which Kubernetes sets on + # every namespace itself (>= 1.21), so no labelling work is needed on a + # namespace this chart does not own - but also no typo protection: a + # namespace name that does not exist matches nothing and is silently + # ineffective. + # + # NOTE THE GRANULARITY: an entry here opens the port to EVERY pod in that + # namespace. For the ingress controller use ingressController below instead, + # which pins the source down to the controller's pods. + allowedNamespaces: [] + + # Source CIDRs allowed to reach the port the probes address. This is the + # single most dangerous value in this block, in both directions, so it is + # spelled out in full. + # + # EMPTY MEANS OPEN, NOT BLOCKED. With no entry the rule for that port is + # rendered WITHOUT a "from" selector, so the port is reachable from anywhere + # the CNI routes to - which, since this port carries no NodePort, means from + # inside the cluster and not from the internet. + # + # That is the deliberate choice between two bad defaults. The kubelet's + # liveness/readiness/startup probes originate from the IP of the WORKER NODE + # running the pod, an address this chart cannot know and must not guess: a + # guessed CIDR that excludes the real node addresses 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. Shipping "open" instead is + # honest about what it does. + # + # Narrow it by listing the worker node IPs (e.g. ["10.0.1.11/32", + # "10.0.1.12/32"]) — and mind the companion trap: once this list is + # non-empty the rule IS restricted, which also cuts off everything else that + # reaches this port. The chart adds the release's own namespace, the + # ingressController below and allowedNamespaces to the same rule for exactly + # that reason. + # + # A probe that is an "exec" probe is NOT affected by any of this: it runs + # inside the container through the kubelet's runtime and puts no packet on + # the network, which is why a chart with exec-only probes (valkey) can + # restrict its port by source with no probeSources at all. + 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 inside the restricted branch - with the default (open) rule + # the controller reaches the port anyway and naming it would be noise. + # + # The two keys are rendered as ONE "from" entry, which makes them an AND: + # pods carrying podLabels IN namespace. That is the point of having them + # here at all - putting the controller's namespace into allowedNamespaces + # instead would open the port to every pod in kube-system. + # + # THE DEFAULT IS 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 with the pod label + # app.kubernetes.io/name=rke2-traefik. The same Traefik also serves the + # Gateway API here (GatewayClass "traefik", controller + # traefik.io/gateway-controller, no pods of its own in the Gateway's + # namespace), so this one source covers BOTH the Ingress and the HTTPRoute + # path. + # + # A policy written against the WRONG controller is the expensive mistake in + # this block: it is accepted, it looks strict, and it silently blocks all + # web traffic. Check the cluster before changing these two keys, and note + # that ingress.className says nothing about them - the class names the + # controller's implementation, not the pods its 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 ipBlock CIDRs and pod/namespace + # selectors, never hostnames, so "only to the database host" cannot be + # written at all. Each entry here permits its port to ANY destination. + # What the cap still buys is real: a compromised pod cannot open arbitrary + # outbound connections. + # + # Add what the deployment actually needs: 443 for an outbound HTTPS call + # (an OIDC provider's discovery document, an S3 endpoint, an app that + # fetches), 5432 for Postgres, 3306 for MariaDB, 6379 for Valkey/Redis, + # 587 or 465 for SMTP submission. A chart that KNOWS its outbound port + # from its own values should derive it in the template instead of asking + # for it twice (stalwart does this with dataStore.postgres.port). + extraPorts: [] diff --git a/urlaubsverwaltung/Chart.yaml b/urlaubsverwaltung/Chart.yaml index 9664eb2..2470ec7 100644 --- a/urlaubsverwaltung/Chart.yaml +++ b/urlaubsverwaltung/Chart.yaml @@ -4,5 +4,5 @@ description: A Helm chart to deploy urlaubsverwaltung on Kubernetes icon: https://raw.githubusercontent.com/urlaubsverwaltung/urlaubsverwaltung/main/src/main/resources/static/favicons/apple-touch-icon.png type: application -version: 10.1.1+up6.17.0 +version: 11.0.0+up6.17.0 appVersion: 6.17.0 diff --git a/urlaubsverwaltung/templates/NOTES.txt b/urlaubsverwaltung/templates/NOTES.txt index d003a72..911e624 100644 --- a/urlaubsverwaltung/templates/NOTES.txt +++ b/urlaubsverwaltung/templates/NOTES.txt @@ -416,3 +416,54 @@ urlaubsverwaltung.env IS ADDITIVE ONLY, AND A COLLISION IS LOST ON UPGRADE urlaubsverwaltung.env is empty in this release, so the placeholders above are live exactly as printed. {{- 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 8080 only. Outbound: DNS, TCP 5432 and 3306 (the database — both + conventional ports, because the chart never parses the JDBC URL and cannot + tell which engine is behind it) and 443 (OIDC){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + A database on any other port is blocked, and the symptom is a connection + timeout inside a Java stack trace rather than anything naming this object. + Put the real port in networkPolicy.egress.extraPorts. + + 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 }}-deployment + + 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/urlaubsverwaltung/templates/urlaubsverwaltung.networkpolicy.yaml b/urlaubsverwaltung/templates/urlaubsverwaltung.networkpolicy.yaml new file mode 100644 index 0000000..08f7513 --- /dev/null +++ b/urlaubsverwaltung/templates/urlaubsverwaltung.networkpolicy.yaml @@ -0,0 +1,187 @@ +{{- /* +NetworkPolicy for urlaubsverwaltung (Gitea #5). + +Urlaubsverwaltung takes a full JDBC URL from a 1Password item - "jdbc-url", +e.g. jdbc:postgresql://host:5432/urlaubsverwaltung - and an OIDC provider, +which it REQUIRES: there is no login path besides OIDC. + +BOTH DATABASE PORTS ARE ALLOWED, 5432 AND 3306, AND THAT IS A DELIBERATE +LOOSENING. The chart cannot parse the JDBC URL to find out which engine is +behind it, and this cluster runs both a Postgres and a MariaDB; allowing only +one would mean a policy that breaks half the possible deployments on upgrade, +with the symptom appearing as a connection timeout inside a Java stack trace. +Two ports instead of one is a small widening against a large and silent +failure, and it is written down rather than left to look like carelessness. + +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 := .Values.ingress.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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 8080 - 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: 8080 + {{- 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 + {{- /* + The database, on BOTH conventional ports: 5432 for PostgreSQL and 3306 for + MySQL/MariaDB. The chart receives a full JDBC URL inside a 1Password item and + never parses it, so it cannot know which engine or port is in use - and this + cluster runs both. Allowing one of the two would break half the possible + deployments on upgrade, as a connection timeout buried in a Java stack trace. + A database on any other port needs networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + - protocol: TCP + port: 3306 + {{- /* + Outbound HTTPS, for the OIDC provider: the chart fetches the issuer's + configuration and exchanges tokens over it, and the issuer URL lives inside a + 1Password item the chart never parses. 443 to any destination is as narrow as + this object gets for that - a hostname cannot appear in a NetworkPolicy. An + issuer on another port (a CI dex on 5556) needs + networkPolicy.egress.extraPorts. + */}} + - 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/urlaubsverwaltung/values.schema.json b/urlaubsverwaltung/values.schema.json index 5caffcc..ed48c3e 100644 --- a/urlaubsverwaltung/values.schema.json +++ b/urlaubsverwaltung/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/urlaubsverwaltung/values.yaml b/urlaubsverwaltung/values.yaml index e16a6cc..977fa89 100644 --- a/urlaubsverwaltung/values.yaml +++ b/urlaubsverwaltung/values.yaml @@ -195,3 +195,84 @@ urlaubsverwaltung: cpu: 0.1 memory: 768Mi ephemeralStorage: 10Mi + +# --------------------------------------------------------------------------- +# 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 8080 only, and outbound DNS plus TCP 5432 + # AND 3306 (the database - see below) and 443 (the OIDC provider, which this + # application requires: there is no other login path). + # + # WHY BOTH DATABASE PORTS: the chart is handed a full JDBC URL inside a + # 1Password item and never parses it, so it cannot tell PostgreSQL from + # MariaDB - and this cluster runs both. Allowing only one would break half the + # possible deployments on upgrade, and the symptom would be a connection + # timeout inside a Java stack trace. A deliberate, small widening against a + # large and silent failure. + # + # Inbound 8080 is not restricted by source in the default (probe port), 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 8080, 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 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. + # + # Add the real port for a database that is on neither 5432 nor 3306, or for + # an OIDC issuer that is not on 443. + extraPorts: [] 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: diff --git a/voucher-vault/Chart.yaml b/voucher-vault/Chart.yaml index 81890bc..c033cee 100644 --- a/voucher-vault/Chart.yaml +++ b/voucher-vault/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/l4rm4nd/VoucherVault/main/myapp/static/a type: application -version: 6.2.1+up1.30.3 +version: 7.0.0+up1.30.3 appVersion: "1.30.3" maintainers: diff --git a/voucher-vault/templates/NOTES.txt b/voucher-vault/templates/NOTES.txt index c743c3c..c79056c 100644 --- a/voucher-vault/templates/NOTES.txt +++ b/voucher-vault/templates/NOTES.txt @@ -219,3 +219,53 @@ EXPIRY NOTIFICATIONS STAY SILENT UNTIL A USER ADDS AN APPRISE URL schedule runs daily at 09:00 in the container's time zone (TZ={{ index $env "TZ" | default "Europe/Berlin" }}) and is created ONLY while an empty database is initialized — delete it in the admin and no later start brings it back. +{{- 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) and + 443 (OIDC){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + THOSE ARE THE STANDARD PORTS, NOT YOUR CONFIGURED ONES — the connection + details live in 1Password items this chart never parses. A dependency + elsewhere is blocked; add its port to networkPolicy.egress.extraPorts. + + 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/voucher-vault/templates/voucher-vault.networkpolicy.yaml b/voucher-vault/templates/voucher-vault.networkpolicy.yaml new file mode 100644 index 0000000..51d98e7 --- /dev/null +++ b/voucher-vault/templates/voucher-vault.networkpolicy.yaml @@ -0,0 +1,183 @@ +{{- /* +NetworkPolicy for voucher-vault (Gitea #5). + +Voucher-Vault reaches a Postgres, a Redis and an OIDC provider, all three +configured through 1Password items the chart never parses - so the egress rules +name the standard ports (5432, 6379, 443) and a dependency elsewhere is blocked +until networkPolicy.egress.extraPorts says otherwise. + +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 := .Values.ingress.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 because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - 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 the chart never parses, so this is the conventional port and not the + configured one; a Postgres anywhere else is blocked and belongs in + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Redis/Valkey on the STANDARD port, same reasoning as Postgres above: the URL + sits in a secret this chart does not read. + */}} + - ports: + - protocol: TCP + port: 6379 + {{- /* + Outbound HTTPS, for the OIDC provider: the chart fetches the issuer's + configuration and exchanges tokens over it, and the issuer URL lives inside a + 1Password item the chart never parses. 443 to any destination is as narrow as + this object gets for that - a hostname cannot appear in a NetworkPolicy. An + issuer on another port (a CI dex on 5556) needs + networkPolicy.egress.extraPorts. + */}} + - 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/voucher-vault/values.schema.json b/voucher-vault/values.schema.json index 0ebca96..cd9f3cd 100644 --- a/voucher-vault/values.schema.json +++ b/voucher-vault/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/voucher-vault/values.yaml b/voucher-vault/values.yaml index 526df5f..c6eb3b7 100644 --- a/voucher-vault/values.yaml +++ b/voucher-vault/values.yaml @@ -274,3 +274,81 @@ httpRoute: # 127.0.0.1 on purpose. So a second hostname needs ingress.domain changed in # the same step. hostnames: [] + +# --------------------------------------------------------------------------- +# 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) and 443 (the OIDC provider). + # + # WHAT IT DOES NOT DO: those are the STANDARD ports, not the configured ones - + # all three dependencies arrive as connection details inside 1Password items + # this chart never parses. A dependency on another port is BLOCKED and belongs + # in egress.extraPorts. + # + # Inbound 8000 is not restricted by source in the default (probe port), 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 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. + # + # Add the real port whenever Postgres, Redis or the OIDC issuer is not on + # 5432 / 6379 / 443. + extraPorts: [] diff --git a/ytdlp-web-player/Chart.yaml b/ytdlp-web-player/Chart.yaml index 3a25718..c1ee8c6 100644 --- a/ytdlp-web-player/Chart.yaml +++ b/ytdlp-web-player/Chart.yaml @@ -5,7 +5,7 @@ icon: https://raw.githubusercontent.com/matszwe02/ytdlp_web_player/main/src/stat type: application -version: 1.1.0+upv1.2.0 +version: 2.0.0+upv1.2.0 appVersion: "v1.2.0" maintainers: diff --git a/ytdlp-web-player/templates/NOTES.txt b/ytdlp-web-player/templates/NOTES.txt index 7388c37..9291a5d 100644 --- a/ytdlp-web-player/templates/NOTES.txt +++ b/ytdlp-web-player/templates/NOTES.txt @@ -324,3 +324,56 @@ WHAT THIS CHART DELIBERATELY DOES NOT DO and there is no value to make them work. No PersistentVolume. No mirror of the upstream's twenty environment switches as chart values — they live in ytdlpWebPlayer.env, so an upstream release cannot make this chart lie. +{{- 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 5000 only. Outbound: DNS plus TCP 80 and 443 to ANY destination{{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, + plus TCP {{ join ", " . }}{{ end }}{{ end }} — fetching media is the application. + + THE OAUTH2-PROXY SUBCHART IS NOT COVERED BY THIS POLICY. It selects this + chart's own workload by label; the subchart's Deployment carries its own + labels and ships no policy of its own. With the proxy enabled, the pod that + faces the Ingress is therefore unrestricted in both directions. Nothing in + this chart can change that — an upstream chart is consumed as it comes — so + do not read "this chart has a NetworkPolicy" as "this release is covered". + + 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 }}-deployment + + 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/ytdlp-web-player/templates/ytdlp-web-player.networkpolicy.yaml b/ytdlp-web-player/templates/ytdlp-web-player.networkpolicy.yaml new file mode 100644 index 0000000..db24ced --- /dev/null +++ b/ytdlp-web-player/templates/ytdlp-web-player.networkpolicy.yaml @@ -0,0 +1,177 @@ +{{- /* +NetworkPolicy for ytdlp-web-player (Gitea #5). + +Downloading media is this application's purpose, so egress 80 and 443 to any +destination is the rule and cannot be narrowed - a NetworkPolicy matches CIDRs +and selectors, never hostnames. + +AND ONE THING THIS POLICY DOES NOT COVER AT ALL, which matters more here than +in any other chart of the sweep: THE OAUTH2-PROXY SUBCHART'S PODS. This policy +selects this chart's own workload by its app label; the subchart renders its own +Deployment with its own labels and brings no policy of its own. So with +oauth2-proxy enabled, the proxy pod - the component that actually faces the +Ingress - is unrestricted in both directions. That is a gap in the convention +and not a decision of this chart: an upstream chart is consumed as it comes +(README, "Chart dependencies"), and patching a subchart's templates is not done +here. Said out loud so nobody reads "the chart has a NetworkPolicy" as "the +release is covered". + +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 Deployment'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 }}-deployment + policyTypes: + - Ingress + {{- /* + Egress is listed because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 5000 - the port from ytdlpWebPlayer.ports named "http", which is the single + place it is configured: the chart derives PORT from it, the Service targets it + by that name, and all three probes address it by name. The probe path. + */}} + - ports: + - protocol: TCP + port: 5000 + {{- 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. With oauth2-proxy enabled the + traffic arrives from the PROXY POD instead, which allowedNamespaces cannot + express at pod granularity - another reason the subchart gap above matters. + */}} + 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 + {{- /* + The web on both ports, to any destination: fetching media is the application. + Not narrowable to the sites in use, because this object cannot name a host. + */}} + - ports: + - protocol: TCP + port: 80 + - 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/ytdlp-web-player/values.schema.json b/ytdlp-web-player/values.schema.json index a87534f..57667d1 100644 --- a/ytdlp-web-player/values.schema.json +++ b/ytdlp-web-player/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 - and note that with a subchart present this is no longer hypothetical: whatever is set here reaches oauth2-proxy too.", "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/ytdlp-web-player/values.yaml b/ytdlp-web-player/values.yaml index 173821d..21a1bc6 100644 --- a/ytdlp-web-player/values.yaml +++ b/ytdlp-web-player/values.yaml @@ -455,3 +455,86 @@ oauth2-proxy: # it is spelled out here because switching it on looks like an upgrade. alphaConfig: enabled: false + +# --------------------------------------------------------------------------- +# 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 5000 only, and outbound DNS plus TCP 80 + # and 443 to ANY destination - fetching media is the application, and this + # object cannot name a host. + # + # WHAT IT DOES NOT COVER: THE OAUTH2-PROXY SUBCHART'S PODS. This policy + # selects this chart's own workload by its app label. The subchart renders + # its own Deployment with its own labels and ships no policy, so with the + # proxy enabled the pod that actually faces the Ingress is unrestricted in + # both directions. That is a gap in the convention, not a choice of this + # chart: an upstream chart is consumed as it comes (README, "Chart + # dependencies"). Do not read "this chart has a NetworkPolicy" as "this + # release is covered". + # + # Inbound 5000 is not restricted by source in the default (probe port), 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 the port named "http" (5000), which 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. + # + # 80 and 443 are already allowed. Add a port only for a source that serves + # media elsewhere. + extraPorts: [] diff --git a/zipline/Chart.yaml b/zipline/Chart.yaml index ba70524..77bbb18 100644 --- a/zipline/Chart.yaml +++ b/zipline/Chart.yaml @@ -16,7 +16,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 7.2.0+up4.7.0 +version: 8.0.0+up4.7.0 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/zipline/templates/NOTES.txt b/zipline/templates/NOTES.txt index 7082c94..164be9a 100644 --- a/zipline/templates/NOTES.txt +++ b/zipline/templates/NOTES.txt @@ -209,3 +209,55 @@ zipline.env IS APPENDED, NOT MERGED belongs in valueFrom.secretKeyRef: a plain "value" is stored in the pod spec and readable by everyone who can read pods in {{ .Release.Namespace }}. {{- 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 3000 only. Outbound: DNS, TCP 5432 (Postgres) and 443 (optional + OAuth providers, and an external S3 endpoint){{ with .Values.networkPolicy.egress }}{{ with .extraPorts }}, plus TCP {{ join ", " . }}{{ end }}{{ end }}. + + 5432 IS THE STANDARD PORT, NOT YOUR CONFIGURED ONE: the connection URL lives + in a 1Password item this chart never parses. A Postgres elsewhere is blocked — + and since all three probes hit this app's own port and not the database, that + shows up as an application error rather than as an un-Ready pod. Add the real + port to networkPolicy.egress.extraPorts. + + 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/zipline/templates/zipline.networkpolicy.yaml b/zipline/templates/zipline.networkpolicy.yaml new file mode 100644 index 0000000..315b315 --- /dev/null +++ b/zipline/templates/zipline.networkpolicy.yaml @@ -0,0 +1,177 @@ +{{- /* +NetworkPolicy for zipline (Gitea #5). + +Zipline keeps its uploads on a PVC, so its only hard outbound dependency is +Postgres. 443 is allowed in addition, because Zipline's optional OAuth +providers (Discord, GitHub, Google, or an OIDC issuer) are configured through +env and all of them are HTTPS - and a login path that silently cannot reach its +provider is a worse failure than a slightly wider rule. + +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 := .Values.ingress.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 because there IS something to restrict: this workload + needs DNS and the ports named below, and nothing else. A chart whose + workload opens no outbound connection of its own must NOT list Egress - + valkey is the example - because a rule that constrains nothing makes the + object look stricter than it is. + */}} + - Egress + ingress: + {{- /* + 3000 - the only port this pod declares (named zipline-server), the one the + Service forwards 80 to, and the port all three probes address. The probe + path. + */}} + - ports: + - protocol: TCP + port: 3000 + {{- 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 the chart never parses, so this is the conventional port and not the + configured one; a Postgres anywhere else is blocked and belongs in + networkPolicy.egress.extraPorts. + */}} + - ports: + - protocol: TCP + port: 5432 + {{- /* + Outbound HTTPS, for Zipline's optional OAuth providers (Discord, GitHub, + Google, an OIDC issuer) - all of them HTTPS, all configured through env, none + of them known to the chart. Also covers an external S3 endpoint if one is + configured that way. Ports only: these are hostnames, which this object cannot + match. + */}} + - 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/zipline/values.schema.json b/zipline/values.schema.json index ba3d98c..3ab4551 100644 --- a/zipline/values.schema.json +++ b/zipline/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/zipline/values.yaml b/zipline/values.yaml index 0d538ae..28b97a8 100644 --- a/zipline/values.yaml +++ b/zipline/values.yaml @@ -167,3 +167,82 @@ httpRoute: # CORE_DEFAULT_DOMAIN with it: every share link Zipline hands out keeps # naming ingress.domain until that value is changed too. hostnames: [] + +# --------------------------------------------------------------------------- +# 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 3000 only, and outbound DNS plus TCP 5432 + # (Postgres) and 443 (optional OAuth providers, and an external S3 endpoint + # if one is configured). + # + # WHAT IT DOES NOT DO: 5432 is the STANDARD Postgres port - the connection + # URL lives in a 1Password item this chart never parses - so a database + # elsewhere is BLOCKED and belongs in egress.extraPorts. 443 goes to any + # destination, because the providers are hostnames. + # + # Inbound 3000 is not restricted by source in the default (probe port), 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 3000, 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 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. + # + # The usual entry here is a Postgres that is not on 5432, or an S3 endpoint + # (MinIO, garage) on a non-standard port. + extraPorts: []