Files
felhom.eu/documentation/audits/RECON-subdomain-onboarding-2026-07-31.md
T
admin 9e079c7883 RECON: a Felhom-issued subdomain works in the product — the blocker is Cloudflare edge-cert depth
Question A: YES, no code change. customer.domain is a trimmed string with no
UNIQUE, no CHECK, no format rule (store.go:114, configs.go:673), copied verbatim
into controller.yaml (configgen.go:48), and every one of its 30 consumers on the
box interpolates it without parsing. Zero hits for registrable/eTLD/publicsuffix
across both repos. Nothing creates DNS records (zero hits for dns_records) — the
two Cloudflare clients are WAF-only. And the zone-ownership assumption is a
SWITCH, not a requirement: traefik.yml.tmpl selects DNS-01 when cf_api_token is
set and HTTP-01 when it is empty.

The real blocker is Cloudflare, proven live: the edge certificate covers exactly
one wildcard level (SAN = demo-felhom.eu, *.demo-felhom.eu), so a two-label
hostname — which a per-tester subdomain forces — gets "tls alert handshake
failure" and no peer certificate at all. That makes Advanced Certificate Manager
a prerequisite of the separate-domain plan, not an optional extra. Whether ACM is
available on the account could not be established read-only: the only Cloudflare
tokens in reach are the Zone:DNS:Edit tokens on the demo boxes, which the fence
forbids using.

Question C, measured rather than reasoned: r.Cookie returns the FIRST match and
never tries the others (BOGUS+real = 302, real+BOGUS = 200), so a tossed cookie
wins outright — DoS and confusion, not takeover, since it fails closed on
mutations. CSRF is a single choke point (server.go:256) and the token carries the
whole load against a same-registrable-domain attacker. But it is SKIPPED entirely
when no session cookie is present, which with browser-cached Basic auth is
cross-origin CSRF on every mutating route (R-135).

Agreeing with the separate-domain recommendation, with the caveat the brief asked
for: it is necessary but not sufficient. It does not solve Question D, because
that is a shared-zone problem and the new domain is a shared zone.

Filed R-133..R-138: duplicate domains accepted; hub/controller zone-resolvers
disagree on depth; CSRF skipped on the no-cookie path; __Host- rename (one line,
preconditions verified met); geo-WAF rules zone-scoped and non-namespaced (four
cross-tenant faults, blocks shared-zone onboarding); shared-zone cf_api_token is
a zone-wide DNS-write capability on a customer's box.

Nothing created: no customer, DNS record, tunnel, route or code change.
2026-07-31 09:00:11 +02:00

