Merge pull request 'ADR-0016 (Proposed): public ingress via Cloudflare Tunnel' (#1) from docs/adr-cloudflare-tunnel-ingress into main
Reviewed-on: jakman85/home-network-k3s#1
This commit was merged in pull request #1.
This commit is contained in:
commit
1ddc14b918
12 files changed
+242
-41
No files matched your search
+5
-2
@@ -26,8 +26,11 @@ two things (e.g. "VM"), this file disambiguates.
|
|||||||
the port level (R-NET-01).
|
the port level (R-NET-01).
|
||||||
- **Egress class** — a service's declared outbound path: `internet` (standard WAN) or `vpn`
|
- **Egress class** — a service's declared outbound path: `internet` (standard WAN) or `vpn`
|
||||||
(commercial VPN tunnel). See [`docs/03-networking-isolation-and-egress.md`](docs/03-networking-isolation-and-egress.md).
|
(commercial VPN tunnel). See [`docs/03-networking-isolation-and-egress.md`](docs/03-networking-isolation-and-egress.md).
|
||||||
- **Exposure class** — how a service is reachable: `internal` (LAN-only) or `public` (edge
|
- **Exposure class** — how a service is reachable: `internal` (LAN/VPN-only) or `public` (published
|
||||||
port-forwarded).
|
through the Cloudflare Tunnel, ADR-0016).
|
||||||
|
- **Cloudflare Tunnel** — the outbound-only connection from in-cluster cloudflared to Cloudflare that
|
||||||
|
carries all public HTTP(S) ingress; public records are proxied CNAMEs to `<uuid>.cfargotunnel.com`
|
||||||
|
(ADR-0016).
|
||||||
- **Kill-switch** — the guarantee that a `vpn`-class workload leaks nothing to the plain internet if
|
- **Kill-switch** — the guarantee that a `vpn`-class workload leaks nothing to the plain internet if
|
||||||
the tunnel drops (R-NET-06); fail-closed.
|
the tunnel drops (R-NET-06); fail-closed.
|
||||||
- **Multi-WAN** — multiple upstream internet uplinks with routing-rule-based selection; physical WAN
|
- **Multi-WAN** — multiple upstream internet uplinks with routing-rule-based selection; physical WAN
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ screenshot, or "it reconciles" is **not** done (R-TEST-01..04, [`../docs/14-test
|
|||||||
### DNS & ingress (R-DNS-*)
|
### DNS & ingress (R-DNS-*)
|
||||||
- [ ] `<service>.home.keithjackson.dev` record appears automatically and routes correctly.
|
- [ ] `<service>.home.keithjackson.dev` record appears automatically and routes correctly.
|
||||||
- [ ] `internal` is unreachable publicly; `public` is reachable + TLS-valid.
|
- [ ] `internal` is unreachable publicly; `public` is reachable + TLS-valid.
|
||||||
- [ ] Internal clients resolve to the internal Gateway (no public hairpin).
|
- [ ] Internal clients resolving via Pi-hole reach the internal Gateway; via a public resolver, a
|
||||||
|
`public` service still works through the tunnel (R-DNS-04).
|
||||||
|
- [ ] Non-`public` hostnames get 404 at the tunnel and have no public record (R-DNS-05); the
|
||||||
|
Kubernetes API is not publicly reachable (R-SEC-04).
|
||||||
|
|
||||||
### Automated updates (R-UPD-*)
|
### Automated updates (R-UPD-*)
|
||||||
- [ ] Advances to a newer **stable** version automatically on schedule with **no human/AI edit**.
|
- [ ] Advances to a newer **stable** version automatically on schedule with **no human/AI edit**.
|
||||||
|
|||||||
@@ -57,3 +57,4 @@ serves. **Do not edit an accepted decision in place** — supersede it with a ne
|
|||||||
| [0013](adr-0013-vpn-provider-pia-gluetun-wireguard.md) | VPN provider: PIA over OpenVPN-UDP + port forwarding | Accepted — provider/protocol only (client+steering superseded by 0015) | R-NET-05/06/07 |
|
| [0013](adr-0013-vpn-provider-pia-gluetun-wireguard.md) | VPN provider: PIA over OpenVPN-UDP + port forwarding | Accepted — provider/protocol only (client+steering superseded by 0015) | R-NET-05/06/07 |
|
||||||
| [0014](adr-0014-idp-keycloak.md) | Identity Provider: Keycloak (SSO via OIDC + forward-auth) | Accepted | R-IDP-01..06 |
|
| [0014](adr-0014-idp-keycloak.md) | Identity Provider: Keycloak (SSO via OIDC + forward-auth) | Accepted | R-IDP-01..06 |
|
||||||
| [0015](adr-0015-vpn-egress-steering-node-tunnel-cilium.md) | VPN egress steering: node-level tunnel + Cilium Egress Gateway (host kill-switch) | Accepted | R-NET-05/06/07 |
|
| [0015](adr-0015-vpn-egress-steering-node-tunnel-cilium.md) | VPN egress steering: node-level tunnel + Cilium Egress Gateway (host kill-switch) | Accepted | R-NET-05/06/07 |
|
||||||
|
| [0016](adr-0016-public-ingress-cloudflare-tunnel.md) | Public ingress: Cloudflare Tunnel (outbound-only) instead of edge port-forwarding | Proposed | R-DNS-01..05, R-TLS-01/02, R-SEC-02/04 |
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# ADR-0016 — Public ingress: Cloudflare Tunnel (outbound-only) instead of edge port-forwarding
|
||||||
|
- Status: Proposed (decided by the operator 2026-11-08; set Accepted on merge)
|
||||||
|
- Date: 2026-11-08
|
||||||
|
- Serves: R-DNS-01, R-DNS-02, R-DNS-03, R-DNS-04, R-DNS-05, R-TLS-01, R-TLS-02, R-SEC-02, R-SEC-04
|
||||||
|
- Supersedes: — (amends the **ingress** part of the OQ-1 resolution, "edge does NAT + port-forward
|
||||||
|
only", in [00](../docs/00-vision-and-scope.md), [03](../docs/03-networking-isolation-and-egress.md)
|
||||||
|
and [09](../docs/09-dns-and-ingress.md). [ADR-0002](adr-0002-cni-cilium.md) (Gateway API on Cilium)
|
||||||
|
and [ADR-0006](adr-0006-tls-cert-manager-letsencrypt.md) (cert-manager + Let's Encrypt DNS-01) are
|
||||||
|
unchanged: the Gateway still terminates origin TLS with the LE wildcard.)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
The OQ-1 resolution routed public traffic as: public DNS → WAN IP → **edge router port-forward
|
||||||
|
80/443** → Cilium Gateway VIP. LAN clients were expected to avoid the WAN path through **Pi-hole
|
||||||
|
split-horizon DNS** (R-DNS-03).
|
||||||
|
|
||||||
|
That design fails in this environment:
|
||||||
|
- The ISP router (GFiber) **cannot hairpin NAT**. A LAN client that resolves a service to the WAN IP
|
||||||
|
cannot reach it.
|
||||||
|
- The same router **advertises Google IPv6 DNS servers via Router Advertisement**, and the operator
|
||||||
|
cannot turn that off. LAN clients (Android in particular) therefore use a public resolver
|
||||||
|
alongside, or instead of, Pi-hole. They get the WAN answer and fail.
|
||||||
|
|
||||||
|
So split-horizon DNS cannot be *relied on* to keep LAN clients off the WAN path. Correctness for
|
||||||
|
public web apps has to come from the public answer itself working from the LAN.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
**Public HTTP(S) ingress goes through a Cloudflare Tunnel.**
|
||||||
|
|
||||||
|
- **cloudflared runs in-cluster** (a Flux-managed workload) and holds an **outbound-only**
|
||||||
|
connection to Cloudflare. No inbound connection to the cluster is needed for public web traffic.
|
||||||
|
- **Public records** for `exposure: public` services are **proxied CNAMEs to
|
||||||
|
`<tunnel-uuid>.cfargotunnel.com`**, managed by **ExternalDNS** from declared service intent
|
||||||
|
(R-DNS-02). No record is hand-edited.
|
||||||
|
- **The edge router's 80/443 forwards are closed** after cutover.
|
||||||
|
- **Hostnames are unchanged:** `<service>.home.keithjackson.dev` and the apex
|
||||||
|
`home.keithjackson.dev` (R-DNS-01, R-C2-01).
|
||||||
|
- **Edge TLS** is Cloudflare's: **Advanced Certificate Manager / Total TLS**, because Universal SSL
|
||||||
|
covers only one subdomain level (`*.keithjackson.dev`) and not `<svc>.home.keithjackson.dev`. The
|
||||||
|
edge certificate is publicly trusted and auto-renewed (R-TLS-01/02).
|
||||||
|
- **Origin TLS stays at the Gateway** with the cert-manager Let's Encrypt wildcard (ADR-0006).
|
||||||
|
cloudflared connects to the Gateway over HTTPS and **validates the origin certificate**, using
|
||||||
|
the service hostname as SNI. It does not skip verification (`noTLSVerify` is not used).
|
||||||
|
- **Only `exposure: public` services have a tunnel route.** The tunnel's ingress rules end in a
|
||||||
|
**catch-all `http_status:404`**, so any other hostname presented at the tunnel is refused
|
||||||
|
(R-DNS-05).
|
||||||
|
- **The Kubernetes API is never reachable through the tunnel**, and is never published by any other
|
||||||
|
path. It is LAN/VPN-only (R-SEC-04).
|
||||||
|
|
||||||
|
**Never published (LAN/VPN-only, `exposure: internal`):** jackett, pihole, semaphore, qbittorrent,
|
||||||
|
radarr, sonarr, **headlamp** (a front end to the Kubernetes API), and the non-HTTP L4 services
|
||||||
|
**unifi** and **crafty-mc** (Minecraft). They get no public DNS record and no tunnel route.
|
||||||
|
|
||||||
|
**Plex stays off the tunnel.** Cloudflare's terms restrict proxying video, so Plex remote streaming
|
||||||
|
keeps using **plex.tv + WAN tcp/32400** (an edge forward that stays). The `plex.home` web UI becomes
|
||||||
|
**LAN-only**: no tunnel route and no proxied record.
|
||||||
|
|
||||||
|
**Unchanged:**
|
||||||
|
- **WireGuard remote access**: udp/51820, reached through the DNS-only (unproxied) `wan.` record,
|
||||||
|
which cloudflare-ddns keeps current. The WAN IP therefore **stays public**. The tunnel does not
|
||||||
|
hide the origin; it only removes 80/443 from the edge.
|
||||||
|
- **Pi-hole keeps serving the LAN**, but split DNS is no longer *required* for public web apps to
|
||||||
|
work. It keeps explicit local records for:
|
||||||
|
- the internal-only names;
|
||||||
|
- the L4 VIPs;
|
||||||
|
- the names that must avoid Cloudflare's **100 MB request-body** and **100 s response-timeout**
|
||||||
|
limits, or must keep working during an internet outage: **gitea**, **nextcloud**, and
|
||||||
|
**keycloak/auth**.
|
||||||
|
- **In-cluster CoreDNS split horizon** and the **node `/etc/hosts` pins** (the OIDC issuer and the
|
||||||
|
Gitea registry) stay. Cluster-internal callers never go through Cloudflare.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
- **Keep the port-forward and fix split horizon.** Rejected: the router's RA-advertised Google IPv6
|
||||||
|
DNS cannot be disabled, so some LAN clients will always get the public answer, and the router
|
||||||
|
cannot hairpin it.
|
||||||
|
- **Replace the ISP router (or put a router capable of hairpin NAT and RA control behind it).**
|
||||||
|
Rejected for now: it adds hardware and a non-IaC edge device, which contradicts the "dumb SOHO
|
||||||
|
edge" premise of OQ-1, and the GFiber RA behaviour would still need to be contained. It can be
|
||||||
|
reopened by a future ADR.
|
||||||
|
- **Block or override the Google DNS on the LAN** (firewall DoH/DNS egress, or intercept port 53).
|
||||||
|
Rejected: the SOHO edge has no such controls, and client-side DoH/hard-coded resolvers make it
|
||||||
|
incomplete anyway.
|
||||||
|
- **Tailscale Funnel / ngrok / another reverse-tunnel provider.** Rejected: Cloudflare is already the
|
||||||
|
authoritative DNS and token holder (OQ-4), so its tunnel integrates with ExternalDNS and the
|
||||||
|
existing scoped token with no new vendor.
|
||||||
|
- **Self-hosted reverse tunnel on a VPS** (WireGuard + reverse proxy). Rejected: it adds a host to
|
||||||
|
run, patch and secure, outside the cluster's GitOps model.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
- **Positive:** LAN clients reach public web apps correctly whichever resolver answers. No NAT
|
||||||
|
hairpin is needed. Inbound 80/443 is no longer open on the WAN IP. Publishing is an explicit,
|
||||||
|
declared, per-hostname act (tunnel route + record), and anything else 404s at the edge.
|
||||||
|
- **Dependence on Cloudflare.** A Cloudflare or tunnel outage takes public access down. The LAN keeps
|
||||||
|
working through Pi-hole's local records for gitea, nextcloud and keycloak/auth and the
|
||||||
|
internal-only names. Public apps without a local record are unreachable from the LAN during such an
|
||||||
|
outage too. Tunnel health becomes an alert condition (R-OBS-04).
|
||||||
|
- **Cloudflare sees plaintext.** TLS terminates at Cloudflare's edge, so Cloudflare can read the
|
||||||
|
request and response content of every tunnelled service. Re-encryption to the origin protects
|
||||||
|
only the Cloudflare → cluster leg.
|
||||||
|
- **Upload and timeout limits:** 100 MB request bodies and 100 s responses through the proxy. Large
|
||||||
|
uploads (Nextcloud, Gitea pushes and registry blobs) must use the LAN path, which is why those names
|
||||||
|
keep local records.
|
||||||
|
- **Client IP** now arrives as `CF-Connecting-IP` / `X-Forwarded-For` from cloudflared, not as the
|
||||||
|
TCP source. Logging, rate-limiting and any IP-based policy must read those headers, and trust them
|
||||||
|
only from cloudflared.
|
||||||
|
- **The WAN IP stays public** (WireGuard, Plex 32400). The tunnel is not origin cloaking.
|
||||||
|
- **TLS certificates:** two publicly trusted certificates are in play per public name (Cloudflare edge
|
||||||
|
+ LE origin). Both auto-renew. ACM/Total TLS is a paid Cloudflare feature and an operator-held
|
||||||
|
prerequisite.
|
||||||
|
- **Follow-ups (implementation repo):** deploy cloudflared with a SOPS-encrypted tunnel credential;
|
||||||
|
move ExternalDNS public records to proxied CNAMEs for `public` services only; add conformance tests
|
||||||
|
for R-DNS-04/05 and R-SEC-04; alert on tunnel health; then close the 80/443 edge forwards (a
|
||||||
|
documented manual edge step, like the original forwards).
|
||||||
@@ -40,7 +40,9 @@ The platform is delivered in two stages:
|
|||||||
|
|
||||||
## The domain & DNS model
|
## The domain & DNS model
|
||||||
|
|
||||||
- Public apex `*.keithjackson.dev` is available and its records resolve to the network's public IP.
|
- Public apex `*.keithjackson.dev` is available in Cloudflare DNS. `public` services under
|
||||||
|
`*.home.keithjackson.dev` are published through a **Cloudflare Tunnel** (proxied records, no inbound
|
||||||
|
80/443 at the edge — ADR-0016); the network's public IP stays in use for WireGuard and Plex only.
|
||||||
- All platform services live under `*.home.keithjackson.dev`.
|
- All platform services live under `*.home.keithjackson.dev`.
|
||||||
- Each service is reachable at `<service>.home.keithjackson.dev` (e.g. `plex.home.keithjackson.dev`).
|
- Each service is reachable at `<service>.home.keithjackson.dev` (e.g. `plex.home.keithjackson.dev`).
|
||||||
- The **root** `home.keithjackson.dev` serves the **Command & Control (C2) center** UI.
|
- The **root** `home.keithjackson.dev` serves the **Command & Control (C2) center** UI.
|
||||||
@@ -117,6 +119,12 @@ mandatory; `SHOULD` is strongly preferred and requires an ADR to deviate.
|
|||||||
- **R-DNS-02** — DNS records MUST be managed automatically from declared service intent.
|
- **R-DNS-02** — DNS records MUST be managed automatically from declared service intent.
|
||||||
- **R-DNS-03** — Internal clients MUST resolve service names correctly (split-horizon where public
|
- **R-DNS-03** — Internal clients MUST resolve service names correctly (split-horizon where public
|
||||||
and internal answers differ).
|
and internal answers differ).
|
||||||
|
- **R-DNS-04** — `public` HTTP(S) services MUST be reachable from the internet **without inbound
|
||||||
|
80/443 port-forwarding at the edge**, and LAN clients MUST reach them correctly **whichever resolver
|
||||||
|
answers** (public or internal) — no dependency on NAT hairpin (ADR-0016).
|
||||||
|
- **R-DNS-05** — Only services declared `exposure: public` MAY be reachable through the public
|
||||||
|
ingress path. A request for any other hostname at that path MUST be refused (HTTP 404), and
|
||||||
|
`internal` services MUST have no public DNS record (ADR-0016).
|
||||||
|
|
||||||
### Observability & alerting — `R-OBS-*`
|
### Observability & alerting — `R-OBS-*`
|
||||||
- **R-OBS-01** — Services MUST be monitored (metrics, logs, health).
|
- **R-OBS-01** — Services MUST be monitored (metrics, logs, health).
|
||||||
@@ -137,6 +145,8 @@ mandatory; `SHOULD` is strongly preferred and requires an ADR to deviate.
|
|||||||
- **R-SEC-02** — The platform MUST follow least-privilege for workload identities and network reach.
|
- **R-SEC-02** — The platform MUST follow least-privilege for workload identities and network reach.
|
||||||
- **R-SEC-03** — All the above properties MUST be demonstrable/auditable (an operator can prove
|
- **R-SEC-03** — All the above properties MUST be demonstrable/auditable (an operator can prove
|
||||||
isolation, egress path, cert validity, and backup integrity).
|
isolation, egress path, cert validity, and backup integrity).
|
||||||
|
- **R-SEC-04** — The Kubernetes API MUST NOT be reachable from the public internet by any path (no
|
||||||
|
tunnel route, no edge forward, no public DNS record); it is LAN/VPN-only (ADR-0016).
|
||||||
|
|
||||||
### Identity & access (SSO) — `R-IDP-*`
|
### Identity & access (SSO) — `R-IDP-*`
|
||||||
- **R-IDP-01** — The platform MUST provide a central Identity Provider (SSO) as the single user
|
- **R-IDP-01** — The platform MUST provide a central Identity Provider (SSO) as the single user
|
||||||
@@ -204,6 +214,8 @@ implementation.
|
|||||||
- **OQ-1 (WAN / edge)** — **RESOLVED**: one commercial WAN, no multi-ISP plans; dumb SOHO edge (no
|
- **OQ-1 (WAN / edge)** — **RESOLVED**: one commercial WAN, no multi-ISP plans; dumb SOHO edge (no
|
||||||
VLAN/VPN/services). All segmentation + VPN in-cluster; edge does NAT + port-forward only. Physical
|
VLAN/VPN/services). All segmentation + VPN in-cluster; edge does NAT + port-forward only. Physical
|
||||||
multi-WAN out of scope. See [`03`](03-networking-isolation-and-egress.md), [`09`](09-dns-and-ingress.md).
|
multi-WAN out of scope. See [`03`](03-networking-isolation-and-egress.md), [`09`](09-dns-and-ingress.md).
|
||||||
|
**Amended 2026-11-08 (ADR-0016):** public web ingress moved to an outbound-only **Cloudflare Tunnel**;
|
||||||
|
the edge no longer forwards 80/443. Its remaining forwards are WireGuard udp/51820 and Plex tcp/32400.
|
||||||
- **OQ-2 (bulk media)** — **RESOLVED (deferred backing)**: Plex bulk media lives **outside the
|
- **OQ-2 (bulk media)** — **RESOLVED (deferred backing)**: Plex bulk media lives **outside the
|
||||||
cluster**; the specific backing is **TBD later** and does not block the core platform. Plex's
|
cluster**; the specific backing is **TBD later** and does not block the core platform. Plex's
|
||||||
metadata DB still lives on replicated Longhorn. The **off-cluster DR backup target** (for app
|
metadata DB still lives on replicated Longhorn. The **off-cluster DR backup target** (for app
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ choice here without a superseding ADR.**
|
|||||||
│ dev/test: libvirt KVMs (OpenTofu + cloud-init + Ansible) │
|
│ dev/test: libvirt KVMs (OpenTofu + cloud-init + Ansible) │
|
||||||
│ prod: 3× mini-PC bare metal, local disks │
|
│ prod: 3× mini-PC bare metal, local disks │
|
||||||
├──────────────────────────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────────────────────────┤
|
||||||
│ Edge: router/firewall — multi-WAN uplinks, port-forward to Gateway, │
|
│ Edge: SOHO router — single WAN, NAT; forwards only WireGuard + Plex │
|
||||||
│ VPN uplink(s), split-horizon DNS │
|
│ Public web ingress: Cloudflare Tunnel (outbound-only cloudflared, ADR-0016)│
|
||||||
└──────────────────────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -82,8 +82,10 @@ as multi-ISP routing but as **two egress paths selected by routing rules**:
|
|||||||
route across; the `internet` path is simply the default route out.
|
route across; the `internet` path is simply the default route out.
|
||||||
- **The edge router is a dumb SOHO device** — no VLANs, no VPN, no services, no API/IaC. Therefore
|
- **The edge router is a dumb SOHO device** — no VLANs, no VPN, no services, no API/IaC. Therefore
|
||||||
**all segmentation and all VPN handling live in-cluster** (Cilium policy + the VPN gateway), not on
|
**all segmentation and all VPN handling live in-cluster** (Cilium policy + the VPN gateway), not on
|
||||||
the edge. The edge's only jobs are NAT out and **port-forwarding 80/443 (and specific service
|
the edge. The edge's only jobs are NAT out and a few **manual port-forwards that are not web
|
||||||
ports) to the ingress VIP** for `public` services — a documented manual step ([09](09-dns-and-ingress.md)).
|
ingress**: WireGuard udp/51820 and Plex tcp/32400 ([09](09-dns-and-ingress.md)). Public web ingress
|
||||||
|
uses an outbound-only **Cloudflare Tunnel** from in-cluster cloudflared (ADR-0016), so the edge does
|
||||||
|
not forward 80/443.
|
||||||
- The **service-facing contract is unchanged**: a service still just declares `egress: internet|vpn`.
|
- The **service-facing contract is unchanged**: a service still just declares `egress: internet|vpn`.
|
||||||
|
|
||||||
## VPN egress model: node-level tunnel + Cilium Egress Gateway (R-NET-05/06 — ADR-0015; provider ADR-0013)
|
## VPN egress model: node-level tunnel + Cilium Egress Gateway (R-NET-05/06 — ADR-0015; provider ADR-0013)
|
||||||
@@ -133,7 +135,8 @@ closed (no packets to the plain internet), while `internet`-class is unaffected.
|
|||||||
|
|
||||||
North-south exposure is handled by **Gateway API implemented by Cilium**, with TLS from
|
North-south exposure is handled by **Gateway API implemented by Cilium**, with TLS from
|
||||||
cert-manager and DNS from ExternalDNS. Detailed in [`09-dns-and-ingress.md`](09-dns-and-ingress.md).
|
cert-manager and DNS from ExternalDNS. Detailed in [`09-dns-and-ingress.md`](09-dns-and-ingress.md).
|
||||||
Exposure is a declared property: `internal` (LAN-only) vs `public` (port-forwarded at the edge).
|
Exposure is a declared property: `internal` (LAN/VPN-only) vs `public` (published through the
|
||||||
|
Cloudflare Tunnel, ADR-0016).
|
||||||
|
|
||||||
## Observability of the network (supports R-SEC-03, R-OBS-04)
|
## Observability of the network (supports R-SEC-03, R-OBS-04)
|
||||||
- **Hubble** provides flow visibility to verify default-deny and troubleshoot allow rules.
|
- **Hubble** provides flow visibility to verify default-deny and troubleshoot allow rules.
|
||||||
@@ -151,6 +154,8 @@ Exposure is a declared property: `internal` (LAN-only) vs `public` (port-forward
|
|||||||
## Resolved
|
## Resolved
|
||||||
- **OQ-1** — one commercial WAN, no multi-ISP plans; dumb SOHO edge (no VLAN/VPN/services). All
|
- **OQ-1** — one commercial WAN, no multi-ISP plans; dumb SOHO edge (no VLAN/VPN/services). All
|
||||||
segmentation + VPN in-cluster; edge does NAT + port-forward only. Multi-ISP routing out of scope.
|
segmentation + VPN in-cluster; edge does NAT + port-forward only. Multi-ISP routing out of scope.
|
||||||
|
Amended 2026-11-08 (ADR-0016): 80/443 are no longer forwarded; public web ingress is a Cloudflare
|
||||||
|
Tunnel.
|
||||||
- **OQ-3** — **PIA over OpenVPN-UDP** (ADR-0013); egress is a **node-level host tunnel** steered by **Cilium
|
- **OQ-3** — **PIA over OpenVPN-UDP** (ADR-0013); egress is a **node-level host tunnel** steered by **Cilium
|
||||||
Egress Gateway** with a host nftables kill-switch (ADR-0015), rules enforced outside the service.
|
Egress Gateway** with a host nftables kill-switch (ADR-0015), rules enforced outside the service.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ Defense in depth with declarative, auditable controls:
|
|||||||
node(s) via Ansible vault** (the tunnel is host infra, not a cluster pod; ADR-0015), NOT a cluster SOPS Secret.
|
node(s) via Ansible vault** (the tunnel is host infra, not a cluster pod; ADR-0015), NOT a cluster SOPS Secret.
|
||||||
- Keycloak admin credential and per-service **OIDC client secrets** ([15](15-identity-and-access.md)).
|
- Keycloak admin credential and per-service **OIDC client secrets** ([15](15-identity-and-access.md)).
|
||||||
- DNS provider API token (Cloudflare) for ACME DNS-01 + ExternalDNS.
|
- DNS provider API token (Cloudflare) for ACME DNS-01 + ExternalDNS.
|
||||||
|
- Cloudflare Tunnel credential/token for in-cluster cloudflared (SOPS-encrypted; ADR-0016).
|
||||||
- SMTP credentials for Alertmanager email.
|
- SMTP credentials for Alertmanager email.
|
||||||
- Service credentials (Nextcloud, Gitea admin, UniFi, Semaphore, Kasm, DB passwords).
|
- Service credentials (Nextcloud, Gitea admin, UniFi, Semaphore, Kasm, DB passwords).
|
||||||
- Off-cluster backup target credentials (S3/NFS) for Velero/Longhorn.
|
- Off-cluster backup target credentials (S3/NFS) for Velero/Longhorn.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 08 — TLS & Certificates
|
# 08 — TLS & Certificates
|
||||||
|
|
||||||
Serves: **R-TLS-01/02/03**, **R-DNS-01**. Related: [ADR-0006 (cert-manager + Let's Encrypt)](../decisions/adr-0006-tls-cert-manager-letsencrypt.md).
|
Serves: **R-TLS-01/02/03**, **R-DNS-01**. Related: [ADR-0006 (cert-manager + Let's Encrypt)](../decisions/adr-0006-tls-cert-manager-letsencrypt.md),
|
||||||
|
[ADR-0016 (Cloudflare Tunnel public ingress)](../decisions/adr-0016-public-ingress-cloudflare-tunnel.md).
|
||||||
|
|
||||||
## Requirement recap
|
## Requirement recap
|
||||||
|
|
||||||
@@ -30,8 +31,8 @@ services (R-TLS-01/02/03).
|
|||||||
|
|
||||||
## DNS-01 challenge access (OQ-4 — RESOLVED: scoped Cloudflare API token)
|
## DNS-01 challenge access (OQ-4 — RESOLVED: scoped Cloudflare API token)
|
||||||
|
|
||||||
Cloudflare already has a wildcard `*.keithjackson.dev` → public-IP record, which covers
|
Cloudflare already has a wildcard `*.keithjackson.dev` → public-IP record, which does not cover
|
||||||
*reachability* but not certificate issuance (ACME DNS-01 must **write `_acme-challenge` TXT records**,
|
certificate issuance (ACME DNS-01 must **write `_acme-challenge` TXT records**,
|
||||||
and that wildcard does not match two-label-deep names like `plex.home.keithjackson.dev`).
|
and that wildcard does not match two-label-deep names like `plex.home.keithjackson.dev`).
|
||||||
|
|
||||||
**Decision (OQ-4):** use a **scoped Cloudflare API token** — `Zone·DNS·Edit` limited to the
|
**Decision (OQ-4):** use a **scoped Cloudflare API token** — `Zone·DNS·Edit` limited to the
|
||||||
@@ -55,8 +56,15 @@ DNS edit only) so its blast radius is bounded (R-SEC-02). Rotation = re-encrypt
|
|||||||
## Where TLS terminates
|
## Where TLS terminates
|
||||||
|
|
||||||
- **North-south**: at the **Gateway API** listener (implemented by Cilium, [09](09-dns-and-ingress.md)),
|
- **North-south**: at the **Gateway API** listener (implemented by Cilium, [09](09-dns-and-ingress.md)),
|
||||||
using the cert-manager-issued cert. Public services are reached via the edge router forwarding to
|
using the cert-manager-issued cert. Internal services resolve to the Gateway on the LAN.
|
||||||
the Gateway; internal services resolve to the Gateway on the LAN.
|
- **Public services via the Cloudflare Tunnel (ADR-0016)** are TLS twice:
|
||||||
|
- **Edge:** Cloudflare terminates the client's TLS with an ACM/Total TLS certificate. It is
|
||||||
|
publicly trusted and auto-renewed (R-TLS-01/02). Universal SSL is not enough, because it covers
|
||||||
|
only one subdomain level.
|
||||||
|
- **Origin:** cloudflared re-encrypts to the Gateway and **validates** the Let's Encrypt wildcard,
|
||||||
|
using the service hostname as SNI. Verification is never disabled.
|
||||||
|
|
||||||
|
Cloudflare sees plaintext between the two legs, a trade-off accepted in ADR-0016.
|
||||||
- Internal service-to-service traffic relies primarily on network policy for security; mTLS between
|
- Internal service-to-service traffic relies primarily on network policy for security; mTLS between
|
||||||
workloads is **optional** future hardening (could be added via Cilium/service mesh) and is not a
|
workloads is **optional** future hardening (could be added via Cilium/service mesh) and is not a
|
||||||
launch requirement — record as a possible future ADR.
|
launch requirement — record as a possible future ADR.
|
||||||
|
|||||||
+73
-24
@@ -1,14 +1,16 @@
|
|||||||
# 09 — DNS & Ingress
|
# 09 — DNS & Ingress
|
||||||
|
|
||||||
Serves: **R-DNS-01/02/03**, **R-C2-01**, works with **R-TLS-***. Related:
|
Serves: **R-DNS-01/02/03/04/05**, **R-C2-01**, **R-SEC-04**, works with **R-TLS-***. Related:
|
||||||
[ADR-0002 (Cilium/Gateway API)](../decisions/adr-0002-cni-cilium.md).
|
[ADR-0002 (Cilium/Gateway API)](../decisions/adr-0002-cni-cilium.md),
|
||||||
|
[ADR-0016 (Cloudflare Tunnel public ingress)](../decisions/adr-0016-public-ingress-cloudflare-tunnel.md).
|
||||||
|
|
||||||
## Naming model
|
## Naming model
|
||||||
|
|
||||||
- All services live at `<service>.home.keithjackson.dev` (e.g. `plex.home.keithjackson.dev`).
|
- All services live at `<service>.home.keithjackson.dev` (e.g. `plex.home.keithjackson.dev`).
|
||||||
- The **root** `home.keithjackson.dev` serves the **Command & Control UI** ([12](12-command-center-ui.md)).
|
- The **root** `home.keithjackson.dev` serves the **Command & Control UI** ([12](12-command-center-ui.md)).
|
||||||
- Public apex `*.keithjackson.dev` resolves to the network's public IP; `*.home.keithjackson.dev`
|
- `*.home.keithjackson.dev` is the platform's zone, in Cloudflare DNS. `public` services are
|
||||||
is the platform's zone.
|
published as **proxied CNAMEs to the Cloudflare Tunnel** (`<tunnel-uuid>.cfargotunnel.com`,
|
||||||
|
ADR-0016), not as records pointing at the network's public IP.
|
||||||
|
|
||||||
## DNS automation (R-DNS-02)
|
## DNS automation (R-DNS-02)
|
||||||
|
|
||||||
@@ -16,16 +18,32 @@ Serves: **R-DNS-01/02/03**, **R-C2-01**, works with **R-TLS-***. Related:
|
|||||||
authoritative DNS provider (Cloudflare, per OQ-4) automatically — a new service's DNS appears from
|
authoritative DNS provider (Cloudflare, per OQ-4) automatically — a new service's DNS appears from
|
||||||
its declared intent, no manual record editing.
|
its declared intent, no manual record editing.
|
||||||
- Records for `*.home.keithjackson.dev` are created/updated/removed as services come and go.
|
- Records for `*.home.keithjackson.dev` are created/updated/removed as services come and go.
|
||||||
|
- Public records are created **only for `exposure: public` services** (R-DNS-05). The DNS-only
|
||||||
|
(unproxied) `wan.` record for WireGuard is kept current by cloudflare-ddns, outside this flow.
|
||||||
|
|
||||||
## Split-horizon resolution (R-DNS-03)
|
## Split-horizon resolution (R-DNS-03)
|
||||||
|
|
||||||
Internal and external answers differ:
|
Internal and external answers differ:
|
||||||
- **External** (`*.home.keithjackson.dev` from the internet): resolves to the public IP; reaches
|
- **External** (`<service>.home.keithjackson.dev` from the internet): a `public` service resolves to
|
||||||
the edge router, which forwards `public`-class services to the in-cluster Gateway.
|
**Cloudflare's edge** (proxied CNAME to the tunnel). Cloudflare forwards the request down the
|
||||||
- **Internal** (LAN clients): resolves `<service>.home.keithjackson.dev` to the **internal Gateway
|
outbound-only tunnel to cloudflared in the cluster, which forwards it to the Gateway (ADR-0016).
|
||||||
address** directly, avoiding hairpinning through the public IP.
|
`internal` services have no public answer.
|
||||||
- **PiHole** (itself an onboarded service) provides internal LAN DNS and the split-horizon answers
|
- **Internal** (LAN clients): Pi-hole answers with the **internal Gateway address** (or the service's
|
||||||
for `.home.keithjackson.dev`; the trusted Let's Encrypt cert is served either way ([08](08-tls-and-certificates.md)).
|
L4 VIP) for the names it holds local records for.
|
||||||
|
- **Correctness does not depend on split horizon for `public` web apps** (R-DNS-04). The ISP router
|
||||||
|
cannot hairpin NAT and advertises a public IPv6 resolver via RA, so some LAN clients will get the
|
||||||
|
public answer. That answer goes through Cloudflare and works from the LAN too.
|
||||||
|
- **PiHole** (itself an onboarded service) provides internal LAN DNS. It keeps explicit local records
|
||||||
|
for:
|
||||||
|
- the `internal` names;
|
||||||
|
- the L4 VIPs;
|
||||||
|
- the names that must avoid Cloudflare's 100 MB body / 100 s timeout limits or must work during an
|
||||||
|
internet outage: **gitea**, **nextcloud**, **keycloak/auth**.
|
||||||
|
|
||||||
|
A trusted certificate is served either way: Cloudflare's edge certificate on the tunnel path, the
|
||||||
|
Let's Encrypt wildcard on the LAN path ([08](08-tls-and-certificates.md)).
|
||||||
|
- **In-cluster** resolution keeps its own split horizon (CoreDNS) and the node `/etc/hosts` pins (OIDC
|
||||||
|
issuer, Gitea registry), so cluster-internal callers never traverse Cloudflare.
|
||||||
|
|
||||||
> DNS is thus both a platform capability *and* a hosted service. Bootstrapping order matters: the
|
> DNS is thus both a platform capability *and* a hosted service. Bootstrapping order matters: the
|
||||||
> platform needs working DNS before PiHole is fully managed — handled by the bring-up sequence in
|
> platform needs working DNS before PiHole is fully managed — handled by the bring-up sequence in
|
||||||
@@ -40,19 +58,41 @@ Internal and external answers differ:
|
|||||||
- Each service contributes an **HTTPRoute** mapping its hostname to its Service — rendered from the
|
- Each service contributes an **HTTPRoute** mapping its hostname to its Service — rendered from the
|
||||||
ServiceDefinition, not hand-authored.
|
ServiceDefinition, not hand-authored.
|
||||||
- **Exposure class** is declared per service:
|
- **Exposure class** is declared per service:
|
||||||
- `internal` — reachable only on the LAN (no edge port-forward; internal DNS only).
|
- `internal` — reachable only on the LAN/VPN (no tunnel route, no public record; internal DNS only).
|
||||||
- `public` — additionally reachable from the internet via the edge router forwarding 80/443 to the
|
- `public` — additionally reachable from the internet through the **Cloudflare Tunnel**:
|
||||||
Gateway.
|
cloudflared → the Gateway over HTTPS (ADR-0016).
|
||||||
|
- **Never `public`:** jackett, pihole, semaphore, qbittorrent, radarr, sonarr, **headlamp** (a front
|
||||||
|
end to the Kubernetes API), plex's web UI, and the non-HTTP L4 services unifi and crafty-mc.
|
||||||
|
- **The Kubernetes API is never published** — no tunnel route, no edge forward, no public record
|
||||||
|
(R-SEC-04).
|
||||||
|
|
||||||
|
## Public ingress: Cloudflare Tunnel (ADR-0016)
|
||||||
|
|
||||||
|
- **cloudflared** runs in-cluster as a Flux-managed workload and holds an **outbound-only**
|
||||||
|
connection to Cloudflare. No inbound 80/443 reaches the WAN IP.
|
||||||
|
- Its ingress rules list one route per `public` hostname → the Gateway (HTTPS, origin certificate
|
||||||
|
validated with the hostname as SNI). The rules end in a **catch-all `http_status:404`**, so an
|
||||||
|
unlisted hostname is refused at the tunnel (R-DNS-05).
|
||||||
|
- **Edge TLS** is Cloudflare ACM/Total TLS (Universal SSL does not cover two-label-deep names);
|
||||||
|
**origin TLS** is the Let's Encrypt wildcard at the Gateway ([08](08-tls-and-certificates.md)).
|
||||||
|
- **Limits and trade-offs:**
|
||||||
|
- 100 MB request bodies and 100 s responses through the proxy;
|
||||||
|
- Cloudflare terminates TLS and sees plaintext;
|
||||||
|
- a Cloudflare outage takes public access down (the LAN keeps working through Pi-hole's local
|
||||||
|
records);
|
||||||
|
- the client IP arrives as `CF-Connecting-IP` / `X-Forwarded-For`, not as the TCP source.
|
||||||
|
|
||||||
## Edge integration (single SOHO router — OQ-1 resolved)
|
## Edge integration (single SOHO router — OQ-1 resolved)
|
||||||
|
|
||||||
- The environment has **one SOHO edge router**: no VLANs, no VPN, no services, no API/IaC. Its only
|
- The environment has **one SOHO edge router**: no VLANs, no VPN, no services, no API/IaC. It
|
||||||
ingress job is **manual port-forwarding of 80/443 (and specific service ports, e.g. Plex 32400,
|
**does not forward 80/443** (public web ingress is the tunnel, ADR-0016). Its only remaining
|
||||||
Minecraft 25565) to the Gateway VIP** for `public` services. This is a documented manual edge step
|
inbound forwards are **WireGuard udp/51820** (remote access, via the DNS-only `wan.` record) and
|
||||||
(the SOHO device has no automation) — the analogue of the old pfSense manual-config exception.
|
**Plex tcp/32400** (remote streaming via plex.tv; Cloudflare's terms restrict proxied video). This
|
||||||
Confirm the router can port-forward (basic NAT; almost all can).
|
is a documented manual edge step (the SOHO device has no automation), the analogue of the old
|
||||||
|
pfSense manual-config exception. The WAN IP therefore stays public.
|
||||||
- No multi-WAN inbound rules — single WAN ([03](03-networking-isolation-and-egress.md)).
|
- No multi-WAN inbound rules — single WAN ([03](03-networking-isolation-and-egress.md)).
|
||||||
- `internal` services are never port-forwarded; they rely on internal DNS + Gateway on the LAN.
|
- `internal` services are never port-forwarded or tunnelled; they rely on internal DNS + Gateway (or
|
||||||
|
their L4 VIP) on the LAN, or WireGuard from outside.
|
||||||
- **Ingress VIP mechanism — resolved: Cilium L2 announcement (ARP/NDP)** on the flat LAN. BGP is not
|
- **Ingress VIP mechanism — resolved: Cilium L2 announcement (ARP/NDP)** on the flat LAN. BGP is not
|
||||||
available on a SOHO router, and a single flat subnet makes L2 announcement the natural choice.
|
available on a SOHO router, and a single flat subnet makes L2 announcement the natural choice.
|
||||||
|
|
||||||
@@ -62,9 +102,10 @@ A wildcard `*.keithjackson.dev` matches only **one** label deep — it covers `h
|
|||||||
but **not** `plex.home.keithjackson.dev`. Consequences:
|
but **not** `plex.home.keithjackson.dev`. Consequences:
|
||||||
- **`internal` services need no public record** — PiHole resolves `<svc>.home.keithjackson.dev` to
|
- **`internal` services need no public record** — PiHole resolves `<svc>.home.keithjackson.dev` to
|
||||||
the internal Gateway VIP (split-horizon). This is the majority of services.
|
the internal Gateway VIP (split-horizon). This is the majority of services.
|
||||||
- **`public` services** need a public record for `<svc>.home.keithjackson.dev`. Per OQ-4 (resolved),
|
- **`public` services** need a public record for `<svc>.home.keithjackson.dev`: a **proxied CNAME to
|
||||||
**ExternalDNS manages these automatically** via the scoped Cloudflare API token — no manual record
|
the tunnel** (ADR-0016). Per OQ-4 (resolved), **ExternalDNS manages these automatically** via the
|
||||||
editing (see [08](08-tls-and-certificates.md)).
|
scoped Cloudflare API token — no manual record editing (see [08](08-tls-and-certificates.md)). The
|
||||||
|
same depth limit is why edge TLS needs ACM/Total TLS rather than Universal SSL.
|
||||||
|
|
||||||
## Interaction with default-deny
|
## Interaction with default-deny
|
||||||
|
|
||||||
@@ -76,10 +117,18 @@ not let other in-cluster services reach it unless an explicit allow rule exists.
|
|||||||
- A new service's `<service>.home.keithjackson.dev` record appears automatically and routes to it.
|
- A new service's `<service>.home.keithjackson.dev` record appears automatically and routes to it.
|
||||||
- `internal` services are unreachable from the public internet; `public` services are reachable and
|
- `internal` services are unreachable from the public internet; `public` services are reachable and
|
||||||
TLS-valid.
|
TLS-valid.
|
||||||
- Internal clients resolve to the internal Gateway (no public hairpin) and get a trusted cert.
|
- Internal clients resolving through Pi-hole reach the internal Gateway for the names it holds local
|
||||||
|
records for, and get a trusted cert.
|
||||||
|
- A LAN client resolving a `public` name through a **public** resolver reaches it, TLS-valid
|
||||||
|
(R-DNS-04). The WAN IP does not answer on 80/443.
|
||||||
|
- A request at the tunnel for any hostname that is not a `public` service returns **404**, and no
|
||||||
|
`internal` service has a public DNS record (R-DNS-05).
|
||||||
|
- The Kubernetes API is unreachable from the internet: no tunnel route, no edge forward, no public
|
||||||
|
record (R-SEC-04).
|
||||||
- The root `home.keithjackson.dev` serves the C2 UI.
|
- The root `home.keithjackson.dev` serves the C2 UI.
|
||||||
|
|
||||||
## Resolved
|
## Resolved
|
||||||
- **OQ-1** — single SOHO edge (manual port-forward only); ingress VIP via **Cilium L2 announcement**.
|
- **OQ-1** — single SOHO edge; ingress VIP via **Cilium L2 announcement**. Public web ingress amended
|
||||||
|
2026-11-08 to a **Cloudflare Tunnel** (ADR-0016); the edge keeps only udp/51820 and tcp/32400.
|
||||||
- **OQ-4** — scoped **Cloudflare API token** drives ExternalDNS (records) and cert-manager DNS-01
|
- **OQ-4** — scoped **Cloudflare API token** drives ExternalDNS (records) and cert-manager DNS-01
|
||||||
(certs) automatically; see [08](08-tls-and-certificates.md).
|
(certs) automatically; see [08](08-tls-and-certificates.md).
|
||||||
@@ -19,7 +19,7 @@ kind: container | vm # PaaS (default) or IaaS via KubeVirt (R-PLAT
|
|||||||
source:
|
source:
|
||||||
image: <repo/image> # container: image ref
|
image: <repo/image> # container: image ref
|
||||||
# or vmTemplate: <image/source> # vm: CDI source image
|
# or vmTemplate: <image/source> # vm: CDI source image
|
||||||
exposure: internal | public # LAN-only vs edge port-forwarded (R-DNS/R-NET)
|
exposure: internal | public # LAN/VPN-only vs Cloudflare Tunnel (R-DNS-04/05, ADR-0016)
|
||||||
egress: internet | vpn # egress class (R-NET-05); vpn ⇒ kill-switch (R-NET-06)
|
egress: internet | vpn # egress class (R-NET-05); vpn ⇒ kill-switch (R-NET-06)
|
||||||
storage:
|
storage:
|
||||||
- name: config
|
- name: config
|
||||||
@@ -138,8 +138,10 @@ Points the schema left ambiguous, now pinned so the JSON Schema, Kyverno policie
|
|||||||
- **DR-critical data** (Nextcloud, Gitea, Minecraft worlds, PiHole config, service DBs) MUST have
|
- **DR-critical data** (Nextcloud, Gitea, Minecraft worlds, PiHole config, service DBs) MUST have
|
||||||
off-cluster backups ([04](04-storage-and-state.md)); bulk media may be `reacquirable` per operator
|
off-cluster backups ([04](04-storage-and-state.md)); bulk media may be `reacquirable` per operator
|
||||||
choice (OQ-2).
|
choice (OQ-2).
|
||||||
- **Public exposure** (Plex, Seerr, Nextcloud, Minecraft, Gitea) requires edge port-forwarding
|
- **Public exposure** (Seerr, Nextcloud, Gitea, …) is through the **Cloudflare Tunnel**
|
||||||
([09](09-dns-and-ingress.md)); everything else stays `internal`.
|
([09](09-dns-and-ingress.md), ADR-0016). Everything else stays `internal`. **Plex** remote
|
||||||
|
streaming uses plex.tv + an edge forward of tcp/32400 (not the tunnel), and its web UI is LAN-only.
|
||||||
|
**Minecraft (crafty-mc)** and other L4 services are LAN/VPN-only.
|
||||||
- **Kasm** is resource-heavy and may be a candidate for `kind: vm` if container mode proves
|
- **Kasm** is resource-heavy and may be a candidate for `kind: vm` if container mode proves
|
||||||
insufficient — the ServiceDefinition makes that a one-field change.
|
insufficient — the ServiceDefinition makes that a one-field change.
|
||||||
- **Gitea is bootstrapping-critical.** The platform self-hosts its git and CI/CD, so Gitea is brought
|
- **Gitea is bootstrapping-critical.** The platform self-hosts its git and CI/CD, so Gitea is brought
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ Each maps to requirement IDs; phrased as Given/When/Then intent.
|
|||||||
- **DNS/exposure (R-DNS-*):** *Given* a service, *When* resolved, *Then* the record exists and
|
- **DNS/exposure (R-DNS-*):** *Given* a service, *When* resolved, *Then* the record exists and
|
||||||
internal clients hit the internal Gateway; `internal` is unreachable publicly, `public` is
|
internal clients hit the internal Gateway; `internal` is unreachable publicly, `public` is
|
||||||
reachable + TLS-valid.
|
reachable + TLS-valid.
|
||||||
|
- **Public ingress path (R-DNS-04/05, R-SEC-04):** *Given* the tunnel ingress config, *When* it is
|
||||||
|
rendered, *Then* every route is a `public` service, the last rule is `http_status:404`, and no rule
|
||||||
|
targets the Kubernetes API (L0). *When* a non-`public` hostname is requested at the tunnel, *Then*
|
||||||
|
it gets 404. *When* a `public` name is resolved via a public resolver from the LAN, *Then* it is
|
||||||
|
reachable + TLS-valid (L2).
|
||||||
- **Hands-off update (R-UPD-01/02, L3):** *Given* an out-of-date service under a stable policy,
|
- **Hands-off update (R-UPD-01/02, L3):** *Given* an out-of-date service under a stable policy,
|
||||||
*When* the scan interval elapses, *Then* it advances with no human/AI edit; *Given* a deliberately
|
*When* the scan interval elapses, *Then* it advances with no human/AI edit; *Given* a deliberately
|
||||||
bad update, *Then* health-gating auto-rolls-back and an email alert fires; *Given* a pinned
|
bad update, *Then* health-gating auto-rolls-back and an email alert fires; *Given* a pinned
|
||||||
|
|||||||
Reference in new issue
Block a user