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
Revised 2026-08-06. Rebuilt on the per-app ListenerSet primitive to align with ADR-0011 / #168 - the original dask-gateway-pack draft predated that and proposed upserting a listener onto the shared Gateway, the co-ownership pattern that work removes. Also reframed after confirming the gap live on an enforcing cluster (Hetzner k3s): this is not only an off-cluster limitation, it breaks the primary in-cluster dask client too (see Summary).
Summary
NebariApp reconciles HTTP only today: one hostname, one backend Service, HTTPRoutes, and per-app HTTPS Terminate listeners (api/v1/nebariapp_types.go, reconcilers/{routing,tls}/). Some workloads carry L4 traffic that is TLS end-to-end and routed by SNI - the gateway must forward the raw TLS stream without terminating it. Gateway API models this as a TLSRoute attached to a listener with tls.mode: Passthrough; Envoy Gateway supports it, but the operator cannot express it.
The impact is broader than "expose one more thing off-cluster." Without a passthrough listener, a pack's SNI/TLS traffic can only stay in-cluster - and on any cluster that enforces NetworkPolicies, staying in-cluster does not even serve in-cluster clients: the pack's internal proxy sits on a ClusterIP in a private range that hardened egress policies deny, so the intended workload cannot reach it. This is therefore a correctness gap for the primary consumer on production-grade clusters, with off-cluster access as the secondary case.
Every Dask cluster gets its own TLS credentials, generated by the gateway and terminated at the scheduler pod (mutual TLS).
The dask_gateway client always connects through a proxy endpoint, dialing gateway://<proxy-address>/<cluster> and sending TLS SNI daskgateway-<namespace>.<cluster-name> (dask_gateway/comm.py); the proxy routes purely on SNI, passthrough. There is no direct-to-scheduler mode and no plain-HTTP fallback.
Upstream ships a bundled Traefik for this (per-cluster IngressRouteTCP, tls.passthrough: true). In the Nebari pack that Traefik is kept strictly in-cluster (ClusterIP). Confirmed live on an enforcing Hetzner k3s cluster (2026-08-06), this breaks connectivity two ways:
Off-cluster dask clients cannot reach schedulers at all - they hit the REST API through the NebariApp route, create a cluster, then fail to connect.
In-cluster, on any CNI that enforces NetworkPolicies, the data-science-pack singleuser egress policy permits only the Envoy gateway (:10443) plus DNS/hub/proxy and public-minus-private ranges. Traefik's ClusterIP sits in 10/8, which that policy denies - so a JupyterHub notebook, the intended dask client, cannot reach the scheduler proxy either. "Keep Traefik in-cluster" does not serve the in-cluster notebook once policies are enforced.
Repro caveat: this surfaces only on an enforcing CNI. On k3d (flannel) the policy objects exist but are inert, so the notebook reaches Traefik and the gap is invisible - use k3s/Calico to see it.
SNI shape matters: the varying label (<cluster-name>, a UUID) is the second DNS label, so no single listener hostname wildcard can cover it (wildcards are leftmost-label only). A passthrough listener therefore needs either no hostname (match-all on a dedicated port) or per-route exact hostnames - which is exactly how TLSRoute works.
How this must fit the gateway rework (read first)
This depends on and builds on the ListenerSet migration - it is not the shared-Gateway-upsert design the first draft described:
feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2) #168 (ADR-0011 Option 2) moves the operator's per-app listeners off NIC's shared Gateway into an operator-owned per-app ListenerSet attached via spec.parentRef. The passthrough listener introduced here MUST live in that same ListenerSet. Do not upsert it onto the shared Gateway - that is the exact pattern we are eliminating.
A ListenerSet listener can be TLS/Passthrough just as well as Terminate, so this needs no new ownership mechanism: it is an additional listener protocol on the primitive already introduces.
The "own only the listener, let the app attach its own routes" variant below is the same shape as nebari-infrastructure-core#403 (listener-only TLS) and the llm-serving-pack shared-listener use case. Converge into one "operator owns a listener (Terminate or Passthrough), routes optionally app-managed" primitive rather than a parallel design.
Create TLSRoutes (gateway.networking.k8s.io/v1alpha2) for static backends; or - the dask case, where routes are inherently dynamic (one per running cluster) - own only the listener and let the app's own controller attach namespace-local TLSRoutes.
Surface readiness in a new TCPRoutingReady condition and publish the listener/port in status.
TLSRoute stability: GA upstream, version-lagged on our Envoy Gateway stack
TLSRoutegraduated to the Standard channel (gateway.networking.k8s.io/v1) in Gateway API v1.6.0, so it is not an experimental API in the abstract - the earlier worry that it clashes with ADR-0011's "no unstable API in the stable contract" driver rested on stale information.
The real constraint is a version lag in Envoy Gateway, not the API's maturity: EG v1.8.x (the release the NIC upgrade currently targets) pins Gateway API v1.5.1, where TLSRoute is still v1alpha2; EG main already pins Gateway API v1.6.1, and the next release (v1.9.0, ~Aug 2026) ships it, at which point TLSRoute is v1 on our stack. So it is a timing gap that closes on its own, not a stable-vs-experimental design conflict - and a data point for targeting EG v1.9.0 over v1.8.x in the NIC upgrade.
To keep the operator's own contract off the alpha version regardless of the lag, prefer the listener-only split: the operator owns the stable v1 Passthrough listener in its ListenerSet, and the app (e.g. the dask pack) owns the TLSRoute in its own namespace. The only path that pulls v1alpha2 into the operator is the optional "operator creates TLSRoutes for static backends" (step 2 above); gate that as explicitly opt-in until the stack is on EG v1.9.0+ / Gateway API v1.6.
Summary
NebariAppreconciles HTTP only today: one hostname, one backend Service,HTTPRoutes, and per-app HTTPS Terminate listeners (api/v1/nebariapp_types.go,reconcilers/{routing,tls}/). Some workloads carry L4 traffic that is TLS end-to-end and routed by SNI - the gateway must forward the raw TLS stream without terminating it. Gateway API models this as aTLSRouteattached to a listener withtls.mode: Passthrough; Envoy Gateway supports it, but the operator cannot express it.The impact is broader than "expose one more thing off-cluster." Without a passthrough listener, a pack's SNI/TLS traffic can only stay in-cluster - and on any cluster that enforces NetworkPolicies, staying in-cluster does not even serve in-cluster clients: the pack's internal proxy sits on a ClusterIP in a private range that hardened egress policies deny, so the intended workload cannot reach it. This is therefore a correctness gap for the primary consumer on production-grade clusters, with off-cluster access as the secondary case.
Motivating case: dask-gateway-pack
dask-gateway's client<->scheduler protocol (dask-gateway 2026.3.0):
dask_gatewayclient always connects through a proxy endpoint, dialinggateway://<proxy-address>/<cluster>and sending TLS SNIdaskgateway-<namespace>.<cluster-name>(dask_gateway/comm.py); the proxy routes purely on SNI, passthrough. There is no direct-to-scheduler mode and no plain-HTTP fallback.IngressRouteTCP,tls.passthrough: true). In the Nebari pack that Traefik is kept strictly in-cluster (ClusterIP). Confirmed live on an enforcing Hetzner k3s cluster (2026-08-06), this breaks connectivity two ways::10443) plus DNS/hub/proxy and public-minus-private ranges. Traefik's ClusterIP sits in10/8, which that policy denies - so a JupyterHub notebook, the intended dask client, cannot reach the scheduler proxy either. "Keep Traefik in-cluster" does not serve the in-cluster notebook once policies are enforced.SNI shape matters: the varying label (
<cluster-name>, a UUID) is the second DNS label, so no single listenerhostnamewildcard can cover it (wildcards are leftmost-label only). A passthrough listener therefore needs either no hostname (match-all on a dedicated port) or per-route exact hostnames - which is exactly howTLSRouteworks.How this must fit the gateway rework (read first)
This depends on and builds on the ListenerSet migration - it is not the shared-Gateway-upsert design the first draft described:
ListenerSetattached viaspec.parentRef. The passthrough listener introduced here MUST live in that same ListenerSet. Do not upsert it onto the shared Gateway - that is the exact pattern we are eliminating.ListenerSetlistener can beTLS/Passthroughjust as well asTerminate, so this needs no new ownership mechanism: it is an additional listener protocol on the primitive already introduces.nebari-infrastructure-core#403(listener-only TLS) and the llm-serving-pack shared-listener use case. Converge into one "operator owns a listener (Terminate or Passthrough), routes optionally app-managed" primitive rather than a parallel design.Proposed spec surface (sketch)
Operator responsibilities (rebased on ListenerSet)
TLS/Passthroughlistener on a dedicated port to the app's per-appListenerSet(the feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2) #168 primitive), withallowedRoutesscoped to the app's namespace. Never mutate NIC's Gateway.TLSRoutes (gateway.networking.k8s.io/v1alpha2) for static backends; or - the dask case, where routes are inherently dynamic (one per running cluster) - own only the listener and let the app's own controller attach namespace-localTLSRoutes.TCPRoutingReadycondition and publish the listener/port instatus.TLSRoute stability: GA upstream, version-lagged on our Envoy Gateway stack
TLSRoutegraduated to the Standard channel (gateway.networking.k8s.io/v1) in Gateway API v1.6.0, so it is not an experimental API in the abstract - the earlier worry that it clashes with ADR-0011's "no unstable API in the stable contract" driver rested on stale information.The real constraint is a version lag in Envoy Gateway, not the API's maturity: EG v1.8.x (the release the NIC upgrade currently targets) pins Gateway API v1.5.1, where
TLSRouteis stillv1alpha2; EGmainalready pins Gateway API v1.6.1, and the next release (v1.9.0, ~Aug 2026) ships it, at which pointTLSRouteisv1on our stack. So it is a timing gap that closes on its own, not a stable-vs-experimental design conflict - and a data point for targeting EG v1.9.0 over v1.8.x in the NIC upgrade.To keep the operator's own contract off the alpha version regardless of the lag, prefer the listener-only split: the operator owns the stable
v1Passthrough listener in itsListenerSet, and the app (e.g. the dask pack) owns theTLSRoutein its own namespace. The only path that pullsv1alpha2into the operator is the optional "operator createsTLSRoutes for static backends" (step 2 above); gate that as explicitly opt-in until the stack is on EG v1.9.0+ / Gateway API v1.6.