Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions design-proposals/external-database-exposure/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ All repository paths below refer to the `cozystack/cozystack` repository.

## Decisions

- [0002. Plaintext external exposure is refused for redis, where TLS is decided at creation, and not keyed on postgres tls.enabled](./decisions/0002-plaintext-external-exposure-is-refused-for-redis-at-creation.md) — why the admission rule is per engine, covers redis only, and lets an update neither expose an instance nor change its TLS while exposed.
- [0001. External exposure is the native LoadBalancer Service, not a Cozystack exposure API](./decisions/0001-external-exposure-is-the-native-loadbalancer-service.md) — why the removed `ExposureClass` / `ServiceExposure` layer is not the orchestration point.

## Context
Expand All @@ -49,7 +50,7 @@ One consolidation lever exists today without any Gateway involvement: Cilium LB-

### The certificate hooks already exist

The external hostname today is `<release>.<_namespace.host>`, where `_namespace.host` is the tenant apex. The SAN-injection hook already exists on `main` for postgres: it adds the hostname to the CNPG `Cluster` via `spec.certificates.serverAltDNSNames` in `packages/apps/postgres/templates/db.yaml`, gated on `tls.enabled` **and** `external` (the tri-state in the chart's own `_tls.tpl` resolves `tls.enabled` to the value of `external` when unset). The other engines acquire the same SAN hook through the per-app TLS series tracked by the `unified-tls-pki` proposal — on `main` today redis and mariadb carry no TLS templates at all, and their certificate/SAN support lives in the open PRs `cozystack/cozystack#2729` and `cozystack/cozystack#2680`. The trust anchor (`ca.crt`) is delivered to the tenant through that same `unified-tls-pki` contract. So this proposal builds on hooks that are present for postgres and arriving for the rest — it does not invent new ones; what changes under the subdomain scheme is the SAN value set those hooks inject (§6).
The external hostname today is `<release>.<_namespace.host>`, where `_namespace.host` is the tenant apex. Postgres, redis and mariadb each have a SAN-injection hook. For postgres it adds the hostname to the CNPG `Cluster` via `spec.certificates.serverAltDNSNames` in `packages/apps/postgres/templates/db.yaml`, gated on `tls.enabled` **and** `external` (the tri-state in the chart's own `_tls.tpl` resolves `tls.enabled` to the value of `external` when unset). For redis and mariadb, part of the per-app TLS series tracked by the `unified-tls-pki` proposal (`cozystack/cozystack#2729` and `cozystack/cozystack#2680`), each chart's `templates/certmanager.yaml` adds the external names to the server certificate when `external` is on — for redis only when its opt-in TLS is on, and for mariadb only under `tls.issuer: cert-manager`, since the default `operator` issuer cannot carry the external name. The trust anchor (`ca.crt`) is delivered to the tenant through that same `unified-tls-pki` contract. So this proposal builds on those hooks — it does not invent new ones; what changes under the subdomain scheme is the SAN value set those hooks inject (§6).

### The one-IP-per-tenant ceiling

Expand Down Expand Up @@ -130,7 +131,7 @@ This keeps listener consumption at **O(engine types)** — at most four or five

Native ports are chosen over forcing everything onto 443 because the latter buys nothing: it does not improve IP consolidation (SNI already does that on any port) and it breaks client ergonomics and tooling defaults (`psql -h host` assumes 5432) while still demanding direct-TLS. The all-on-443 variant is retained as a documented opt-in for operators who want a single-port firewall surface (see Alternatives).

**The Postgres caveat is load-bearing.** libpq has historically performed a StartTLS-style negotiation: it sends a plaintext `SSLRequest` and waits for the server's single-byte reply *before* the TLS `ClientHello`. There is no SNI in the first packet, so a passthrough listener cannot route it. This is resolved by `sslnegotiation=direct` (libpq, PostgreSQL 17+), which sends the `ClientHello` immediately, carrying SNI. It comes with hard prerequisites, not preferences: the **server must also be PostgreSQL 17+**; `sslnegotiation=direct` requires `sslmode=require` or stronger; direct SSL mandates ALPN (`postgresql`); and SNI is emitted only when the client dials by **hostname** with `sslsni=1` (the default) — an IP literal carries no SNI. Driver coverage is broad by now — pgjdbc 42.7.4+, Npgsql 9.0+, node-postgres, pgx v5.7.5+, lib/pq, and psycopg linked against a libpq 17 all support direct negotiation — with stragglers (sqlx has an open request; asyncpg offers only a lower-level flag without the standard ALPN). Any client older than these, against a pre-17 server, or with `sslmode` below `require`, falls back to the legacy negotiation and cannot be SNI-routed. Postgres-over-passthrough is therefore conditional on direct-TLS-capable clients and is opt-in, not default-on.
**The Postgres caveat is load-bearing.** libpq has historically performed a StartTLS-style negotiation: it sends a plaintext `SSLRequest` and waits for the server's single-byte reply *before* the TLS `ClientHello`. There is no SNI in the first packet, so a passthrough listener cannot route it. This is resolved by `sslnegotiation=direct` (libpq, PostgreSQL 17+), which sends the `ClientHello` immediately, carrying SNI. It comes with hard prerequisites, not preferences: the **server must also be PostgreSQL 17+**; `sslnegotiation=direct` requires `sslmode=require` or stronger; direct SSL mandates ALPN (`postgresql`); and SNI is emitted only when the client dials by **hostname** with `sslsni=1` (the default) — an IP literal carries no SNI. Driver coverage is broad by now — pgjdbc 42.7.4+, Npgsql 9.0+, node-postgres, pgx v5.7.5+, lib/pq, and psycopg linked against a libpq 17 all support direct negotiation — with stragglers (sqlx has an open request; asyncpg offers only a lower-level flag without the standard ALPN). Any client older than these, one left at the default `sslnegotiation=postgres`, or one with `sslmode` below `require` uses the legacy negotiation and cannot be SNI-routed; a direct-negotiation client does not fall back against a pre-17 server, which cannot read the `ClientHello` and closes the connection. Postgres-over-passthrough is therefore conditional on direct-TLS-capable clients and is opt-in, not default-on.

### 2. Certificate reuse is a property of passthrough (the WS5 core)

Expand Down Expand Up @@ -235,21 +236,21 @@ A database gains an `external`-adjacent toggle to select passthrough/SNI mode

## Upgrade and rollback compatibility

The default stays today's per-database LoadBalancer, so no existing external database changes its IP on upgrade. Passthrough/SNI mode is opt-in. Migrating an existing external database to SNI mode is a breaking, opt-in change — the IP changes and the client must be reconfigured (new host, direct-TLS for Postgres); it is not automatic. The later flat-hostname phase is additive by construction (both SANs issued from day one; subdomain hostnames keep working). The existing `TLSPassthroughServices` field continues to work unchanged, and reverting the feature removes the per-release routes and the engine-type listeners without touching the database's own PKI.
The default stays today's per-database LoadBalancer, so no existing external database changes its IP on upgrade. Passthrough/SNI mode is opt-in. Migrating an existing external database to SNI mode is a breaking, opt-in change — the IP changes and the client must be reconfigured (new host, direct-TLS for Postgres); it is not automatic. For redis the admission rule (Security) narrows this further: an external redis without TLS cannot turn TLS on, which passthrough needs, so it reaches SNI mode only as a new instance, and an internal redis cannot be turned `external` later. The later flat-hostname phase is additive by construction (both SANs issued from day one; subdomain hostnames keep working). The existing `TLSPassthroughServices` field continues to work unchanged, and reverting the feature removes the per-release routes and the engine-type listeners without touching the database's own PKI.

## Security

The edge never holds the database's private key — the central strength of passthrough over termination. SNI is sent in cleartext on TLS 1.3 except under ECH, which database clients do not use, so the external hostname is observable on the wire; this is no worse than DNS or SNI exposure for any TLS service, and the payload stays encrypted end-to-end. A missing or mis-emitted SNI is a hard connection failure (a passthrough listener has no certificate to fall back to) — fail-closed, which is the secure default.

Exposing a database externally with TLS explicitly off is not silently corrected: when `tls.enabled` is left unset the tri-state turns TLS on together with `external`, but 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 conflict surfaces as a clear error rather than a silent override. Where that rejection binds deserves precision: the database kinds (`apps.cozystack.io/v1alpha1` `Postgres`, `Redis`, …) are served by the **aggregated** `cozystack-api` apiserver, and the kube-apiserver proxies those writes *before* its own admission chain runs — so it is not kube-apiserver that enforces a policy there. A `ValidatingAdmissionPolicy` bound to the typed kinds still works, because `cozystack-api` is built on the generic apiserver library (`k8s.io/apiserver` `RecommendedOptions`), whose delegated admission chain includes the ValidatingAdmissionPolicy plugin — the extension apiserver evaluates cluster VAPs itself. Verified at runtime, not assumed: a scoped deny-all canary VAP matching `apps.cozystack.io` `tenants` fires on a live cluster exactly as it does on a core resource, and the existing `cozystack-tenant-host-policy` (`packages/system/cozystack-basics`) already relies on this path in production. So the rule ships as a VAP on the typed kinds, consistent with the existing tenant-isolation policies (which enforce hostname isolation — not IP restrictions — via VAPs on the Gateway API kinds and on `tenants`); registry-level validation in `cozystack-api`'s write path remains available as a belt-and-suspenders complement. Cross-namespace `backendRef` requires a `ReferenceGrant`, consistent with the existing attached-namespaces model. Because trust rides the `ca.crt`-only object, clients never receive private key material.
A plaintext external redis is **rejected at admission** rather than silently corrected. Redis TLS is opt-in and fixed when the instance is created, so `external: true` needs an explicit `tls.enabled: true` on create, an update cannot turn `external` on, and the effective `tls.enabled` (unset counts as off) cannot change while `external` stays on. Withdrawing `external` in the same edit is allowed, and the callers `cozystack-tenant-host-policy` trusts are exempt from the TLS freeze, though not from the refusal to expose an existing instance. Postgres is not covered: CloudNativePG keeps TLS offered and accepts plaintext from a client that asks, whatever the chart's `tls.enabled` says, so no rule on that field closes anything; requiring TLS there is a pg_hba change in the chart. MariaDB's opt-in `tls.required` is not checked at admission either. [Decision 0002](./decisions/0002-plaintext-external-exposure-is-refused-for-redis-at-creation.md) records why the rule took this shape. Where that rejection binds deserves precision: the database kinds (`apps.cozystack.io/v1alpha1` `Postgres`, `Redis`, …) are served by the **aggregated** `cozystack-api` apiserver, and the kube-apiserver proxies those writes *before* its own admission chain runs — so it is not kube-apiserver that enforces a policy there. A `ValidatingAdmissionPolicy` bound to the typed kinds still works, because `cozystack-api` is built on the generic apiserver library (`k8s.io/apiserver` `RecommendedOptions`), whose delegated admission chain includes the ValidatingAdmissionPolicy plugin — the extension apiserver evaluates cluster VAPs itself. Verified at runtime, not assumed: a scoped deny-all canary VAP matching `apps.cozystack.io` `tenants` fires on a live cluster exactly as it does on a core resource, and the existing `cozystack-tenant-host-policy` (`packages/system/cozystack-basics`) already relies on this path in production. So the rule ships as a VAP on the typed Redis kind, consistent with the existing tenant-isolation policies (which enforce hostname isolation — not IP restrictions — via VAPs on the Gateway API kinds and on `tenants`); registry-level validation in `cozystack-api`'s write path remains available as a belt-and-suspenders complement. Cross-namespace `backendRef` requires a `ReferenceGrant`, consistent with the existing attached-namespaces model. Because trust rides the `ca.crt`-only object, clients never receive private key material.

## Failure and edge cases

- A pre-PG17 (client or server) or non-direct-TLS Postgres client sends no SNI → no route → connection reset/timeout (document the symptom).
- A Postgres client that does not use direct TLS negotiation (which needs libpq 17+ with `sslnegotiation=direct`, or a driver's equivalent) sends a plaintext SSLRequest before the handshake, so the listener never sees a ClientHello or its SNI → no route → connection reset/timeout (document the symptom). A direct-negotiation client against a pre-17 server is routed, and the server closes the connection because it cannot read the ClientHello.
- Any MariaDB/MySQL client dials a passthrough listener → mutual deadlock (client waits for the server greeting, listener waits for a ClientHello) → timeout; prevented by never rendering a MariaDB listener (matrix exclusion).
- The assembled listener total exceeds the Gateway API cap of 64 → the controller renders no Gateway at all and the tenant Gateway reports `Ready=False`; it is not the one listener over the line that is refused. Because listeners are one per engine type, the cap is reached through child-apex fan-out rather than database fan-out, and the mitigation is to split that subtree onto its own Gateway until `ListenerSet` lifts the cap.
- A flat `*.<apex>` passthrough listener is declared → the controller's overlap rule refuses it while the default `tlsPassthroughServices` listeners hold `api.<apex>`, and the tenant Gateway reports `Ready=False` instead of rendering; on the pinned Cilium that hostname would in any case overlap the terminate listeners and routing would not isolate correctly. The per-engine subdomain hostnames every entry declares here avoid both, and the flat scheme is a later phase (Rollout).
- An explicit `tls.enabled: false` together with `external: true` → rejected at admission by a ValidatingAdmissionPolicy on the typed kind, evaluated in `cozystack-api`'s admission chain (see Security), not silently overridden; an unset `tls` tri-state auto-enables TLS with `external`.
- A redis release asks for `external: true` without `tls.enabled: true`, turns `external` on after creation, or changes the effective `tls.enabled` while `external` stays on → rejected at admission by a ValidatingAdmissionPolicy on the typed kind, evaluated in `cozystack-api`'s admission chain (see Security), not silently overridden.
- An operator expects multi-Gateway IP sharing → each Gateway still gets its own IP (expected under the Cilium constraint).
- A release selects passthrough mode before its engine has a listener → the chart renders the `TLSRoute`, no `tls-<engine>` section exists to attach to, and the route reports that in its status; nothing serves the hostname until the entry is declared. The reverse ordering of the deletion case, and reachable for the same reason: the route follows the release and the entry does not.
- An engine listener is declared for `*.<engine>.<apex>` → every hostname under that subtree stops being terminated by the Gateway, since a passthrough hostname earns no HTTPS-terminate listener and no certificate. Harmless while the subtree holds only database hostnames, which is what the per-engine scheme buys; publishing an HTTP app under `<something>.<engine>.<apex>` is what to avoid.
Expand All @@ -260,7 +261,7 @@ Exposing a database externally with TLS explicitly off is not silently corrected

- Helm-template assertions that the certificate SAN includes both `<release>.<engine>.<apex>` and `<release>.<apex>` per engine, mirroring the existing TLS test fixtures.
- A controller unit test that a `tlsPassthroughListeners` entry renders a shared listener on the declared port with `mode: Passthrough`, the entry's hostname, and route attachment confined to the Gateway's own namespace, and that two per-release `TLSRoute` objects on that one listener SNI-route to their respective backends.
- An admission test that `tls.enabled: false` with `external: true` is rejected by the typed-kind ValidatingAdmissionPolicy on the aggregated path, while an unset `tls` is admitted and auto-enables; and that the listener validation contract (name and port uniqueness, port range, the reserved 80 and 443, hostname within the apex, and the refusal under `certMode: dns01` or `existingSecret`) is enforced at admission, while hostname overlap, an apex no listener hostname fits inside, a repeated `tlsPassthroughServices` entry and the assembled listener total surface as `Ready=False`.
- An admission test that the typed-kind ValidatingAdmissionPolicy refuses a plaintext external redis on create, a later exposure, and a TLS change while external, and admits a release created external with TLS and other edits to one already external; and that the listener validation contract (name and port uniqueness, port range, the reserved 80 and 443, hostname within the apex, and the refusal under `certMode: dns01` or `existingSecret`) is enforced at admission, while hostname overlap, an apex no listener hostname fits inside, a repeated `tlsPassthroughServices` entry and the assembled listener total surface as `Ready=False`.
- An end-to-end test per fitting engine: connect from outside the cluster with SNI and `ca.crt`, and assert that the serial of the presented certificate equals the operator-issued internal certificate — proving reuse, not re-issuance.
- A negative test: a client without SNI fails closed.

Expand Down
Loading