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:
jklein committed 2026-10-12 21:10:00 +00:00
1 parent 470b90a393
commit cffc50e4ea
6 files changed
+505 -12

No files matched your search

+129
View File
@@ -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
# ---------------------------------------------------------------------------