Edge exposure and authentication -- cloud specifics
The model, the identity plane and on-prem behaviour: EDGE-AUTH.md. This page covers what changes when the Gateway Service is internet-facing: the public listener, the render guard, CIDR allow-listing, rate limiting, WAF and DNS.
Internet-facing defaults
On a cloud deploy the Gateway Service is internet-facing:
envoyGateway.service.internetFacing is true in edge-aws.yaml, edge-gcp.yaml
and edge-azure.yaml, because each cloud makes a LoadBalancer public unless told
otherwise.
Public web access is default-deny, per exposure group. A route's class puts
it in a group: dfe-ui, HyperDX and the engine's browser families are console;
the engine's CLI families, /openapi.json and SCIM are api; the admin UIs are
admin. Every route of a group, on its internal name and its public one, is
refused at Envoy unless the client address is on exposure.groups.<group>.allow,
and the group's deny list is refused first.
exposure.groups.<group>.allow | The group's routes | Said by |
|---|---|---|
| empty | 403 to every address outside exposure.selfRanges | NOTES, dfe-ops access-summary |
| ranges | the ranges, and exposure.selfRanges | NOTES, dfe-ops access-summary |
a /0 of either family | every address | a WARNING in both |
Every web route the chart renders carries its own SecurityPolicy with its group's
rule; the OIDC ones AND it with their group check. The Gateway-wide policy,
dfe-edge-fence, refuses every address, so it holds only a route another
namespace attaches. Ingest routes (otel, and receiver on
exposure.groups.ingest.door: gateway) carry the ingest group's rule the same
way. The :80 redirect carries an explicit allow, since it serves nothing but a
301 to the fenced https side.
One Service carries every listener, so the load balancer cannot tell the groups
apart: its source ranges are the union of the enabled web groups' allow lists,
the ingest group's while an ingest route renders, exposure.selfRanges and
envoyGateway.service.loadBalancerSourceRanges. A group that admits every
address leaves it open. The Envoy Service carries the web groups' state as
dfe.hyperi.io/edge-fence: console=<state>,api=<state>,admin=<state>, and the
CIDRs each group's routes admit, with exposure.selfRanges, as JSON in
dfe.hyperi.io/edge-fence-ranges.
exposure.selfRanges is the cluster's own NAT egress, which the aws root writes
onto the cluster secret. It is a fact, never an allow list: the deployer's lists
live in the deploy repo's infra/common.yaml, where the engine can change them at
runtime.
exposure.groups.admin.enabled defaults off on the same three flavours, and
argocd/values/aws.yaml sets oidc.enabled: true -- Argo CD, Kafbat, Forgejo,
the links page and Cruise Control all stay off the public load balancer until an
operator's overlay turns the group on. HyperDX is class product, beside the
console that frames it, so the switch never reaches it. The chart's render guard
(envoy-gateway-config.validateUi) refuses the admin group on an internet-facing
Service with an empty exposure.groups.admin.allow, unless allowPublic: true,
and refuses allowPublic without an edge OIDC provider (oidc.enabled and an
oidc.providers entry).
Upgrading a cloud deployment
- Set
exposure.groups.console.allow(andapi.allow,admin.allowas needed) in the deploy repo'sinfra/common.yamlbefore upgrading. An internet-facing Gateway with an empty list answers 403 on that group's routes from the first sync. ui.allowed_cidrs,exposure.infraUisExternalandui.public.<admin UI>are retired and refuse to render, each naming its replacement.- The trusted ranges a deployment already runs with live on its Argo CD cluster secret, in
dfe.hyperi.io/edge_trusted_proxy_cidrs, and stay trusted untilbootstrap.shruns again. Only bootstrap writes that annotation, anddfe-ops refreshanddfe-ops upgradeleave it alone. Re-run bootstrap after upgrading, or remove the annotation, so a load balancer's subnets stop being believed.
The client address the fence judges
The fence judges the TCP peer. No listener believes X-Forwarded-For unless ui.trusted_proxy_cidrs names the hops in front, so neither a client nor a host beside the load balancer, such as the tunnel forwarder in an AWS public subnet, can claim a listed address. With no client-IP detection, Envoy Gateway v1.9.2 sets use_remote_address and trusts no hop (internal/xds/translator/listener.go).
| Flavour | Load balancer | Envoy's TCP peer |
|---|---|---|
| AWS | NLB with IP targets and preserve_client_ip.enabled=true (edge-aws.yaml) | the client |
| GCP | GKE's passthrough Network Load Balancer, externalTrafficPolicy: Local (Envoy Gateway's default) | the client |
| Azure | Azure Load Balancer, externalTrafficPolicy: Local | the client |
| On-prem | MetalLB or another L4 pool, externalTrafficPolicy: Local | the client, or a node address where the pool SNATs |
A CDN in front of the public hostnames writes the client into X-Forwarded-For and connects from its own ranges. Name those ranges in ui.trusted_proxy_cidrs (the dial's edge.product.trusted_proxy_cidrs). The public listeners then read the header right to left when the peer is inside the list and take the first address outside it, while every other connection is judged by its own address. The internal listener never reads the header. List only a proxy that writes the header itself: never an L4 load balancer, and never a subnet other hosts share.
Public hostnames on a cloud deploy
Everything DFE uses internally is private. The UIs are the exception,
because their users sit outside the VPC, and the gateway chart's ui: values
expose them. Nothing in it acts until ui.public_domain (the dial's
edge.product.domain, itself the deployment's dns.public_zone) names a
delegated public zone.
The keys below are the CHART's. The dial states the same decisions under
edge.product and edge.admin_uis (deployment.example.yaml), where
rate_limit, waf and tls are these values verbatim and the rest is the
dial's own spelling. ui.rate_limit.per_client_requests is the chart's alone.
| Key | Default | What it decides |
|---|---|---|
ui.public_domain | "" | The public zone the UIs answer on. Empty renders no public half. |
ui.public.<name> | dfe_ui: true, hyperdx: false | Which product UIs get a public hostname. |
exposure.groups.admin.tools.<route> | every tool false | Which admin UIs get a public hostname, once the admin group is on. |
exposure.groups.<group>.allow, .deny | [] (nobody, internet-facing) | Who may reach the group's routes, at Envoy AND, as a union, at the load balancer. |
ui.trusted_proxy_cidrs | "" (no forwarded header believed) | A CDN or other L7 proxy in front of the public hostnames, whose X-Forwarded-For names the client. |
ui.rate_limit | on, 6000/Minute, local | Each public route's shared bucket, the wall a distributed credential-stuffing run meets before the login, per proxy replica. |
ui.rate_limit.per_client_requests | 1200 (per unit) | A bucket per client address, per route, per proxy replica, on every public route and on every internal-name web route of an internet-facing Service, so one caller's dashboard cannot empty HyperDX for the rest. |
envoyGateway.connectionLimit | on, 10000 | Concurrent connections each listener takes per proxy replica. |
ui.waf.mode | none | Only none renders. |
ui.tls | "1.2", HSTS on | The public TLS floor and Strict-Transport-Security. |
A public UI gets its OWN listener, its own publicly trusted certificate and
its own HTTPRoute <route>-public -- not a second hostname on the internal
route, because the policies that make a public route safe must not reach the
internal one. Internal routes keep the internal CA; the public hostnames
hold a Let's Encrypt certificate issued by DNS-01 on the public zone through
cert-manager's Pod Identity role (tls.public).
What fails the render
These fail the render rather than shipping a weaker edge:
- A public UI with no authentication. Edge OIDC (
edgePolicy, oroidc.targetRoutesnaming the route, with a provider configured) counts, as do the three app-side schemes above -- dfe-ui's NextAuth, Kafbat's own OIDC, HyperDX'sdfe_tokencookie. Nothing else does. - The receiver route on an internet-facing Service with no caller auth.
The chart cannot read the receiver's
config.server.auth.mode, soroutes.receiver.authModerestates it, and onlyheaderorbearerrenders. Envoy terminates TLS, somtlsnever sees a client certificate. internetFacing: falseon a load balancer the cloud makes public. The key is declared, not derived, sofalseis refused besideservice.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing, and on GCP or Azure (cloudorglobal.cloud) on a LoadBalancer Service with nonetworking.gke.io/load-balancer-type: Internalorservice.beta.kubernetes.io/azure-load-balancer-internal: "true".- An entry in any group list,
selfRangesortrusted_proxy_cidrsthat is not a CIDR range, such as a bare address. Envoy Gateway and the load balancer would refuse it at admission, failing the whole gateway sync. - The admin group on an internet-facing Service with no allow list, unless
allowPublicis true with an edge OIDC provider. - A retired key:
ui.allowed_cidrs,exposure.infraUisExternalorui.public.<admin UI>, each refused with its replacement named. - A
/0of either family intrusted_proxy_cidrs. Every peer is then trusted, so Envoy takes the client from the leftmostX-Forwarded-Forentry, which the caller writes.render_dial.py,bootstrap.shand the aws root refuse it too. rate_limit.scope: global, which needs a Redis backend named in the EnvoyGateway install config.rate_limit.requestsorrate_limit.per_client_requeststhat is not an unquoted whole number from 1 to 4294967295, the range Envoy Gateway admits. 0 would refuse every caller, and the old dial's quoted"300"is a string.rate_limit.requestsat or belowrate_limit.per_client_requests. Envoy spends the lower-rate bucket first, so one caller could empty the shared one for every public user.- An engine route whose internal path list is empty. A rule with no matches answers every path on the dfe hostname.
waf.modeother thannone. A WAF terminates TLS above Envoy, which moves the public certificate to the cloud's certificate store.- A
ui.public.<name>with no route, rather than the flag doing nothing.
On AWS the load-balancer half of the filter is loadBalancerSourceRanges,
which the AWS Load Balancer Controller turns into the NLB's frontend
security group. That group can only be attached at creation, so the
controller must own the Service from the start -- see the annotations and
their reasons in argocd/values/edge-aws.yaml. The ranges fence the WHOLE front
door, ingest included: one Service carries every listener.
The engine answers on two routes on its one hostname: dfe-engine carries the
browser families (routes.dfeEngine.browserFamilies) for the console group, and
dfe-engine-cli carries the CLI families, /openapi.json, the Swagger pages
where the engine serves them and, with exposure.groups.api.scim, SCIM, for the
api group. The edge OIDC login fronts dfe-engine alone, because the CLI cannot
follow an interactive redirect; the engine authenticates every call itself.