483 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# RECON — can a tester get a Felhom-issued subdomain instead of owning a domain? (2026-07-31)
**Read-only.** No customer, DNS record, tunnel, route or code change was created. Two hub login
sessions were created (in-memory, 7-day expiry) to test cookie precedence; nothing else was mutated.
**Headline:** the *product* accepts a Felhom-issued subdomain today with **no code change**. The
blocker is not Felhom — it is **Cloudflare's edge certificate depth**, proven live: a two-label
hostname under a zone gets `tls alert handshake failure` and **no certificate at all**. Everything
else follows from that.
---
## 1. Baselines — read fresh, none carried
| What | Value | How |
|---|---|---|
| controller `main` | **v0.188.0** | `HEAD`=`origin/main`=`4115e88`, clean |
| controller on `demo-felhom` (9201) | 0.187.0 | `pct exec 9201 -- cat /etc/felhom-controller-image` |
| controller on `demo-hp` (9201) | 0.187.0 | same, on demo-hp |
| golden-baked controller | **0.188.0** (sha `7353d8be…`) | hub `/configuration`, `selected` option |
| agent published + vouched | **0.119.0** (sha `e37aca82…`) | hub manifest; both boxes run 0.119.0 |
| hub | **0.84.0** | `deploy/hub` image tag |
| ISO | v1.25.0 (`scripts/iso/build-felhom-iso.sh:44`); host-install v1.22.0 | source read |
| `felhom.eu` HEAD | **`b4edc08`** | `git rev-parse` |
| MinAgent | 0.113.0 | hub `/configuration` |
Both the hub (0.83.0→0.84.0) and the agent (0.118.1→0.119.0) moved since this morning's tester-gate
run, which is exactly why the brief says to read rather than carry.
**Not mine, left untouched:** `hub/internal/api/testdata/host-report.golden.json` is dirty in the
shared worktree — another session's WIP. Not staged, not reverted.
---
## 2. Question A — does the product accept a Felhom-issued subdomain?
### 2.1 The domain's full path, hop by hop
| # | Hop | Citation | What happens |
|---|---|---|---|
| 1 | Operator form | `hub/internal/web/configs.go:673` | `Domain: strings.TrimSpace(r.FormValue("domain"))` — trimmed, nothing else |
| 2 | Hub DB | `hub/internal/store/store.go:114` | `domain TEXT NOT NULL DEFAULT ''`**no UNIQUE, no CHECK, no format** |
| 3 | `controller.yaml` generation | `hub/internal/configgen/configgen.go:48` | `setNested(base, []string{"customer","domain"}, cfg.Domain)` — verbatim copy |
| 4 | Box pull | `felhom-host-install.sh` `config-retrieve``/opt/docker/felhom-controller/controller.yaml` | verified present on demo-felhom |
| 5 | Controller config load | `controller/internal/config/config.go:363,379` | env override `FELHOM_CUSTOMER_DOMAIN`; **the only check is non-empty** |
| 6 | Consumers | 30 call sites (§2.3) | all plain string interpolation |
### 2.2 Constraints found — the answer is "almost none"
- **No format validation anywhere.** Grepped the hub and controller for `valid|regexp|match|check` near
`domain`: no hits on the customer domain.
- **No registrable-domain / eTLD logic anywhere.** `grep -riE 'registrable|etld|publicsuffix'` across
both repos: **zero hits**. Nothing computes "the registrable part" of a customer domain.
- **No "must not be a subdomain" rule.** None exists.
- **No uniqueness on domain.** `hub/internal/web/configs.go:644` rejects a duplicate **`customer_id`**
only. Two customers may be given the identical domain string with no complaint (→ **R-133**).
- **The only permissive-pattern check** is the agent's `internal/lanresolver/lanresolver.go:225`
`domainRe = ^[ \t]*domain:[ \t]*"?([A-Za-z0-9.-]+)"?` — dots allowed, so a subdomain parses fine.
### 2.3 What consumes it on the box — all interpolation, no parsing
| Surface | Citation | Shape |
|---|---|---|
| Controller Traefik route | `controller/internal/infra/infra.go:214` | ``Host(`felhom.%s`)`` |
| Wildcard TLS SAN | same, `:217-223` | `main: "*.<domain>"`, `sans: ["<domain>"]` |
| App Traefik rules | catalog compose | ``Host(`${SUBDOMAIN}.${DOMAIN}`)`` |
| App env | `controller/internal/stacks/manager.go:1187`, `deploy.go:560` | `DOMAIN=<domain>` |
| Controller-host guard | `controller/internal/web/server.go:571` | `controllerHost := "felhom." + domain` |
| Report / DR recipe | `controller/internal/report/builder.go:53` | `https://felhom.<domain>` |
| FileBrowser | `controller/internal/stacks/infra.go:157` | domain passed to compose render |
| Share launcher, escrow, export, claim, data-path cards | 9 further sites | template data only |
| SMB | `controller/internal/infra/samba.go:65` | NetBIOS name from `smb.EffectiveServerName()` — **not the domain** |
**None of these parse the domain.** A dotted subdomain flows through every one of them unchanged.
### 2.4 Does anything assume the customer owns the zone? — Yes, but it is a *switch*, not a requirement
This is the question the brief flagged, and the answer is better than feared. The ACME challenge is
**config-selected** at `controller/internal/infra/templates/traefik.yml.tmpl`:
```yaml
certificatesResolvers:
letsencrypt:
acme:
email: {{.ACMEEmail}}
{{- if .CFAPIToken}}
dnsChallenge:
provider: cloudflare
{{- else}}
httpChallenge:
entryPoint: web
{{- end}}
```
- **With `cf_api_token`** → DNS-01, which **does** need `Zone:DNS:Edit` for that zone, and the token is
written to a 0600 `.env` on the box (`controller/internal/infra/infra.go:123`).
- **Without it** → **HTTP-01**, which needs no DNS credential at all.
The wildcard SAN is gated on the same flag: `controller/internal/stacks/infra.go:211` —
`wildcardTLS := CFAPIToken != "" && Customer.Email != ""`.
**Live on demo-felhom:** DNS-01 is what actually runs — `traefik.yml` shows
`dnsChallenge: provider: cloudflare`, a 0600 `.env` exists, `cloudflared` is up, and the dynamic route
carries `main: "*.demo-felhom.eu"`. So both demo boxes hold a zone-write Cloudflare token **because
they own their zones**, not because the code demands one.
### 2.5 Does anything create DNS records automatically? — No
`grep -rn 'dns_records'` across both repos: **zero hits**. Neither the hub nor the controller ever
creates, updates or deletes a DNS record. The two Cloudflare clients that exist are **WAF-only**:
- `hub/internal/cloudflare/unblock.go` — deletes `[felhom-geo]` WAF rules.
- `controller/internal/cloudflare/{waf,geosync,zone}.go` — reconciles `[felhom-geo]` WAF rules.
The cloudflared template says it outright: *"Routes are configured in the Cloudflare dashboard
(Zero Trust > Networks > Tunnels > Public Hostname)"*. **All DNS and tunnel routing is manual operator
work today.**
### 2.6 Verdict on Question A
> **YES — the product accepts a Felhom-issued subdomain today, with no code change.**
`customer.domain = "t1.<felhom-owned-zone>"` flows through the hub, `controller.yaml`, Traefik, the
tunnel, app env and every URL builder without touching a single constraint. Nothing needs DNS API
credentials **if** `cf_api_token` is left empty (HTTP-01), and nothing creates DNS records either way.
**The blocker is elsewhere** — see Question B.4. Two code-level caveats that are not blockers:
- **Zone-resolution depth asymmetry (→ R-134).** The controller's `GetZoneID`
(`controller/internal/cloudflare/zone.go:18`) strips labels **progressively** (any depth). The hub's
`resolveZone` (`hub/internal/cloudflare/unblock.go:115`) tries the exact name and then
`parentDomain` — which strips exactly **one** label (`:136`). For `t1.felhomcust.hu` both succeed;
for a two-label customer domain the hub silently fails to find the zone while the controller
succeeds. Two implementations of one concept, disagreeing.
- **Geo-restriction must stay OFF** for shared-zone customers — Question D, and it is severe.
---
## 3. Question B — Cloudflare routing
### 3.1 How the demo boxes reach the internet
**One tunnel per customer, created by hand.** `cf_tunnel_token` is a per-customer field the operator
pastes into the hub (`hub/internal/web/configs.go:1338`), stored in `config_json.infrastructure`,
delivered in `controller.yaml`, and passed straight to `cloudflared` as `TUNNEL_TOKEN`
(`controller/internal/infra/templates/cloudflared-compose.yml.tmpl`). If it is empty, cloudflared is
simply not deployed — `controller/internal/stacks/infra.go:67` logs *"LAN-only node"*. The token **is**
the entire config; there is no `config.yml`, and public hostnames live only in the dashboard.
### 3.2 The live DNS picture
```
felhom.eu A 37.191.56.193 (NOT proxied)
hub.felhom.eu CNAME dooplex.hopto.org → (NOT proxied — no cf-ray, no server: cloudflare)
files.felhom.eu CNAME dooplex.hopto.org
ep0.felhom.eu A 167.233.158.164
demo-felhom.eu (apex: no A)
felhom.demo-felhom.eu 188.114.97.0 / 188.114.96.0 (Cloudflare proxy)
poll.demo-felhom.eu 188.114.97.0 / 188.114.96.0 (Cloudflare proxy)
felhom.enkisfelhom.hu 188.114.96.0 / 188.114.97.0 (Cloudflare proxy)
```
All four zones (`felhom.eu`, `demo-felhom.eu`, `enkisfelhom.hu`, `sajatfelhom.hu`) sit on the **same
Cloudflare nameservers** (`martha`/`tony.ns.cloudflare.com`) — one account.
**Two facts that matter, both established rather than assumed:**
1. **`felhom.eu` is NOT behind Cloudflare.** `hub.felhom.eu` and `felhom.eu` return no `cf-ray` and no
`server: cloudflare`; they resolve through a DDNS name to a home IP and are served by DooPlex's k3s
nginx ingress. The hub is *not* protected by, or reachable through, the tunnel fabric.
2. **There is no `*.felhom.eu` wildcard today.** A random label under `felhom.eu` resolves to
**nothing**; the same probe under `demo-felhom.eu` and `enkisfelhom.hu` returns Cloudflare proxy
IPs. So the cookie exposure in Question C is **latent, not live** — it becomes live the moment a
tester box is put under `felhom.eu`.
### 3.3 Proxied wildcards — verified, not remembered
The brief warns against trusting a remembered plan restriction. **Checked empirically:** a random
label under a customer zone resolves to Cloudflare **proxy** anycast IPs and returns a real HTTP
response with `server: cloudflare` and a `cf-ray`. So a **proxied wildcard DNS record works on this
account today** — the historical "wildcard proxying is Enterprise-only" restriction does not bind here.
Two wildcards in the same account already route to **two different tunnels** (each box runs its own
cloudflared with its own token). Distinct wildcard records at distinct names
(`*.t1.zone` → tunnel A, `*.t2.zone` → tunnel B) are ordinary DNS with no conflict.
### 3.4 The actual blocker — edge certificate depth
**Proven live, and this is the finding that decides the shape of the whole plan.**
The Cloudflare edge certificate for a zone covers **exactly one wildcard level**:
```
$ openssl s_client -connect poll.demo-felhom.eu:443 -servername poll.demo-felhom.eu
subject=CN=demo-felhom.eu
issuer=C=US, O=Google Trust Services, CN=WE1
X509v3 Subject Alternative Name:
DNS:demo-felhom.eu, DNS:*.demo-felhom.eu
```
A **two-label** hostname resolves — the wildcard DNS record matches at any depth — but the TLS
handshake is refused outright:
```
$ openssl s_client -connect zz.poll.demo-felhom.eu:443 -servername zz.poll.demo-felhom.eu
CONNECTED(00000003)
ssl3_read_bytes:tls alert handshake failure ... SSL alert number 40
no peer certificate available
$ curl https://zz.poll.demo-felhom.eu/
curl: (35) TLS connect error: ... tls alert handshake failure
$ curl https://poll.demo-felhom.eu/ # one-level control
HTTP 404 # TLS fine; just no tunnel route for a random name
```
**Consequence.** A Felhom-issued subdomain means `customer.domain = t1.felhomcust.hu`, so the
controller lives at `felhom.t1.felhomcust.hu` and apps at `poll.t1.felhomcust.hu` — **two labels under
the zone**. Under Universal SSL those hostnames get **no certificate**, and the tester's browser gets a
hard TLS error before anything Felhom-related runs.
Options, in order of how much I can vouch for them:
| Option | Cost | Status |
|---|---|---|
| **Cloudflare Advanced Certificate Manager / Total TLS** — issue `*.t1.felhomcust.hu` at the edge | paid add-on (~$10/mo, one zone) | **Could not verify read-only** — needs the dashboard or a token I am fenced from using. **This is the single thing to check before committing to the plan.** |
| **One zone per tester** | one domain per tester, ~24k HUF/yr each | Works today, no code change — but it is the cost the plan exists to avoid |
| **Flat naming** (`poll-t1.felhomcust.hu`) | — | **Does not work without code change.** The controller hard-derives `felhom.<domain>` (`infra.go:214`, `web/server.go:571`); every tester's controller would want the identical `felhom.felhomcust.hu` |
| **Grey-cloud + DNS-01 wildcard on the box** | — | Traefik can issue `*.t1.felhomcust.hu` itself via DNS-01, and the browser would see *that* cert — but grey-cloud means **no tunnel**, so the box needs a public IP and port-forwarding. Loses the reason the tunnel exists |
### 3.5 What must exist for one tester box — the create-list
Assuming a Felhom-owned zone `felhomcust.hu` already on Cloudflare, and ACM/Total TLS confirmed:
| # | Where | Action |
|---|---|---|
| 1 | Cloudflare → zone `felhomcust.hu` → SSL/TLS → Edge Certificates | Enable **Total TLS / ACM** so depth-2 hostnames get a cert. **Verify first — §3.4.** |
| 2 | Cloudflare → Zero Trust → Networks → Tunnels | **Create tunnel** `felhom-t1`. Copy its token (this is the `cf_tunnel_token`) |
| 3 | Same tunnel → **Public Hostname** | `felhom.t1.felhomcust.hu` → `https://traefik:443` (or per the existing demo tunnels' pattern — **I could not read those routes, §8**) |
| 4 | Same tunnel → Public Hostname, one per app | e.g. `poll.t1.felhomcust.hu` → same origin. Cloudflare creates the proxied CNAME to `<tunnel>.cfargotunnel.com` automatically when you add a public hostname |
| 5 | Cloudflare → DNS (only if you prefer a wildcard over per-app entries) | `*.t1.felhomcust.hu` CNAME → `<tunnel-id>.cfargotunnel.com`, **proxied**. Verified workable in §3.3 |
| 6 | Hub → Customers → + Add Customer | `customer_id` = `t1`; `domain` = **`t1.felhomcust.hu`**; `email` = **the tester's real mailbox** (see §6); DR tier / offsite per policy |
| 7 | Hub → the customer's Edit tab | Paste `cf_tunnel_token` from step 2. **Leave `cf_api_token` EMPTY** — §2.4 (HTTP-01), §5 (a shared-zone token is a cross-tenant write capability) |
| 8 | — | **Do not enable geo-restriction** for this customer. §5. |
No DNS record and no tunnel is created by Felhom; steps 15 are entirely operator work in the
Cloudflare dashboard.
---
## 4. Question C — cookies, CSRF, and the domain recommendation
### 4.1 The cookie, reconfirmed at source and on the wire
`hub/internal/web/server.go:656-663` sets `hub_session` with `Path: "/"`, `HttpOnly: true`,
`SameSite: SameSiteLaxMode`, `Secure: isSecure`, **no `Domain`**, **no `__Host-` prefix** — as the
brief states. Confirmed on the live production path:
```
set-cookie: hub_session=<REDACTED>; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax
```
`isSecure` is computed from `r.TLS != nil || X-Forwarded-Proto == "https"`. Over the k3s ingress that
holds; over the plain-HTTP ClusterIP the same login returns the cookie **without `Secure`**.
### 4.2 What the hub does with two same-named cookies — tested, not reasoned
Against the live hub, `GET /configuration`, varying only the `Cookie:` header:
| Cookie header | Result |
|---|---|
| `hub_session=<real>` | **HTTP 200 — authenticated** |
| `hub_session=BOGUS; hub_session=<real>` | **HTTP 302 → /login — rejected** |
| `hub_session=<real>; hub_session=BOGUS` | **HTTP 200 — authenticated** |
| `hub_session=BOGUS` | HTTP 302 → /login |
**`r.Cookie("hub_session")` returns the FIRST match in header order and never tries the others.** A
tossed cookie that sorts first therefore wins outright, and the real session is silently ignored.
**Exploitability, stated precisely.** Per RFC 6265 §5.4 a browser orders cookies **longer `Path`
first**. Any page on any `*.felhom.eu` host can set
`hub_session=X; Domain=felhom.eu; Path=/configuration` — a longer path than the real `Path=/` cookie —
so the browser sends the attacker's value first for every `/configuration*` request.
What that does and does not buy an attacker:
- **It is not session takeover.** The attacker cannot mint a value present in the hub's in-memory
`sessions` map, so the tossed cookie never authenticates.
- **It is a persistent, hard-to-diagnose denial of service** against the operator UI, on paths of the
attacker's choosing, invisible in the UI and not clearable from the hub.
- **It fails closed on mutations.** `csrfToken(r)` (`server.go:698`) reads the same first cookie and
returns `""`, and `validateCSRF` (`:678`) returns false for an unknown session → 403.
### 4.3 What the CSRF defence actually rests on
**A single choke point, and it is the token.** `hub/internal/web/server.go:256-269` gates *every*
non-GET/HEAD/OPTIONS web route before the route switch, exempting only `/login`, `isPublicBindPath`,
and the no-password-configured case. `/api/v1/` is Bearer-authenticated and separately exempt. So the
token is enforced on every mutating web route — the brief's worry that it "would be carrying the whole
load" is correct, and it does carry it.
Measured, using `POST /zz-recon-nonexistent` so the CSRF gate is isolated with **zero** mutation
(the middleware at `:256` runs before the route switch at `:271`; a 404 therefore means "past the
gate"):
| Request | Result |
|---|---|
| session cookie, **no** `_csrf` | **403 — CSRF enforced** |
| session cookie, valid `_csrf` | 404 — past the gate |
| **Basic auth, no cookie, no `_csrf`** | **404 — CSRF SKIPPED** |
| Basic auth + bogus cookie, no `_csrf` | 403 — CSRF enforced |
The third row is `validateCSRF`'s documented early return: *"No session cookie — likely Basic Auth or
programmatic access; skip CSRF"* (`server.go:679-683`).
**Why that is a real hole and not just an API convenience (→ R-135).** Browsers cache HTTP Basic
credentials per origin and **resend them automatically, including on cross-origin requests**, and
`SameSite` does not govern the `Authorization` header. So if the operator has ever authenticated to
`hub.felhom.eu` with Basic auth in their browser, an attacker page anywhere — not merely same-site —
can POST to any mutating hub route: the credentials ride along, auth passes, and CSRF is skipped
because there is no cookie. The condition ("operator used Basic auth in a browser") is what makes this
latent rather than live, and it is a condition, not a guarantee.
*Note on `SameSite=Lax`:* against a genuinely cross-site attacker it already blocks the cookie, so the
cookie path is safe there. Against a **same-registrable-domain** attacker (`tester.felhom.eu` →
`hub.felhom.eu`) `Lax` provides **no boundary at all**, and the CSRF token is the only thing left.
### 4.4 Should customer boxes live under `felhom.eu`? — No. And that is necessary, not sufficient.
**I agree with the recommendation, for the stated reason, and it is cheap.** A different registrable
domain makes tossing structurally impossible, restores `SameSite` as a real boundary, and permanently
answers "the tester owns no domain" for every future customer at one domain's annual cost.
**But my findings say it does not finish the job, and the brief asked to be told if so:**
1. **Question D is not solved by moving zones** — it is a *shared-zone* problem, and the new domain is
a shared zone. The geo-WAF collisions in §5 apply identically there.
2. **The nesting requirement collides with edge-certificate depth** (§3.4). The separate domain
*forces* two-label hostnames, which is precisely what Universal SSL will not serve. **The separate
domain makes ACM a prerequisite, not an optional extra.** That is a real, ongoing cost the
recommendation should carry openly.
So: **adopt the separate registrable domain, and treat Cloudflare ACM/Total TLS on that zone as part of
its price.** If ACM turns out to be unavailable or unexpectedly expensive, the honest fallback is one
cheap zone per tester — which keeps every property except the "one domain forever" economics.
**Independently, and regardless:** rename the cookie to `__Host-hub_session`. Verified on the live
production response that all three prefix preconditions already hold (`Secure`, `Path=/`, no `Domain`),
so it genuinely is a one-line change — with **one caveat worth writing into the ticket**: a browser
rejects a `__Host-` cookie that lacks `Secure`, and `isSecure` is conditional, so any plain-HTTP
browser access to the hub would stop working. Non-browser access uses Basic auth and is unaffected.
Filed as **R-136**; **not implemented here.**
---
## 5. Question D — what breaks with many customers in one zone
**What I checked, including what came back clean:**
| Surface | Checked | Result |
|---|---|---|
| Hub uniqueness on `domain` | `store.go:114`, `configs.go:644` | **No constraint** — duplicate domains accepted silently (**R-133**) |
| App subdomain collisions | catalog `Host(${SUBDOMAIN}.${DOMAIN})` | **Clean** if each tester has their own label — `poll.t1.z` vs `poll.t2.z` |
| Controller hostname | `infra.go:214`, `web/server.go:571` | **Clean with nesting**, fatal with flat naming — every box wants `felhom.<domain>` |
| SMB / NetBIOS names | `controller/internal/infra/samba.go:63-65` | **Clean** — `workgroup = WORKGROUP` fixed, NetBIOS from settings, not the domain. LAN-scoped anyway |
| `storage.cfg` / PVE storage names | agent `internal/storage/` | **Clean** — no domain-derived names found |
| Host identity | hub log `sess-g-e687c3` | **Clean** — `<customer_id>-<random>`; the agent never reads `customer.domain` except in `lanresolver` |
| Tunnel naming | dashboard-only, token is the whole config | **Clean** — tunnels are independent objects; no Felhom-side naming |
| LE certificate rate limits | — | **Not a factor** while the edge terminates TLS; becomes one only in the grey-cloud/DNS-01 topology |
| LAN split-horizon DNS | agent `internal/lanresolver/lanresolver.go:6-13` | **Scoped correctly with nesting** (`local=/t1.felhomcust.hu/`); would hijack the **entire zone** on that LAN if a customer's domain were the bare zone — another reason flat naming is wrong |
| **Cloudflare geo-WAF rules** | `controller/internal/cloudflare/{waf,geosync}.go` | **BROKEN — four distinct cross-tenant faults (R-137)** |
### 5.1 The geo-WAF collision — the one real breakage
The rules are written at **zone** scope with **non-namespaced** identities:
- `waf.go:18` — `globalRuleDesc = "[felhom-geo] Global"` — one literal description for the whole zone.
- `waf.go:21` — `appRuleDescPrefix = "[felhom-geo] app:"` + the **app name**, with no customer in the key.
- `waf.go:241` — `BuildGlobalExpression` returns `(not ip.src.country in {...})` plus `http.host ne`
*exclusions* only. **There is no positive hostname scoping**: the rule matches every hostname in the
zone.
- `geosync.go:320` — `applyDiff` **deletes** every existing `[felhom-geo]` rule not in *this box's*
desired set.
With two customers in one zone and geo-restriction enabled by either:
1. **They overwrite each other forever.** Both want a rule described `[felhom-geo] Global` with
different expressions → each sync sees "expression changed" → `UpdateRule` → flip-flop on every tick.
2. **One customer's country policy applies to everyone** in the zone, because the expression is not
hostname-scoped.
3. **Per-app rules collide by name** — two testers running `rallly` both produce
`[felhom-geo] app:rallly`.
4. **Disabling the feature for one customer wipes them all** — `deleteAllRules` (`geosync.go:167`), and
the hub's operator "unblock" (`hub/internal/cloudflare/unblock.go:22` `RemoveGeoRules`) deletes
**all** `[felhom-geo]` rules in the resolved zone.
**Mitigation for tester #1 (no code):** leave geo-restriction **off** for every shared-zone customer.
**Fix (code, R-137):** namespace the descriptions by `customer_id` and add a positive
`http.host ends_with "<customer domain>"` term to both expressions. That is ~4 functions across
`waf.go`/`geosync.go` plus the hub's `RemoveGeoRules` — a **two-repo change**, i.e. exactly the class
that `dr-recipe-three-producers-hub-allowlist` warns about. Small-to-medium, not trivial.
### 5.2 The shared-zone credential problem
If a shared-zone customer is ever given a `cf_api_token` (for DNS-01 or geo), that token is
`Zone:DNS:Edit` **for the whole shared zone**, written to a 0600 `.env` on a box in a stranger's home
(`controller/internal/infra/infra.go:123`). One compromised tester box could repoint **every other
tester's** DNS. Today each box holds a token only for a zone the operator owns and no one shares, so
this is new exposure created by the shared-zone plan. **Keep `cf_api_token` empty for shared-zone
customers** — which §2.4 shows is supported (HTTP-01). Recorded as part of **R-138**.
---
## 6. The operator's step list for tester #1
Ordered, with the prerequisites that bite if taken out of order.
1. **Choose and register the customer domain.** One registrable domain for all customers, e.g.
`felhomcust.hu`. **Not a subdomain of `felhom.eu`** — §4.4.
2. **Add the zone to Cloudflare** (same account) and point the registrar's nameservers at it.
3. **Verify ACM / Total TLS on that zone and enable it.** §3.4 — *do this before promising a tester a
date*; without it every hostname the tester touches fails TLS outright. If ACM is not viable,
fall back to one zone per tester and stop here.
4. **Create the tunnel** `felhom-t1` in Zero Trust → Networks → Tunnels. Keep the token; it is
`cf_tunnel_token`.
5. **Add the public hostnames** on that tunnel: `felhom.t1.felhomcust.hu` plus one per app the tester
will run — or a single proxied wildcard `*.t1.felhomcust.hu` → `<tunnel-id>.cfargotunnel.com`.
6. **Create the customer in the hub** — `customer_id` `t1`, `domain` **`t1.felhomcust.hu`**, and
**`email` = the tester's own working mailbox**.
> **R-119 prerequisite — this is the step that strands a tester.** The dashboard claim code is
> generated hub-side, bcrypt-hashed, and **emailed only**; there is no operator-side route to the
> plaintext. It is sent when the customer record is created and again on
> `POST /configs/<id>/claim-resend`. **The tester's mailbox must be on the record before their box
> boots**, or the box installs perfectly and nobody can claim it. Confirmed live in this morning's
> tester-gate run, where the operator had to relay the code by hand.
7. **Paste `cf_tunnel_token`** into the customer's Edit tab. **Leave `cf_api_token` empty** (§2.4, §5.2).
8. **Do not enable geo-restriction** for this customer (§5.1).
9. **Boot the box** from the v1.25.0 generic ISO. It self-registers as an unclaimed appliance; bind it
to `t1` on the hub's Hosts page; the credentials are delivered once and day-0 completes unattended.
10. **The tester claims the dashboard** with the emailed code and sets their own password.
11. **Sanity-check TLS first, before the tester does:** open `https://felhom.t1.felhomcust.hu`. If it
fails with a handshake error rather than a Felhom page, step 3 did not take.
---
## 7. `R-n` rows opened
Registered in `documentation/backlog/OPEN-ITEMS.md`; none implemented.
| ID | What | Size |
|---|---|---|
| **R-133** | Hub enforces uniqueness on `customer_id` only — two customers can be given the same `domain` with no complaint | XS |
| **R-134** | Zone-resolution depth asymmetry: controller strips labels progressively, hub strips exactly one (`unblock.go:136`) | XS |
| **R-135** | `validateCSRF` returns **true** when no session cookie is present — with browser-cached Basic auth this is cross-origin CSRF on every mutating hub route | **S, security** |
| **R-136** | Rename `hub_session` → `__Host-hub_session` (all preconditions verified met on the production path) | XS, one line |
| **R-137** | Cloudflare geo-WAF rules are zone-scoped and non-namespaced — four cross-tenant faults; blocks geo-restriction for any shared zone | M, two-repo |
| **R-138** | A shared-zone `cf_api_token` is a zone-wide DNS-write capability sitting on a customer's box — policy + a guard | S |
---
## 8. What could not be established read-only
- **Whether Cloudflare ACM / Total TLS is available or enabled on this account.** This is the single
decision-critical unknown (§3.4). It needs the Cloudflare dashboard or an API token. **The only
Cloudflare tokens in reach are the `Zone:DNS:Edit` tokens in `/opt/docker/stacks/traefik/.env` on the
demo boxes — those can WRITE, so per the fence I did not use them.** The hub's own namespace holds no
Cloudflare credential at all (checked: 14 secrets, none Cloudflare-related). **Operator action: read
it off the dashboard.**
- **The existing tunnels' public-hostname route tables** — same reason. So step 5 of §3.5 names the
shape but not the exact origin URL the demo tunnels use; copy it from an existing tunnel.
- **Whether Cloudflare passes HTTP-01 `/.well-known/acme-challenge/` through a proxied hostname to the
tunnel origin.** Relevant only if `cf_api_token` is left empty, which §3.5 step 7 recommends. Would
need a live issuance attempt on a real hostname — i.e. tester #1's first boot. **If it fails, the
fallback is to set `cf_api_token` and accept §5.2's exposure, or rely on the edge cert alone.**
- **Browser Basic-auth resend behaviour** underlying R-135 is stated from the specification, not
measured here; a browser is needed and `claude-in-chrome` is unavailable on DooPlex.
## 9. What was not done
No customer, DNS record, tunnel, route or code change was created. No demo box, `drill-r50`, guest 9201
or Peti's cluster was touched — the only writes anywhere were two hub login sessions (in-memory, 7-day
expiry, tokens shredded) and this document plus the six register rows. `curl -w '%{redirect_url}'` was
not used (R-132).