Skip to content

Exposure

What is reachable from the internet, and what it takes to put something there. For why the guardrails are shaped this way, see ADR 0013.

What is public

insights.fobiat.dev is reachable from the internet as of 2026-08-17. Nothing else is.

The external Gateway has one route, umami-collect, for insights.fobiat.dev. cloudflared has a matching ingress entry that reaches the external Gateway, and the Gateway accepts only the exact /script.js and /api/send paths. A proxied CNAME points insights.fobiat.dev at the tunnel, and the Cloudflare WAF rate-limit rule for /api/send described below is live ahead of it, same order the hand-off required.

Kyle accepted the zone entitlement's 10-second block duration rather than upgrading for the originally designed 60 seconds (see ADR 0014). Confirmed live with a 25-request burst against /api/send: requests 1 through 20 returned Umami's own 400 for an empty payload, and 21 through 25 returned Cloudflare's 429. https://insights.fobiat.dev/script.js returns 200 with the tracker script, and unrelated paths return the configured 404. The Umami dashboard itself stays private at umami.lab.fobiat.dev.

Enabling Umami collection

The Cloudflare hand-off below is complete. It is a public-exposure change, so the rate-limit rule was created before the DNS record, not after.

  1. In the fobiat.dev zone, create a proxied CNAME record named insights that targets 50e8490f-820b-4e20-a076-65254e8ad157.cfargotunnel.com. Done.
  2. Spend the zone's one free-tier rate-limit rule on the ingest endpoint:
  3. Expression: (http.host eq "insights.fobiat.dev" and http.request.uri.path eq "/api/send")
  4. Characteristics: cf.colo.id and source IP. Cloudflare requires its colocation ID because it counts limits at the edge.
  5. Rate: 20 requests per 10 seconds
  6. Action: block for 10 seconds, the zone entitlement's current maximum. Done.
  7. Bot Fight Mode and challenges stay off for this hostname. The tracker sends background requests, so either would break collection for real visitors.

Remaining: create the website in Umami and add its data-website-id to the personal site's tracker snippet, in the separate fobiat.dev repository.

The complete answer to "what can enter through the external Gateway?" is:

ls kubernetes/apps/network-public/routes/app/

Keep asking it that way rather than reading a list here. A list in prose drifts the first time someone forgets to update it; a directory listing cannot. Public DNS is a separate, required check.

How something becomes public

Four objects in three places, and the split is deliberate.

  1. An HTTPRoute in kubernetes/apps/network-public/routes/app/. Routes to the external Gateway live only here, which is what makes the ls above sufficient.
  2. A ReferenceGrant in the app's own namespace, so the route in network-public can resolve a backend Service across the namespace boundary. This is the app's own consent to being published, granted by whoever owns it.
  3. A listener on the external Gateway carrying that exact FQDN, in kubernetes/apps/network-public/gateway/app/gateway.yaml.
  4. An entry in cloudflared's ingress list, in kubernetes/apps/network/cloudflared/app/configmap.yaml, above the closing 404.

Missing any one of them fails safe, and each failure has its own signature:

Missing What you see
Route in the wrong namespace The write is denied at admission. A hand kubectl apply fails outright and Flux's reconcile fails the same way, so no route object ever exists to carry a status
Hostname not on a listener Route status NoMatchingListenerHostname
ReferenceGrant A 500 from the Gateway, not a route to somewhere unintended
Tunnel ingress entry The closing 404

What stops it happening by accident

Two ValidatingAdmissionPolicies, in kubernetes/apps/network/gateway-guard/app/validatingadmissionpolicy.yaml:

  • external-route-namespace denies any route with a parentRef named external from any namespace except network-public. Admission-time, so it also catches a hand kubectl apply that Flux would otherwise only revert an hour later.
  • external-gateway-listeners denies a listener on the external Gateway with a wildcard or absent hostname, denies allowedRoutes.namespaces.from being anything other than Same, and denies spec.allowedListeners being set at all, which would otherwise let a ListenerSet in another namespace attach a listener that skips the first two checks.

scripts/check-vap.sh proves both, in the deny direction and the allow direction, through --dry-run=server. It persists nothing and is re-runnable, including after the Proxmox rebuild.

external-dns cannot publish any of this even if all of the above failed. It sources records from HTTPRoutes only, its provider is AdGuard on the LAN rather than Cloudflare, and its domainFilters suffix is lab.fobiat.dev, which does not match insights.fobiat.dev.

The four objects above are not equally guarded, and "missing any one of them fails safe" should not be read as "all four are policy-enforced". The first three are: the Gateway lives in its own namespace, both admission policies reject the obvious mistakes at write time, and a missing ReferenceGrant is enforced by the Gateway API implementation itself. The fourth, cloudflared's ingress list, sits upstream of all three, so a single entry pointing at cilium-gateway-internal.network.svc.cluster.local:443 would publish every LAN service without touching the external Gateway, either policy, routes/app/ or any ReferenceGrant, and ls kubernetes/apps/network-public/routes/app/ would still truthfully say nothing is routed through the external Gateway while missing that a second publish path exists. The tunnel ingress check runs in local lint and CI. It requires every hostname entry to name cilium-gateway-external.network-public.svc.cluster.local:443, requires the origin host headers to match the hostname, and requires the final rule to remain http_status:404. external-dns also cannot create a public DNS record for this hostname.

What this does not protect

Egress is unrestricted. What the NetworkPolicy baseline bought is lateral movement into three namespaces, and only that. Nothing here constrains exfiltration.

The Gateway is a bypass for any LAN service that already has a route. A compromised pod that cannot reach a Service directly can still reach it by its lab.fobiat.dev hostname, arriving back at the backend as reserved:ingress.

system-backup and volsync-system have the same default-deny ingress baseline as the other protected namespaces. VolSync's metrics endpoint is the only cross-namespace exception, and only Prometheus in monitoring can reach it. Egress remains unrestricted, including the Talos API and restic destinations.