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.
- In the
fobiat.devzone, create a proxied CNAME record namedinsightsthat targets50e8490f-820b-4e20-a076-65254e8ad157.cfargotunnel.com. Done. - Spend the zone's one free-tier rate-limit rule on the ingest endpoint:
- Expression:
(http.host eq "insights.fobiat.dev" and http.request.uri.path eq "/api/send") - Characteristics:
cf.colo.idand source IP. Cloudflare requires its colocation ID because it counts limits at the edge. - Rate: 20 requests per 10 seconds
- Action: block for 10 seconds, the zone entitlement's current maximum. Done.
- 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.
- An HTTPRoute in
kubernetes/apps/network-public/routes/app/. Routes to theexternalGateway live only here, which is what makes thelsabove sufficient. - A
ReferenceGrantin the app's own namespace, so the route innetwork-publiccan resolve a backend Service across the namespace boundary. This is the app's own consent to being published, granted by whoever owns it. - A listener on the
externalGateway carrying that exact FQDN, inkubernetes/apps/network-public/gateway/app/gateway.yaml. - 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-namespacedenies any route with aparentRefnamedexternalfrom any namespace exceptnetwork-public. Admission-time, so it also catches a handkubectl applythat Flux would otherwise only revert an hour later.external-gateway-listenersdenies a listener on theexternalGateway with a wildcard or absent hostname, deniesallowedRoutes.namespaces.frombeing anything other thanSame, and deniesspec.allowedListenersbeing 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.