You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
External database exposure via Gateway API TLS-passthrough (SNI) #2815
Rollout step 1 is on main. #3342 added tlsPassthroughListeners to TenantGatewaySpec and the controller renders the listeners. #3475 (tenant routes must declare their hostnames), #3861 (a tenant picks its Gateway class) and #3872 (Gateway API CRDs v1.6.1) landed alongside it. No engine is wired, which is what step 1 was scoped to.
The step-1 gate was run on a test cluster with Cilium 1.19.5, against a hand-built Gateway that mirrors the controller's render. A passthrough listener on 5432 next to the terminate listener on 443 routed by SNI to the right backend, and Envoy built exactly one passthrough filter chain per hostname. Two things came out of that run. The port is not an access boundary: the chain matches on SNI and carries no destination port, so a database hostname also answers on 443. And nobody has opened a connection through the controller's own render yet: the gateway e2e suite renders a passthrough listener and asserts its status, but dials nothing.
What steps 2 to 4 still need, none of it on main:
Nothing renders a TLSRoute for a database release. The only TLSRoutes in the tree belong to api, vm-exportproxy and cdi-uploadproxy. The ownership question at the bottom is still open, and step 2 cannot start without an answer.
The charts that add an external name to the certificate add only the flat <release>.<apex> (checked: postgres, qdrant, nats, mariadb, opensearch), and redis carries in-cluster names only. The per-engine name <release>.<engine>.<apex> has to be added before an engine is wired.
There is no admission policy rejecting external: true without TLS.
There is no end-to-end test comparing certificate serials, and none showing a client without SNI failing closed.
Step 4 waits on a Cilium carrying the isolation fix, and the Cilium bump itself is blocked on #3912.
Context
The merged design proposal external-database-exposure covers this issue; what follows is the implementation scope it settles.
Today each external: true database gets its own LoadBalancer Service and its own public IP, one per instance. TLS-passthrough already exists in the tenant Gateway (internal/controller/tenantgateway/reconciler.go) for the layer-7 services (api, vm-exportproxy, cdi-uploadproxy), driven by TLSPassthroughServices on TenantGatewaySpec, which renders tls-<service> listeners on port 443. No database is wired through it.
Routing databases by SNI through a non-terminating listener consolidates many databases onto one tenant Gateway IP. And because the Gateway does not terminate, the certificate the external client validates is byte-identical to the operator-issued certificate the database already presents internally: end-to-end TLS is a consequence of not terminating, not a separate feature to build. That is why #2816 is closed rather than tracked separately.
Listener model
One passthrough listener per engine type, on that engine's native port, with a per-engine wildcard hostname: tls-postgres on 5432 with *.postgres.<apex>, tls-redis on 6379 with *.redis.<apex>, tls-mongos on 27017 with *.mongo.<apex>. Each is mode: Passthrough, with AllowedRoutes limited to TLSRoute. Listener consumption is therefore O(engine types), four or five, not O(database instances).
Native ports rather than 443: forcing everything onto 443 buys no additional IP consolidation, since SNI already does that on any port, and it breaks client tooling defaults. All-on-443 remains available as a documented opt-in.
Per-engine subdomain hostnames are the v1 default because they avoid the listener-hostname overlap that Cilium isolates correctly only from 1.20 onward, and the platform runs 1.19.5. The flat *.<apex> scheme is a later additive phase.
API
A new structured field on TenantGatewaySpec, alongside the existing TLSPassthroughServices, which stays, the layer-7 field is not replaced:
typeTLSPassthroughListenerstruct {
Namestring`json:"name"`// renders as listener "tls-<name>"Portint32`json:"port"`// native port, neither 80 nor 443Hostnamestring`json:"hostname"`// required, exact or left-most-label wildcard inside the apex
}
TLSPassthroughListeners []TLSPassthroughListener`json:"tlsPassthroughListeners,omitempty"`
Validation, as shipped in #3342: Name is a DNS-1123 label, unique within the list and distinct from every tlsPassthroughServices entry; Port is 1..65535, unique within the list, and neither 80 nor 443; Hostname is required rather than defaulted, an exact or wildcard hostname equal to the apex or under it. The field is accepted only under the http01 certificate mode, because the *.<apex> terminate listener of the other two modes would overlap it on SNI.
The route is a standard upstream TLSRoute, no new type. It is rendered once per exposed database release, attaching to the shared tls-<engine> listener by sectionName and carrying <release>.<engine>.<apex> as its hostname. The engine-type listener is created on the first exposure of that engine and removed when its last release goes away; adding or removing a release only touches the route.
Database charts do not render Gateway plumbing. Listener synthesis and route rendering stay in the controller.
Per-engine verdicts
Engine
Port
Fits SNI passthrough?
Treatment
redis
6379
yes: immediate TLS, no pre-TLS negotiation
cleanest fit; opt-in first, default-on candidate once validated
postgres
5432
only with direct TLS
opt-in, needs sslnegotiation=direct, PostgreSQL 17+ on both client libpq and server
mongodb (sharded, mongos)
27017
yes: single stateless endpoint
opt-in, sharded topology only
mongodb (replica set)
27017
no: per-member LB and rs.conf rewrite
deferred
kafka
9094
not in v1: per-broker advertised addresses
deferred, revisit once the model is proven
mariadb
3306
no: the wire protocol precludes it
permanently excluded
MariaDB is excluded at the protocol level, not on effort grounds. The classic MySQL and MariaDB protocol on 3306 is server-speaks-first: the server sends a plaintext greeting and only then may the client upgrade. A raw passthrough listener waits for a ClientHello that will never come, while the client waits for a greeting the listener will never send. That is a mutual deadlock, not a degraded mode. No TLS-first wire mode exists in any MySQL or MariaDB release (the upstream request, MySQL bug #84849, is still open), and the X Protocol on 33060 is STARTTLS-style as well. MariaDB keeps its dedicated LoadBalancer. This also closes the "MariaDB connector conformance" question the original issue carried: no connector can conform, because the server, not the client, owns the first packet.
Certificate flow
The chart is to inject both hostnames, <release>.<engine>.<apex> and <release>.<apex>, into the operator-issued certificate's SAN list before an engine is wired, so the later flat-hostname phase invalidates no certificate and forces no client reconfiguration. Only the flat name is in the tree today, see Status. External clients validate against the <release>.tenant-ca trust anchor defined by #2814, which makes SNI exposure gated per engine on that engine's convergence, rather than blanket-gated on the contract merely existing.
Security
An explicit tls.enabled: false combined with external: true is rejected at admission, so a tenant cannot stand up a plaintext external endpoint by accident and the value is never silently overridden. The rule ships as a ValidatingAdmissionPolicy bound to the typed apps.cozystack.io kinds. That works even though those kinds are served by the aggregated cozystack-api: the extension apiserver evaluates cluster policies itself through the generic apiserver's delegated admission chain. This was verified at runtime on a live cluster, and the existing cozystack-tenant-host-policy already relies on the same path in production.
A missing or mis-emitted SNI is a hard connection failure, since a passthrough listener has no certificate to fall back on. Fail-closed is the secure default here.
Rollout
Done in feat(gateway)!: add TLS-passthrough listeners to TenantGateway #3342. API field plus controller listener rendering, with no engine wired. Gate: on the deployed Cilium 1.19.x, stand up a mode: Passthrough listener on a native database port with a per-engine subdomain hostname alongside the Gateway's terminate listeners, and confirm it SNI-routes to the right backend with exactly one Envoy filter chain. No part of this phase depends on an unreleased Cilium.
Redis, opt-in.
PostgreSQL (direct TLS) and sharded MongoDB, opt-in.
Flat-hostname refinement, gated on the platform carrying the Cilium isolation fix. Certificates already carry both SANs, so this phase changes no client.
The default stays today's per-database LoadBalancer. Passthrough is opt-in, and migrating an existing external database onto it is a breaking change: the IP changes, and Postgres clients need direct TLS.
Constraints
One IP per tenant Gateway on current Cilium: every Gateway claims 443/TCP, so the LB-IPAM sharing-key path is inactive on that collision. ListenerSet consolidates many tenants' listeners onto one shared Gateway; it does not make separate Gateways share an IP. Merged upstream as GatewayAPI: Implement ListenerSets cilium/cilium#46303, expected in Cilium 1.20.
Gateway.spec.listeners is hard-capped at 64, while TLSRoute objects are not capped, which is exactly why the listener model is per-engine rather than per-database.
Cilium 1.19.x consumes TLSRoute from the experimental-channel CRD. Pin to whatever API version the targeted Cilium ships, not the upstream v1 graduation.
Postgres direct TLS requires sslmode=require or stronger, mandates ALPN, and emits SNI only when the client dials by hostname. Driver coverage is broad (pgjdbc 42.7.4+, Npgsql 9.0+, node-postgres, pgx 5.7.5+, lib/pq, psycopg linked against libpq 17), with sqlx and asyncpg as the known gaps.
redis-cli sends SNI only with an explicit --sni flag; library clients derive it from the hostname.
Should the controller surface a status condition as the 64-listener budget approaches the cap?
What fraction of the tenant base predates direct-TLS support on either the Postgres client or the server? There is no graceful downgrade on a passthrough listener.
Acceptance
A database is reachable externally through a Gateway TLS-passthrough listener routed by SNI, with no termination at the gateway.
An end-to-end test asserts that the serial of the certificate presented externally equals that of the operator-issued internal certificate, proving reuse rather than re-issuance.
A client that emits no SNI fails closed.
tls.enabled: false combined with external: true is rejected at admission.
Step 1 is on main: #3342 merged 2026-09-11, no engine wired. I added a Status section to the body and fixed three passages to match what shipped: hostname is required, the field works only under the http01 certificate mode, and the trust anchor is <release>.tenant-ca.
Before redis (step 2) four things are missing on main:
nothing renders a TLSRoute for a database release
certificates lack <release>.<engine>.<apex>; charts add only the flat name, redis adds none
no admission policy against external: true without TLS
no e2e for serial equality or for a client without SNI
The second and third can start now. The first needs an owner: does the controller render the route off the interim trigger from the proposal (a toggle next to the engine's external), or does this wait for #3164?
#2814 is closed, so the per-engine gate is met everywhere.
The two items that could start without the route owner are up.
feat(redis,postgres): certify the per-engine external hostname #4627 adds <release>.<engine>.<apex> to the server certificates of redis and postgres, next to the flat name. MongoDB is not in it: PSMDB issues the certificate itself and its CRD has no field for extra SANs, so the mongos name needs the chart to take over the certificate.
One thing for the route work: a child tenant named redis or postgres gets the apex redis.<apex>, which overlaps *.redis.<apex>. Nothing reserves those tenant names today.
Status
Verified against
main@8a6fe02ee, 2026-09-17.Rollout step 1 is on main. #3342 added
tlsPassthroughListenerstoTenantGatewaySpecand the controller renders the listeners. #3475 (tenant routes must declare their hostnames), #3861 (a tenant picks its Gateway class) and #3872 (Gateway API CRDs v1.6.1) landed alongside it. No engine is wired, which is what step 1 was scoped to.The step-1 gate was run on a test cluster with Cilium 1.19.5, against a hand-built Gateway that mirrors the controller's render. A passthrough listener on 5432 next to the terminate listener on 443 routed by SNI to the right backend, and Envoy built exactly one passthrough filter chain per hostname. Two things came out of that run. The port is not an access boundary: the chain matches on SNI and carries no destination port, so a database hostname also answers on 443. And nobody has opened a connection through the controller's own render yet: the gateway e2e suite renders a passthrough listener and asserts its status, but dials nothing.
What steps 2 to 4 still need, none of it on main:
TLSRoutefor a database release. The only TLSRoutes in the tree belong toapi,vm-exportproxyandcdi-uploadproxy. The ownership question at the bottom is still open, and step 2 cannot start without an answer.<release>.<apex>(checked: postgres, qdrant, nats, mariadb, opensearch), and redis carries in-cluster names only. The per-engine name<release>.<engine>.<apex>has to be added before an engine is wired.external: truewithout TLS.Step 4 waits on a Cilium carrying the isolation fix, and the Cilium bump itself is blocked on #3912.
Context
The merged design proposal
external-database-exposurecovers this issue; what follows is the implementation scope it settles.Today each
external: truedatabase gets its ownLoadBalancerService and its own public IP, one per instance. TLS-passthrough already exists in the tenant Gateway (internal/controller/tenantgateway/reconciler.go) for the layer-7 services (api,vm-exportproxy,cdi-uploadproxy), driven byTLSPassthroughServicesonTenantGatewaySpec, which renderstls-<service>listeners on port 443. No database is wired through it.Routing databases by SNI through a non-terminating listener consolidates many databases onto one tenant Gateway IP. And because the Gateway does not terminate, the certificate the external client validates is byte-identical to the operator-issued certificate the database already presents internally: end-to-end TLS is a consequence of not terminating, not a separate feature to build. That is why #2816 is closed rather than tracked separately.
Listener model
One passthrough listener per engine type, on that engine's native port, with a per-engine wildcard hostname:
tls-postgreson 5432 with*.postgres.<apex>,tls-redison 6379 with*.redis.<apex>,tls-mongoson 27017 with*.mongo.<apex>. Each ismode: Passthrough, withAllowedRouteslimited toTLSRoute. Listener consumption is therefore O(engine types), four or five, not O(database instances).Native ports rather than 443: forcing everything onto 443 buys no additional IP consolidation, since SNI already does that on any port, and it breaks client tooling defaults. All-on-443 remains available as a documented opt-in.
Per-engine subdomain hostnames are the v1 default because they avoid the listener-hostname overlap that Cilium isolates correctly only from 1.20 onward, and the platform runs 1.19.5. The flat
*.<apex>scheme is a later additive phase.API
A new structured field on
TenantGatewaySpec, alongside the existingTLSPassthroughServices, which stays, the layer-7 field is not replaced:Validation, as shipped in #3342:
Nameis a DNS-1123 label, unique within the list and distinct from everytlsPassthroughServicesentry;Portis 1..65535, unique within the list, and neither 80 nor 443;Hostnameis required rather than defaulted, an exact or wildcard hostname equal to the apex or under it. The field is accepted only under thehttp01certificate mode, because the*.<apex>terminate listener of the other two modes would overlap it on SNI.The route is a standard upstream
TLSRoute, no new type. It is rendered once per exposed database release, attaching to the sharedtls-<engine>listener bysectionNameand carrying<release>.<engine>.<apex>as its hostname. The engine-type listener is created on the first exposure of that engine and removed when its last release goes away; adding or removing a release only touches the route.Database charts do not render Gateway plumbing. Listener synthesis and route rendering stay in the controller.
Per-engine verdicts
sslnegotiation=direct, PostgreSQL 17+ on both client libpq and serverrs.confrewriteMariaDB is excluded at the protocol level, not on effort grounds. The classic MySQL and MariaDB protocol on 3306 is server-speaks-first: the server sends a plaintext greeting and only then may the client upgrade. A raw passthrough listener waits for a
ClientHellothat will never come, while the client waits for a greeting the listener will never send. That is a mutual deadlock, not a degraded mode. No TLS-first wire mode exists in any MySQL or MariaDB release (the upstream request, MySQL bug #84849, is still open), and the X Protocol on 33060 is STARTTLS-style as well. MariaDB keeps its dedicated LoadBalancer. This also closes the "MariaDB connector conformance" question the original issue carried: no connector can conform, because the server, not the client, owns the first packet.Certificate flow
The chart is to inject both hostnames,
<release>.<engine>.<apex>and<release>.<apex>, into the operator-issued certificate's SAN list before an engine is wired, so the later flat-hostname phase invalidates no certificate and forces no client reconfiguration. Only the flat name is in the tree today, see Status. External clients validate against the<release>.tenant-catrust anchor defined by #2814, which makes SNI exposure gated per engine on that engine's convergence, rather than blanket-gated on the contract merely existing.Security
An explicit
tls.enabled: falsecombined withexternal: trueis rejected at admission, so a tenant cannot stand up a plaintext external endpoint by accident and the value is never silently overridden. The rule ships as a ValidatingAdmissionPolicy bound to the typedapps.cozystack.iokinds. That works even though those kinds are served by the aggregatedcozystack-api: the extension apiserver evaluates cluster policies itself through the generic apiserver's delegated admission chain. This was verified at runtime on a live cluster, and the existingcozystack-tenant-host-policyalready relies on the same path in production.A missing or mis-emitted SNI is a hard connection failure, since a passthrough listener has no certificate to fall back on. Fail-closed is the secure default here.
Rollout
mode: Passthroughlistener on a native database port with a per-engine subdomain hostname alongside the Gateway's terminate listeners, and confirm it SNI-routes to the right backend with exactly one Envoy filter chain. No part of this phase depends on an unreleased Cilium.The default stays today's per-database LoadBalancer. Passthrough is opt-in, and migrating an existing external database onto it is a breaking change: the IP changes, and Postgres clients need direct TLS.
Constraints
Gateway.spec.listenersis hard-capped at 64, whileTLSRouteobjects are not capped, which is exactly why the listener model is per-engine rather than per-database.TLSRoutefrom the experimental-channel CRD. Pin to whatever API version the targeted Cilium ships, not the upstream v1 graduation.sslmode=requireor stronger, mandates ALPN, and emits SNI only when the client dials by hostname. Driver coverage is broad (pgjdbc 42.7.4+, Npgsql 9.0+, node-postgres, pgx 5.7.5+, lib/pq, psycopg linked against libpq 17), with sqlx and asyncpg as the known gaps.redis-clisends SNI only with an explicit--sniflag; library clients derive it from the hostname.Open questions
TLSRouteand reference-counts the shared listener down to zero. The natural owner is the orchestration layer that reconciles structured exposure entries; until that lands, the interim trigger is the engine'sexternal-adjacent toggle. Note that theExposureClass/ServiceExposureAPI such a layer would build on is itself under reconsideration (Reconsider network.cozystack.io (ExposureClass / ServiceExposure): necessity vs native loadBalancerClass, and coordination with the tenant-networking surface #3164, PR refactor(network): drop ExposureClass/ServiceExposure for native loadBalancerClass #3218), so this design deliberately does not depend on it.Acceptance
tls.enabled: falsecombined withexternal: trueis rejected at admission.Part of #2811. Gated per engine on #2814. Subsumes #2816.