Files
helm-charts/paperless-ngx/templates/paperless-server.networkpolicy.yaml
T
jklein 530227405c fix(paperless-ngx): egress names the Tika/Gotenberg POD ports, not the Service port
The rule allowed outbound 80, the port the apache-tika and gotenberg Services
listen on, and justified it in a comment: an egress rule names the port on the
destination as the client dials it, and the client dials the Service. That is
wrong. kube-proxy does a ClusterIP Service's destination NAT BEFORE the egress
policy is evaluated at the pod's network interface, so the policy sees
podIP:targetPort and never clusterIP:port - and those two charts forward 80 to
9998 and 3000. Deployed as 14.0.0+up3.3.1 it cut Paperless off from both
services on 2026-10-15: mail import, EML, HTML and Office documents stopped,
plain PDFs and images kept working, and the error was httpx.ConnectTimeout with
no mention of a NetworkPolicy.

The rule now names 9998, 3000 and 80. The 80 stays because it costs nothing and
covers a foreign Tika that really does listen on 80. The refuted sentence is
quoted rather than deleted: it reads perfectly, which is how it survived review,
and the timeout-not-refused symptom is written down with it.

No policy switch of its own: the rule stays gated on
paperless.files.tikaGotenberg.enabled, because a second value that can drift
from the feature re-arms this very outage.

New networkPolicy.egress.mailImport opens IMAPS 993, off by default - paperless'
mail accounts are rows in its database, so the chart cannot derive the rule.
Plain IMAP (143) and POP3 stay in extraPorts, and 993 is noted as a real
destination port, because a mail server outside the cluster has no DNAT in the
path.

Minor: one additive value, and a fix that widens a rule. Nothing breaks, no
value becomes required, appVersion unchanged.

Part of #136
2026-10-15 17:39:24 +00:00

293 lines
13 KiB
YAML

{{- /*
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 UNLESS THE OPERATOR SAYS SO. Paperless' mail
accounts live in its DATABASE, configured through the web UI, so this chart
cannot see them and cannot derive the rule. networkPolicy.egress.mailImport
opens IMAPS (993) and is off by default; plain IMAP (143) and POP3 belong in
networkPolicy.egress.extraPorts. Left off on a release that does fetch mail,
the symptom 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 AND NEVER SERVICE PORTS.
A NetworkPolicy is evaluated at the pod's network interface, after kube-proxy
has already done its destination NAT - of a NodePort on the way in, and of a
ClusterIP on the way OUT. So a NodePort number in an ingress rule matches
nothing while looking strict (and, being the only rule for that port, blocks
every inbound connection instead), and a SERVICE port in an egress rule matches
nothing wherever the Service's port and its targetPort differ. The second half
is not hypothetical: it is Gitea #136, it cost an outage on 2026-10-15, and the
sentence that caused it is quoted as refuted at the Tika/Gotenberg rule below.
ci/run-networkpolicy-test.sh asserts every INGRESS port here against the
containerPorts of the pod spec this policy selects. Egress cannot be checked
that way - an egress port belongs to the DESTINATION, which for Postgres, Redis
and the web is not even in the render. The one exception is Tika and Gotenberg,
which are two charts of THIS repository: the same test renders them and asserts
that the egress rule below contains their containerPorts.
*/ -}}
{{- 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 by default and is the plausible surprise: mail. Paperless'
mail accounts are rows in its database, configured in the web UI, so the chart
cannot see them - networkPolicy.egress.mailImport opens IMAPS 993, and anything
else (143, POP3) goes into 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 THEIR POD PORTS 9998 AND 3000 - and 80 as well, for a
third case named at the end. Gitea #136: this rule used to name 80 ALONE, and
the release that shipped it cut Paperless off from both services on
2026-10-15. Mail import, EML, HTML and Office documents stopped; plain PDFs
and images kept working, which is why it did not look like a network fault.
THE SENTENCE THAT STOOD HERE IS WRONG. It is quoted rather than deleted,
because it reads perfectly and that is precisely how it survived review:
"an EGRESS rule names the port on the DESTINATION as the client dials
it, and the client dials the Service."
kube-proxy translates the destination of a ClusterIP Service (DNAT) BEFORE
the egress policy is evaluated at the pod's network interface. The policy
therefore sees podIP:targetPort and never clusterIP:port, so an egress rule
names the POD port. A Service port only ever matches by coincidence, where
port and targetPort are the same number - which is the case for 5432, 6379
and 443 above and below, and is the reason nothing else here broke.
THE SYMPTOM IS A TIMEOUT, NOT "connection refused", and that is the half to
remember: a missing service REFUSES, a dropped packet RUNS INTO THE TIMEOUT.
What this produced was httpx.ConnectTimeout out of paperless' parser, naming
the Tika URL and no NetworkPolicy at all - nothing in the error points here.
WHY 80 STAYS: it costs nothing and covers an installation whose Tika really
does listen on 80 - a foreign one behind a proxy, where 80 IS the pod port.
WHY THERE IS NO SWITCH OF ITS OWN for this rule, and that is deliberate: it
is gated on the FEATURE value, because a second value that can drift from the
feature re-arms exactly this outage. Whoever enables Tika and forgets the
policy switch lands back here, with the same message that does not point at
the policy. Rendered only while paperless.files.tikaGotenberg.enabled is
true, so a release without them still carries no rule that permits nothing.
9998 and 3000 are the containerPorts the apache-tika and gotenberg charts of
this repository declare, so they are fixed rather than guessed - and
ci/run-networkpolicy-test.sh renders both of those charts and asserts that
this rule contains them. That is the one assertion a single-chart render
cannot make, and it is why this mistake was not caught the first time.
*/}}
- ports:
- protocol: TCP
port: 9998
- protocol: TCP
port: 3000
- 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 }}
{{- if .mailImport }}
{{- /*
IMAPS for mail import, OFF BY DEFAULT - because this chart cannot know
whether a release fetches mail. Paperless' mail accounts are rows in its
DATABASE, entered in the web UI, and nothing in the values hints that they
exist. So this value is an operator's statement ("this release imports
mail") and not a derivation, which is also why it cannot be folded into
another switch.
993 ONLY. Plain IMAP (143) and POP3 (110, 995) are deliberately NOT included:
a value named mailImport that quietly opened four ports would be wider than
its name, and the plaintext variants are not what a new account uses. They go
into networkPolicy.egress.extraPorts.
AND THIS PORT IS THE REAL DESTINATION PORT, unlike the Tika rule above. A
mail server outside the cluster is reached with no Service and therefore no
DNAT in the path, so 993 is both what paperless dials and what the policy
sees. What decides which number an egress rule needs is the DNAT, not the
direction and not the protocol - that is the whole lesson of #136.
*/}}
- ports:
- protocol: TCP
port: 993
{{- end }}
{{- 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 }}