Skip to main content

Edge exposure and authentication -- cloud specifics

On a cloud deploy the Gateway Service is internet-facing, and public web access is default-deny. The model, the identity plane and on-prem behaviour are in edge-auth.md.

Internet-facing defaults​

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, dfe-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. The group's deny list is refused first.

exposure.groups.<group>.allowThe group's routesSaid by
empty403 to every address outside exposure.selfRangesNOTES, dfe-ops access-summary
rangesthe ranges, and exposure.selfRangesNOTES, dfe-ops access-summary
a /0 of either familyevery addressa WARNING in both

Every web route the chart renders carries its own SecurityPolicy with its group's rule, and 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 can't 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. 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. dfe-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. It also refuses allowPublic without an edge OIDC provider (oidc.enabled and an oidc.providers entry).

Upgrading a cloud deployment​

  • Set exposure.groups.console.allow (and api.allow, admin.allow as needed) in the deploy repo's infra/common.yaml before upgrading. An internet-facing Gateway with an empty list answers 403 on that group's routes from the first sync.
  • ui.allowed_cidrs, exposure.infraUisExternal and ui.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 until bootstrap.sh runs again. Only bootstrap writes that annotation, and dfe-ops refresh and dfe-ops upgrade leave 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).

FlavourLoad balancerEnvoy's TCP peer
AWSNLB with IP targets and preserve_client_ip.enabled=true (edge-aws.yaml)the client
GCPGKE's passthrough Network Load Balancer, externalTrafficPolicy: Local (Envoy Gateway's default)the client
AzureAzure Load Balancer, externalTrafficPolicy: Localthe client
On-premMetalLB or another L4 pool, externalTrafficPolicy: Localthe 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.

KeyDefaultWhat it decides
ui.public_domain""The public zone the UIs answer on. Empty renders no public half.
ui.public.<name>dfe_ui: true, hyperdx: falseWhich product UIs get a public hostname.
exposure.groups.admin.tools.<route>every tool falseWhich 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_limiton, 6000/Minute, localEach public route's shared bucket, per proxy replica. A distributed credential-stuffing run hits it before the login.
ui.rate_limit.per_client_requests1200 (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 dfe-hyperdx for the rest.
envoyGateway.connectionLimiton, 10000Concurrent connections each listener takes per proxy replica.
ui.waf.modenoneOnly none renders.
ui.tls"1.2", HSTS onThe 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. It isn't 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​

The render fails, rather than shipping a weaker edge, for:

  • A public UI with no authentication. Edge OIDC (edgePolicy, or oidc.targetRoutes naming the route, with a provider configured) counts, as do the three app-side schemes above: dfe-ui's NextAuth, Kafbat's own OIDC, dfe-hyperdx's dfe_token cookie. Nothing else does.
  • The receiver route on an internet-facing Service with no caller auth. The chart can't read the receiver's config.server.auth.mode, so routes.receiver.authMode restates it, and only header or bearer renders. Envoy terminates TLS, so mtls never sees a client certificate.
  • internetFacing: false on a load balancer the cloud makes public. The key is declared, not derived, so false is refused beside service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing. On GCP or Azure (cloud or global.cloud) it's refused on a LoadBalancer Service with no networking.gke.io/load-balancer-type: Internal or service.beta.kubernetes.io/azure-load-balancer-internal: "true".
  • An entry in any group list, selfRanges or trusted_proxy_cidrs that isn't 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 allowPublic is true with an edge OIDC provider.
  • A retired key: ui.allowed_cidrs, exposure.infraUisExternal or ui.public.<admin UI>, each refused with its replacement named.
  • A /0 of either family in trusted_proxy_cidrs. Every peer is then trusted, so Envoy takes the client from the leftmost X-Forwarded-For entry, which the caller writes. render_dial.py, bootstrap.sh and the aws root refuse it too.
  • rate_limit.scope: global, which needs a Redis backend named in the EnvoyGateway install config.
  • rate_limit.requests or rate_limit.per_client_requests that isn't 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.requests at or below rate_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.mode other than none. 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, because 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. 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 can't follow an interactive redirect. The engine authenticates every call itself.