feat(stalwart): Gateway-API as an alternative to the Ingress
Group 3 of the #114 sweep. ONE route - not one per node group. #114 describes this chart as rendering "per node group"; its SERVICES and StatefulSets are, the routing object is not and must not be. The route targets exactly ingress.nodeGroup's own http Service and keeps both of the Ingress's guards, because the WebUI's setup and management screens are not safe to spread over a group that may not run the management tasks. ingress.nodeGroup is therefore shared by both modes: it names where the management surface is published from, which is not a property of the Ingress. THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where anything of the TLS path does. stalwart.certificate.yaml covers STARTTLS on 25 and implicit TLS on 465/993; those connections pass through neither an ingress controller nor a Gateway. So in Gateway mode ingress.domain is still required (the certificate's dnsNames read it), ingress.clusterIssuer is still required unless certificate.clusterIssuer is set, and the Certificate must NOT be removed as part of a migration - deleting it drops SMTP and IMAP back to Stalwart's self-signed certificate, with clients seeing a name mismatch while every Kubernetes object stays green. Verified in the render: Gateway mode emits Certificate + HTTPRoute and no Ingress. WHAT THIS MODE DROPS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP. Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried, all of it operator-supplied through ingress.annotations (this chart sets none of it): enable-cors: true cors-allow-origin: https://webmail.bekemeyer-klein.de cors-allow-methods: GET, POST, OPTIONS cors-allow-headers: Authorization, Content-Type That origin is BULWARK, the webmail chart in this same group. Without the CORS configuration the browser-side webmail cannot call this server's JMAP endpoint at all, and the failure is entirely client-side: a console error, nothing in the server's logs. AND "no counterpart" WOULD HAVE BEEN WRONG. Measured by grepping the CRDs this repo pins (gateway-api v1.6.3 standard channel, ci/gateway-api-crds.sh) rather than inferred: HTTPRoute's filters carry a full 'cors' field (GEP-1767) with allowOrigins/allowMethods/allowHeaders/allowCredentials/maxAge, and its documented level - verbatim from that CRD - is "Support: Extended". responseHeaderModifier is in the same CRD and is also Extended, not Core. The chart still does not render it, for three reasons in order: 1. The chart has NO CORS values. The live configuration is the operator's, and the chart sets no CORS annotations in Ingress mode either - so rendering a filter means inventing a values surface that would exist in Gateway mode only. That asymmetry is its own defect and its own work. 2. Extended support is not support. A rejected route is rejected WHOLE, which would take the WebUI and JMAP down together (the reasoning #119 reached for statusCode); and the observed failure is quieter - Cilium accepts filters.type: CORS and forwards the preflight upstream anyway (cilium/cilium#45272), i.e. Accepted: True with the behaviour absent. 3. Half a CORS is worse than none, and the translatable half is the useless one HERE. A responseHeaderModifier can add the Access-Control-Allow-* headers; the PREFLIGHT has to be answered BY THE GATEWAY, which the CORS filter's own contract states and a bare header filter cannot do. Because the allowed headers include Authorization, EVERY webmail request is preflighted - so the expressible half is exactly the half that does not help. Named, not rebuilt (decision 2 of #114), in values.yaml, in the schema and in the Gateway branch of NOTES.txt, with the operational instruction: configure CORS at the Gateway with that implementation's own support BEFORE moving this host, and test it from the webmail in a browser - curl sends no preflight, so it passes while the webmail fails. NOTES.txt now asks $published (ingress.enabled OR httpRoute.enabled) where it asked ingress.enabled, and two paragraphs that named the ingress controller specifically now name the Gateway in that mode: the auto-ban step (switching API does not avoid or reset it - one proxy address instead of another) and the NetworkPolicy paragraph, where the namespace to allow is the Gateway's data plane rather than an ingress controller's. Minor: additive, defaults unchanged. Empty helm template diff against origin/main with ci/stalwart/test-values.yaml. Part of #114
This commit is contained in:
6 files changed
+505
-12
No files matched your search
+1
-1
@@ -25,7 +25,7 @@ type: application
|
||||
# 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 (README, "Versioning").
|
||||
version: 1.1.1+upv0.16.25
|
||||
version: 1.2.0+upv0.16.25
|
||||
appVersion: "v0.16.25"
|
||||
|
||||
maintainers:
|
||||
|
||||
@@ -1,9 +1,27 @@
|
||||
{{- /*
|
||||
NOTHING IN CI READS THIS FILE. `helm template` does not emit notes and no
|
||||
workflow or script under ci/ reads it, so every claim below carries
|
||||
review-only assurance - it is checked by being read, not by a stage. A
|
||||
rendering ERROR here does fail `helm template`, which is all the pipeline can
|
||||
see of it.
|
||||
*/ -}}
|
||||
{{- $release := include "stalwart.fullname" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $valkeyHost := include "stalwart.valkeyHost" . -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- /*
|
||||
"Published" rather than "has an Ingress": the two modes are alternatives, and
|
||||
the paragraphs below that asked ingress.enabled were really asking whether the
|
||||
WebUI and JMAP are reachable from outside at all (Gitea #114).
|
||||
*/ -}}
|
||||
{{- $published := or .Values.ingress.enabled $httpRoute.enabled -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list .Values.ingress.domain -}}
|
||||
{{- end -}}
|
||||
{{- $ingressGroup := "" -}}
|
||||
{{- if .Values.ingress.enabled }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
|
||||
{{- if $published }}{{- $ingressGroup = include "stalwart.ingressNodeGroup" . }}{{- end -}}
|
||||
Stalwart {{ .Chart.AppVersion }} — release {{ .Release.Name }} in namespace {{ .Release.Namespace }}.
|
||||
|
||||
THIS CHART CONFIGURES ALMOST NOTHING, AND THAT IS NOT A GAP
|
||||
@@ -140,14 +158,18 @@ FIRST INSTALL — EIGHT STEPS, AND NONE OF THEM IS OPTIONAL
|
||||
'Cluster role "<name>" not found in registry', and it is printed AFTER the
|
||||
listeners are already bound.
|
||||
|
||||
4. Configure HTTP forwarding before the auto-ban notices the Ingress.
|
||||
Stalwart sees the ingress controller's pod IP for every WebUI and JMAP
|
||||
4. Configure HTTP forwarding before the auto-ban notices the {{ if $httpRoute.enabled }}Gateway{{ else }}Ingress{{ end }}.
|
||||
Stalwart sees the {{ if $httpRoute.enabled }}Gateway's{{ else }}ingress controller's{{ end }} pod IP for every WebUI and JMAP
|
||||
request, not the client's. Sooner or later the auto-ban bans that one
|
||||
address and the WebUI and JMAP go dark for everyone at once. The PROXY
|
||||
protocol is not the fix for HTTP: enable "forwarded" handling with
|
||||
X-Forwarded-For evaluation, and discard forwarding headers the client
|
||||
brought itself. The outage arrives weeks later and looks like a server
|
||||
fault, which is why this is a step and not a tip.
|
||||
{{ if $httpRoute.enabled }}Switching to the Gateway API does not avoid this and does not
|
||||
reset it: it is one proxy address instead of another, and whether the
|
||||
Gateway sends an X-Forwarded-For at all is that implementation's business,
|
||||
not something this route can state.{{ end }}
|
||||
|
||||
5. Point the TLS object at the mounted certificate.
|
||||
{{- if .Values.certificate.enabled }}
|
||||
@@ -295,7 +317,7 @@ DISTRIBUTION AND DISRUPTION BUDGET — WHAT EACH GROUP ACTUALLY GOT
|
||||
{{- end }}
|
||||
{{- if .Values.ingress.enabled }}
|
||||
|
||||
WEB UI AND JMAP
|
||||
WEB UI AND JMAP — INGRESS MODE (ingress.enabled: true, httpRoute.enabled: false)
|
||||
https://{{ .Values.ingress.domain }} → node group "{{ $ingressGroup }}"
|
||||
Certificate requested by cert-manager through the "{{ .Values.ingress.clusterIssuer }}" cluster issuer:
|
||||
kubectl -n {{ .Release.Namespace }} get certificate tls-{{ $release }}-ingress
|
||||
@@ -305,10 +327,101 @@ WEB UI AND JMAP
|
||||
Ingress only works while an HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the
|
||||
database (step 3). Between finishing setup and creating that listener the
|
||||
Ingress resolves to a port that answers nothing.
|
||||
{{- else if $httpRoute.enabled }}
|
||||
|
||||
WEB UI AND JMAP — GATEWAY MODE (httpRoute.enabled: true, ingress.enabled: false)
|
||||
{{- range $hostnames }}
|
||||
https://{{ . }}
|
||||
{{- end }}
|
||||
→ node group "{{ $ingressGroup }}", one route, not one per group
|
||||
|
||||
This release renders ONE HTTPRoute ({{ $release }}-httproute) and nothing
|
||||
else of the HTTP routing path — no Gateway, no class, no certificate for this
|
||||
hostname, no policy object. Three things therefore belong to the cluster:
|
||||
|
||||
1. The Gateway-API CRDs. Without them this install would have failed, so
|
||||
they are present — nothing to check.
|
||||
2. The Gateway named in httpRoute.parentRefs
|
||||
{{- range $httpRoute.parentRefs }}
|
||||
{{ .name }}{{ if .namespace }} in namespace {{ .namespace }}{{ end }}{{ if .sectionName }}, listener {{ .sectionName }}{{ end }}
|
||||
{{- end }}
|
||||
must exist AND its listener must allow routes from this namespace
|
||||
({{ .Release.Namespace }}) in allowedRoutes. A rejected attachment shows only in
|
||||
the route's status:
|
||||
|
||||
kubectl -n {{ .Release.Namespace }} get httproute {{ $release }}-httproute -o yaml
|
||||
|
||||
Read status.parents[].conditions — "Accepted: True" and
|
||||
"ResolvedRefs: True" is a working attachment; an empty status means no
|
||||
controller has looked at the route at all.
|
||||
3. The TLS certificate for the HTTP hostname, on the Gateway's listener.
|
||||
ingress.clusterIssuer is not needed FOR THAT — but read the next
|
||||
paragraph before removing it.
|
||||
|
||||
THE MAIL CERTIFICATE STAYS, AND MUST NOT BE CLEANED UP.
|
||||
{{ if .Values.certificate.enabled }}certificate.enabled is true, so this release still renders
|
||||
{{ $release }}-mail-certificate.{{ else }}certificate.enabled is false, so no mail certificate is rendered here.{{ end }}
|
||||
That object has nothing to do with the Ingress or the Gateway: it covers
|
||||
STARTTLS on the SMTP port and implicit TLS on the submission and IMAPS
|
||||
ports, and those connections pass through no HTTP proxy at all. So in this mode
|
||||
ingress.domain is STILL REQUIRED (the certificate's dnsNames read it) and
|
||||
ingress.clusterIssuer is still required unless certificate.clusterIssuer is
|
||||
set. Deleting the Certificate as part of a migration drops SMTP and IMAP back
|
||||
to Stalwart's self-signed certificate: clients see a name mismatch while
|
||||
every Kubernetes object in this release stays green.
|
||||
|
||||
SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they were not
|
||||
routed by the Ingress. They keep arriving through the per-port NodePort
|
||||
Services. A Gateway could carry them as TLSRoute/TCPRoute listeners; this
|
||||
chart renders neither.
|
||||
|
||||
AND NOW THE ONE THAT WILL BITE: CORS, AND THE WEBMAIL.
|
||||
|
||||
This installation's live Ingress carries, set by the operator through
|
||||
ingress.annotations and NOT by this chart:
|
||||
|
||||
nginx.ingress.kubernetes.io/enable-cors: true
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: https://<the webmail host>
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
||||
|
||||
That is what lets the browser-side webmail (the bulwark chart) call this
|
||||
server's JMAP endpoint at all. IN THIS MODE IT IS GONE, and the failure is
|
||||
entirely on the client side: a console error in the browser, and nothing
|
||||
whatsoever in this server's logs.
|
||||
|
||||
The Gateway API does have a CORS filter — HTTPRoute filters carry a `cors`
|
||||
field in the CRDs this repository pins — so this is not a missing feature of
|
||||
the API. THIS CHART STILL DOES NOT RENDER IT, and knowingly:
|
||||
|
||||
- the chart has no CORS values to render it from (the origin and the method
|
||||
list are the operator's, and the chart sets no CORS annotations in
|
||||
Ingress mode either),
|
||||
- that filter's support level is "Extended", so an implementation may
|
||||
reject the route — and rejection is whole-route, which would take the
|
||||
WebUI and JMAP down together — or, worse, accept it and not honour it,
|
||||
- and the part a plain header filter COULD do is the part that does not
|
||||
help here: the browser's PREFLIGHT has to be answered by the gateway
|
||||
rather than by Stalwart, and because the allowed headers include
|
||||
Authorization, EVERY request from the webmail is preflighted.
|
||||
|
||||
So: configure CORS AT THE GATEWAY, with that implementation's own support,
|
||||
BEFORE you move this host. Then TEST IT FROM THE WEBMAIL IN A BROWSER — curl
|
||||
sends no preflight, so it will pass while the webmail fails.
|
||||
|
||||
ingress.annotations is inert in this mode in general: the Gateway API has no
|
||||
annotation channel for controller behaviour.
|
||||
|
||||
AFTER BOOTSTRAP, PORT {{ (index $ports "http").containerPort }} IS NOT THE ADMIN PORT ANY MORE, and the hostname
|
||||
the WebUI sends administrators to lives IN THE DATABASE, not in any value
|
||||
here. Upstream moves administration to https://<the configured server
|
||||
hostname>/admin once setup is complete, so this route only works while an
|
||||
HTTP NetworkListener on {{ (index $ports "http").containerPort }} exists in the database (step 3).
|
||||
{{- else }}
|
||||
|
||||
NO INGRESS
|
||||
ingress.enabled is false, so there is no Ingress and no WebUI certificate.
|
||||
NEITHER INGRESS NOR HTTPROUTE
|
||||
ingress.enabled and httpRoute.enabled are both false, so there is no Ingress,
|
||||
no HTTPRoute and no WebUI certificate.
|
||||
ingress.domain STAYS REQUIRED even so — the mail Certificate reads it for its
|
||||
dnsNames, and without it the certificate would carry no name at all.
|
||||
{{- end }}
|
||||
@@ -363,6 +476,8 @@ NETWORKPOLICY: THE HTTP PORT IS OPEN INSIDE THE CLUSTER
|
||||
that never becomes Ready reads as a broken application rather than as a
|
||||
policy. The port carries no nodePort, so this is open inside the cluster, not
|
||||
from outside. Narrow it with networkPolicy.probeSources, and then add the
|
||||
ingress controller's namespace to networkPolicy.allowedNamespaces or the
|
||||
Ingress goes dark.
|
||||
{{ if $httpRoute.enabled }}GATEWAY's namespace (not an ingress controller's — in Gateway mode the
|
||||
traffic arrives from the Gateway's data-plane pods, which usually live in a
|
||||
namespace of their own){{ else }}ingress controller's namespace{{ end }} to networkPolicy.allowedNamespaces or the
|
||||
{{ if $httpRoute.enabled }}route{{ else }}Ingress{{ end }} goes dark.
|
||||
{{- end }}
|
||||
@@ -0,0 +1,165 @@
|
||||
{{/*
|
||||
HTTPRoute - the Gateway-API alternative to stalwart.ingress.yaml (Gitea #114).
|
||||
|
||||
ONE ROUTE, NOT ONE PER NODE GROUP, and that is worth saying because #114
|
||||
describes this chart as rendering "per node group". Its SERVICES and
|
||||
StatefulSets are per group; the routing object is not and must not be. The
|
||||
Ingress targets exactly ONE group (ingress.nodeGroup) through that group's own
|
||||
http Service, because the WebUI's setup and management screens are not safe to
|
||||
spread over a group that may not run the management tasks. The route keeps that
|
||||
property, including the render abort when the named group does not expose
|
||||
"http".
|
||||
|
||||
WHAT STAYS IN THIS MODE, AND IT IS THE ONLY CHART IN THE SWEEP WHERE ANYTHING
|
||||
DOES: the MAIL certificate. stalwart.certificate.yaml is not the Ingress
|
||||
certificate - it covers STARTTLS on 25 and implicit TLS on 465/993, and those
|
||||
connections never pass through an ingress controller or a Gateway. So
|
||||
certificate.enabled keeps rendering a Certificate here, ingress.domain stays
|
||||
required (the certificate's dnsNames read it) and ingress.clusterIssuer stays
|
||||
required unless certificate.clusterIssuer is set. Do NOT "clean up" the
|
||||
Certificate when switching to Gateway mode: SMTP and IMAP would fall back to
|
||||
whatever self-signed certificate Stalwart generates, and clients would see a
|
||||
name mismatch while every Kubernetes object stayed green.
|
||||
|
||||
WHAT THIS RENDERS, AND WHAT IT MUST NOT
|
||||
|
||||
Only an HTTPRoute with parentRefs. No Gateway, no gatewayClassName, no
|
||||
Certificate FOR THE HTTP HOSTNAME, and no implementation-specific policy
|
||||
object - the subset that looks identical on nginx-gateway-fabric, Cilium, Envoy
|
||||
Gateway and Istio (decision 1 of #114). The HTTP hostname's certificate belongs
|
||||
on the Gateway's listener; the mail certificate stays here, which is the
|
||||
distinction above.
|
||||
|
||||
And the Gateway API routes HTTP only, so SMTP, IMAP and ManageSieve are as far
|
||||
outside this object as they are outside the Ingress - they keep reaching the
|
||||
pods through the per-port NodePort Services. A Gateway could in principle carry
|
||||
them as TLSRoute/TCPRoute listeners, which this chart does not render and which
|
||||
would be a separate decision.
|
||||
|
||||
WHAT THIS MODE DROPS, MEASURED ON THE LIVE OBJECT - AND THIS IS THE ONE PLACE
|
||||
IN GROUP 3 WHERE THE LOSS BREAKS A SECOND CHART OF THE SAME GROUP
|
||||
|
||||
On 2026-10-12 the live Ingress (jk-cluster, namespace stalwart,
|
||||
mail.bekemeyer-klein.de) carried, all of it OPERATOR-supplied through
|
||||
ingress.annotations - this chart sets none of it:
|
||||
|
||||
nginx.ingress.kubernetes.io/enable-cors: true
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
||||
nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
||||
|
||||
That origin is BULWARK, the webmail chart in this same group. The CORS
|
||||
configuration is the whole reason the browser-side webmail may call this
|
||||
server's JMAP endpoint at all, so it is not decoration: without it the webmail
|
||||
stops working, with a console error and no server-side symptom.
|
||||
|
||||
WHAT THE GATEWAY API HAS FOR IT, MEASURED AGAINST THE PINNED CRDs RATHER THAN
|
||||
ASSUMED. Both halves exist in the API, and neither is promisable by this chart:
|
||||
|
||||
- A full CORS FILTER exists. In the standard channel this repository pins
|
||||
(gateway-api v1.6.3, ci/gateway-api-crds.sh), HTTPRoute's filters carry a
|
||||
`cors` field with allowOrigins, allowMethods, allowHeaders,
|
||||
allowCredentials and maxAge - GEP-1767. Grepped out of the downloaded CRD,
|
||||
not inferred. Its documented support level, verbatim from that CRD, is
|
||||
"Support: Extended".
|
||||
- responseHeaderModifier is in the same CRD and is ALSO "Support: Extended",
|
||||
not Core.
|
||||
|
||||
So "no counterpart" would be the wrong thing to write down. The accurate
|
||||
statement is that there is a counterpart and this chart still does not render
|
||||
it, for three reasons, in order:
|
||||
|
||||
1. THE CHART HAS NO CORS VALUES AT ALL. The live configuration is the
|
||||
operator's: a specific origin, a specific method list. Rendering a CORS
|
||||
filter means inventing a values surface in a sweep PR - and one that
|
||||
would exist in Gateway mode only, because this chart sets no CORS
|
||||
annotations in Ingress mode either. That asymmetry is its own defect and
|
||||
its own piece of work.
|
||||
2. EXTENDED SUPPORT IS NOT SUPPORT. #119 established the shape of this: an
|
||||
implementation that does not support an Extended value must report it in
|
||||
the route's status, and a rejected route is rejected WHOLE - here that
|
||||
would take the WebUI and JMAP down together. Worse, the observed failure
|
||||
is quieter than that: Cilium accepts `filters.type: CORS` and still
|
||||
forwards the browser's preflight upstream
|
||||
(cilium/cilium#45272), i.e. the object is Accepted and the behaviour is
|
||||
absent. A chart cannot check which implementation it landed on.
|
||||
3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
|
||||
ONE HERE. A responseHeaderModifier can add the three
|
||||
Access-Control-Allow-* headers to a response, but the PREFLIGHT is a
|
||||
different thing: the browser sends OPTIONS first and the gateway - not the
|
||||
backend - has to answer it. The CORS filter's own contract says so (a path
|
||||
with a CORS filter MUST NOT pass the preflight upstream), which is exactly
|
||||
what a bare header modifier cannot do. And this installation's header list
|
||||
includes Authorization, which makes EVERY webmail request preflighted: the
|
||||
half that is expressible without the CORS filter is precisely the half
|
||||
that does not help. Rendering it would produce a setup that looks
|
||||
configured and fails on the first real call.
|
||||
|
||||
So: named, not rebuilt (decision 2 of #114), in the Gateway branch of
|
||||
NOTES.txt, with the instruction to configure CORS at the Gateway - via that
|
||||
implementation's CORS support - BEFORE moving this host, and to test it from
|
||||
the webmail rather than with curl, because curl sends no preflight.
|
||||
*/}}
|
||||
{{- /*
|
||||
Null-safe like every other block a template here dereferences (#99): an
|
||||
override written without children - "httpRoute:" with its keys deleted - is
|
||||
YAML null, and .Values.httpRoute.enabled would abort with a nil pointer.
|
||||
*/ -}}
|
||||
{{- $httpRoute := .Values.httpRoute | default dict -}}
|
||||
{{- if and $httpRoute.enabled .Values.ingress.enabled -}}
|
||||
{{- fail "ingress.enabled and httpRoute.enabled are both true - set exactly one of them. The Ingress and the HTTPRoute would publish the same Service under the same hostname over two different data paths, which is already a conflict at DNS level: one name, two addresses, and which one a client reaches is luck. For Stalwart there is a second edge to it: the server's auto-ban sees the proxy's address and bans it, so two data paths mean two addresses to get banned and a WebUI that fails for half the requests. A migration runs over a SECOND hostname (set httpRoute.hostnames explicitly while the Ingress keeps ingress.domain), never over parallel operation of both - and note that the server hostname configured IN THE DATABASE decides where the WebUI tells administrators to go, which no chart value can change." -}}
|
||||
{{- end -}}
|
||||
{{- if $httpRoute.enabled -}}
|
||||
{{- $group := include "stalwart.ingressNodeGroup" . -}}
|
||||
{{- $nodes := fromYaml (include "stalwart.nodes" .) -}}
|
||||
{{- $ports := fromYaml (include "stalwart.ports" .) -}}
|
||||
{{- $spec := index $nodes $group -}}
|
||||
{{- if not (has "http" $spec.exposedPorts) -}}
|
||||
{{- fail (printf "ingress.nodeGroup is %q, but nodes.%s.exposedPorts does not include \"http\" - there would be no Service for the HTTPRoute to point at. The value is shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress." $group $group) -}}
|
||||
{{- end -}}
|
||||
{{- $parentRefs := $httpRoute.parentRefs | default list -}}
|
||||
{{- if not $parentRefs -}}
|
||||
{{- fail "httpRoute.enabled is true but httpRoute.parentRefs is empty - an HTTPRoute without a parent attaches to no Gateway. It is created, it is schema-valid, and it routes NOTHING, with no event and no condition that says so; the only symptom is a hostname that does not answer. Name the cluster's Gateway, e.g. httpRoute.parentRefs: [{name: public, namespace: gateway}]." -}}
|
||||
{{- end -}}
|
||||
{{- /*
|
||||
One source of truth for the hostname: an empty httpRoute.hostnames means
|
||||
[ingress.domain]. Two values that have to agree drift apart. ingress.domain is
|
||||
read outside the routing templates in this chart - the mail Certificate's
|
||||
dnsNames - so it already carries a required() there and is mandatory in every
|
||||
mode, including with both modes off. This required() is the route's own, so the
|
||||
message names the route.
|
||||
*/ -}}
|
||||
{{- $hostnames := $httpRoute.hostnames | default list -}}
|
||||
{{- if not $hostnames -}}
|
||||
{{- $hostnames = list (required "A Domain/Host must be provided: set ingress.domain (used as the HTTPRoute's single hostname when httpRoute.hostnames is empty) or set httpRoute.hostnames explicitly" .Values.ingress.domain) -}}
|
||||
{{- end -}}
|
||||
apiVersion: gateway.networking.k8s.io/v1
|
||||
kind: HTTPRoute
|
||||
metadata:
|
||||
name: {{ include "stalwart.fullname" . }}-httproute
|
||||
labels:
|
||||
app: {{ include "stalwart.fullname" . }}
|
||||
spec:
|
||||
parentRefs:
|
||||
{{- toYaml $parentRefs | nindent 4 }}
|
||||
hostnames:
|
||||
{{- toYaml $hostnames | nindent 4 }}
|
||||
rules:
|
||||
{{- /*
|
||||
The Ingress counterpart is `path: / , pathType: Prefix` on the http
|
||||
Service of the "{{ $group }}" group ONLY - never a port-label selector,
|
||||
for the reason in the header. The port is the Service's port NUMBER and
|
||||
not its name "http": backendRefs takes a PortNumber, so unlike the
|
||||
Ingress this reference cannot be made by name. It comes from
|
||||
ports.http.containerPort, which is the same value the Service renders its
|
||||
port from, so the two cannot drift;
|
||||
ci/run-httproute-match-test.sh holds the pair for the case where they do.
|
||||
*/}}
|
||||
- matches:
|
||||
- path:
|
||||
type: PathPrefix
|
||||
value: /
|
||||
backendRefs:
|
||||
- name: {{ include "stalwart.fullname" . }}-{{ $group }}-http-service
|
||||
port: {{ int (index $ports "http").containerPort }}
|
||||
{{- end }}
|
||||
@@ -390,7 +390,7 @@
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"description": "Gates the Ingress template only. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1). clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
|
||||
"description": "Gates the Ingress template only. Mutually exclusive with httpRoute.enabled - the render aborts when both are true. ingress.domain stays REQUIRED either way, because the mail Certificate's dnsNames reads it too - switching the Ingress off would otherwise render a certificate with no name in it (guideline 12.1) - and the HTTPRoute reads it as its hostname as well. clusterIssuer becomes unnecessary only if certificate.clusterIssuer is set, since that falls back to it.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"clusterIssuer": {
|
||||
@@ -405,16 +405,34 @@
|
||||
"type": ["string", "null"]
|
||||
},
|
||||
"nodeGroup": {
|
||||
"description": "Which node group the Ingress routes to, through that group's own http Service. Must be a key of nodes: and must expose http; both are checked in the template. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
|
||||
"description": "Which node group the routing object - Ingress or HTTPRoute - points at, through that group's own http Service. Shared by both modes on purpose: it names the node group the management surface is published from, which is not a property of the Ingress. Must be a key of nodes: and must expose http; both are checked in both templates. A single group on purpose - the management surface is not safe to spread across a group that may not run the management tasks, and a port-label selector would send each request to a random group.",
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"annotations": {
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open.",
|
||||
"description": "Extra Ingress annotations, merged with the cert-manager annotation the chart always sets. Free-form keys by nature, so this object stays open. INERT IN GATEWAY MODE, and for this chart that is the consequential one: this installation's live Ingress carries its CORS configuration here (enable-cors plus allow-origin/methods/headers for the bulwark webmail). See values.yaml, httpRoute block - the Gateway API does have a CORS filter, and the chart still does not render it.",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"httpRoute": {
|
||||
"description": "Gateway-API alternative to the Ingress (Gitea #114). Mutually exclusive with ingress.enabled - the render aborts when both are true, naming both value paths. The chart renders only an HTTPRoute with parentRefs; the HTTP hostname's certificate sits on the Gateway's listener and belongs to the platform. ONE route, not one per node group - it targets ingress.nodeGroup's http Service, with the same two guards the Ingress has. THE MAIL CERTIFICATE STAYS in this mode: it covers STARTTLS on 25 and implicit TLS on 465/993, which pass through no HTTP proxy, so ingress.domain stays required and the Certificate must not be removed as part of a migration. SMTP, IMAP and ManageSieve are not routed here any more than by the Ingress. NOT COVERED IN THIS MODE: the CORS configuration this installation relies on for the bulwark webmail. A CORS filter DOES exist in the pinned gateway-api CRDs (v1.6.3, GEP-1767) but is 'Support: Extended', implementations vary (Cilium accepts it and forwards the preflight upstream anyway), the chart has no CORS values to render it from, and a bare responseHeaderModifier cannot answer a preflight - which is every request here, since the allowed headers include Authorization. Configure CORS at the Gateway and test from a browser, not with curl. See values.yaml, httpRoute block.",
|
||||
"type": ["object", "null"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"parentRefs": {
|
||||
"description": "The Gateway(s) the route attaches to. Required while httpRoute.enabled is true, enforced by required()-equivalent in the template (an empty list aborts the render) - a JSON Schema \"required\" cannot do it, because the key ships as an empty list and is therefore present. Entries are the Gateway API's ParentReference and are passed through unchanged.",
|
||||
"type": "array",
|
||||
"items": { "$ref": "#/definitions/parentRef" }
|
||||
},
|
||||
"hostnames": {
|
||||
"description": "Empty means [ingress.domain], so the HTTP hostname has a single source of truth. Note that the server's own hostname lives in the DATABASE, not in any chart value: after bootstrap the WebUI is reached at https://<configured server hostname>/admin, so a second hostname can be reachable without the application agreeing that it is its name.",
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityContext": {
|
||||
"description": "May be null / empty at any level: the chart then falls back to its hardening defaults (#99). THIS CHART'S FALLBACK REPRODUCES ITS DEVIATION, not the repo standard - capabilities.add carries NET_BIND_SERVICE, because the image sets 'setcap cap_net_bind_service=+ep' on its binary and the kernel then fails the execve itself when the bounding set does not carry the capability. An erased block that fell back to the standard 'add: []' would stop the chart from starting. uid/gid 2000 is the image's own user, and there is deliberately no fsGroup because this chart mounts no PVC.",
|
||||
"type": ["object", "null"],
|
||||
@@ -455,6 +473,11 @@
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"parentRef": {
|
||||
"description": "A Gateway API ParentReference, passed verbatim into HTTPRoute.spec.parentRefs. Deliberately left OPEN (no additionalProperties: false), for the same reason probes and security contexts are: Kubernetes owns this schema, the Gateway API adds fields to it across releases, and a closed list here would reject a field only upstream knows about. What the chart can assert is that \"name\" is there - it is required by the API and a reference without it attaches to nothing, which is the silent failure this chart aborts the render over.",
|
||||
"type": "object",
|
||||
"required": ["name"]
|
||||
},
|
||||
"exposedPorts": {
|
||||
"description": "Keys of ports: this group may be reached on. Checked against ports: in the template, which also requires 'http' - all three probes target it, so a group without it would never become Ready. Not a copy of the database's listener configuration but a statement of its own, which is why a disagreement in either direction is harmless and why it blunts the fail-open described on nodes:.",
|
||||
"type": "array",
|
||||
|
||||
@@ -625,8 +625,137 @@ ingress:
|
||||
nodeGroup: full
|
||||
# Extra annotations for the Ingress; merged with the cert-manager
|
||||
# cluster-issuer annotation the chart always sets.
|
||||
#
|
||||
# INERT IN GATEWAY MODE - the Gateway API has no annotation channel for
|
||||
# controller behaviour. For THIS chart that is the most consequential line in
|
||||
# the whole block: read the httpRoute section below before switching modes,
|
||||
# because what lives in here on the live installation is the CORS
|
||||
# configuration the webmail depends on.
|
||||
annotations: {}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# THE GATEWAY-API ALTERNATIVE TO THE INGRESS (Gitea #114)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mutually exclusive with ingress.enabled - the render aborts when both are
|
||||
# true, and the message names both value paths. The chart renders ONLY an
|
||||
# HTTPRoute with parentRefs: no Gateway, no class, no Certificate for the HTTP
|
||||
# hostname, and no implementation-specific policy object.
|
||||
#
|
||||
# ONE ROUTE, NOT ONE PER NODE GROUP. #114 describes this chart as rendering
|
||||
# "per node group"; its SERVICES and StatefulSets are, the routing object is
|
||||
# not and must not be. The route targets exactly the ingress.nodeGroup group,
|
||||
# through that group's own http Service, and aborts the render if that group
|
||||
# does not expose "http" - the same two guards the Ingress has, for the same
|
||||
# reason: the WebUI's setup and management screens are not safe to spread over
|
||||
# a group that may not run the management tasks.
|
||||
#
|
||||
# THE MAIL CERTIFICATE STAYS, and this is the only chart in the sweep where
|
||||
# anything does. certificate.enabled renders a Certificate for STARTTLS on 25
|
||||
# and implicit TLS on 465/993; those connections pass through neither an
|
||||
# ingress controller nor a Gateway. So in Gateway mode:
|
||||
# - ingress.domain is still REQUIRED (the certificate's dnsNames read it),
|
||||
# - ingress.clusterIssuer is still required unless certificate.clusterIssuer
|
||||
# is set,
|
||||
# - and the Certificate must NOT be "cleaned up" as part of the migration.
|
||||
# Deleting it would drop SMTP and IMAP back to Stalwart's self-signed
|
||||
# certificate: clients see a name mismatch while every Kubernetes object in the
|
||||
# release stays green.
|
||||
#
|
||||
# SMTP, IMAP AND MANAGESIEVE ARE NOT ROUTED HERE, exactly as they are not
|
||||
# routed by the Ingress. They keep reaching the pods through the per-port
|
||||
# NodePort Services. A Gateway could carry them as TLSRoute/TCPRoute listeners;
|
||||
# this chart renders neither, and doing so would be a separate decision.
|
||||
#
|
||||
# WHAT THIS MODE COSTS - AND HERE IT BREAKS A SECOND CHART OF THE SAME GROUP.
|
||||
# Measured on 2026-10-12, the live Ingress on mail.bekemeyer-klein.de carried,
|
||||
# all of it OPERATOR-supplied through ingress.annotations (this chart sets none
|
||||
# of it):
|
||||
#
|
||||
# nginx.ingress.kubernetes.io/enable-cors: true
|
||||
# nginx.ingress.kubernetes.io/cors-allow-origin: https://webmail.bekemeyer-klein.de
|
||||
# nginx.ingress.kubernetes.io/cors-allow-methods: GET, POST, OPTIONS
|
||||
# nginx.ingress.kubernetes.io/cors-allow-headers: Authorization, Content-Type
|
||||
#
|
||||
# That origin is BULWARK, the webmail chart in this same group. The CORS
|
||||
# configuration is what allows the browser-side webmail to call this server's
|
||||
# JMAP endpoint at all. Lose it and the webmail stops working, with a console
|
||||
# error in the browser and NO server-side symptom whatsoever.
|
||||
#
|
||||
# AND "no counterpart" would be the wrong thing to claim here. Measured against
|
||||
# the CRDs this repository pins (gateway-api v1.6.3 standard channel,
|
||||
# ci/gateway-api-crds.sh), by grepping the downloaded manifest rather than by
|
||||
# inference:
|
||||
# - HTTPRoute's filters carry a full `cors` field (GEP-1767) with
|
||||
# allowOrigins, allowMethods, allowHeaders, allowCredentials and maxAge.
|
||||
# Its documented support level, verbatim from that CRD: "Support:
|
||||
# Extended".
|
||||
# - `responseHeaderModifier` is in the same CRD and is ALSO "Support:
|
||||
# Extended", not Core.
|
||||
#
|
||||
# There IS a counterpart, then, and this chart still does not render it. Three
|
||||
# reasons, in order:
|
||||
#
|
||||
# 1. THIS CHART HAS NO CORS VALUES. The live configuration is the operator's
|
||||
# - a specific origin, a specific method list. Rendering a filter means
|
||||
# inventing a values surface in a sweep PR, and one that would exist in
|
||||
# Gateway mode only, since the chart sets no CORS annotations in Ingress
|
||||
# mode either. That asymmetry is its own defect and its own piece of work.
|
||||
# 2. EXTENDED SUPPORT IS NOT SUPPORT. An implementation that does not support
|
||||
# an Extended value reports it in the route's status, and a rejected route
|
||||
# is rejected WHOLE - which here would take the WebUI and JMAP down
|
||||
# together (the reasoning #119 arrived at for statusCode). The observed
|
||||
# failure is quieter still: Cilium accepts filters.type: CORS and
|
||||
# forwards the browser's preflight upstream anyway (cilium/cilium#45272),
|
||||
# i.e. Accepted: True with the behaviour absent. A chart cannot know which
|
||||
# implementation it landed on.
|
||||
# 3. HALF A CORS IS WORSE THAN NONE, AND THE TRANSLATABLE HALF IS THE USELESS
|
||||
# ONE HERE. A responseHeaderModifier can add the three
|
||||
# Access-Control-Allow-* headers to a response. The PREFLIGHT is a
|
||||
# different thing: the browser sends OPTIONS first and the GATEWAY, not
|
||||
# the backend, has to answer it - the CORS filter's own contract says a
|
||||
# path carrying it MUST NOT pass the preflight upstream. A bare header
|
||||
# modifier cannot do that. And this installation's header list includes
|
||||
# Authorization, which makes EVERY webmail request preflighted: the half
|
||||
# that is expressible without the CORS filter is precisely the half that
|
||||
# does not help.
|
||||
#
|
||||
# So configure CORS AT THE GATEWAY, using that implementation's own CORS
|
||||
# support, BEFORE moving this host - and TEST IT FROM THE WEBMAIL, not with
|
||||
# curl: curl sends no preflight, so it passes while a browser fails.
|
||||
#
|
||||
# THE GATEWAY-API CRDs MUST BE PRESENT. The chart cannot detect them
|
||||
# (.Capabilities.APIVersions is unreliable at template time) and an HTTPRoute
|
||||
# without them fails at install, so this is a value and not a guess.
|
||||
httpRoute:
|
||||
enabled: false
|
||||
|
||||
# The Gateway(s) the route attaches to - a platform object, usually in
|
||||
# another namespace, whose listener must allow this namespace in
|
||||
# allowedRoutes. Entries are the Gateway API's ParentReference (name, and
|
||||
# optionally namespace, sectionName, port, group, kind), passed through
|
||||
# unchanged:
|
||||
#
|
||||
# parentRefs:
|
||||
# - name: public
|
||||
# namespace: gateway
|
||||
# sectionName: https
|
||||
#
|
||||
# REQUIRED while httpRoute.enabled is true - the render aborts on an empty
|
||||
# list, because a route without a parent is created, is valid and routes
|
||||
# nothing, with no event to say so.
|
||||
parentRefs: []
|
||||
|
||||
# Empty means [ingress.domain], so the HTTP hostname has one source of truth.
|
||||
# Set explicitly only to publish under a different or an additional name -
|
||||
# which is how a migration from the Ingress runs: the HTTPRoute takes a
|
||||
# second hostname while the Ingress keeps ingress.domain.
|
||||
#
|
||||
# NOTE THAT THE SERVER'S OWN HOSTNAME LIVES IN THE DATABASE, not here. After
|
||||
# bootstrap the WebUI is reached at https://<the configured server
|
||||
# hostname>/admin, and no value of this chart changes that - so a second
|
||||
# hostname is reachable without the application agreeing that it is its name.
|
||||
hostnames: []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hardening — chart-wide, applied to every node group
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user