# 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: "*."`, `sans: [""]` | | App Traefik rules | catalog compose | ``Host(`${SUBDOMAIN}.${DOMAIN}`)`` | | App env | `controller/internal/stacks/manager.go:1187`, `deploy.go:560` | `DOMAIN=` | | Controller-host guard | `controller/internal/web/server.go:571` | `controllerHost := "felhom." + domain` | | Report / DR recipe | `controller/internal/report/builder.go:53` | `https://felhom.` | | 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."` 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, ~2–4k 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.` (`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 `.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 → `.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 1–5 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=; 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=` | **HTTP 200 — authenticated** | | `hub_session=BOGUS; hub_session=` | **HTTP 302 → /login — rejected** | | `hub_session=; 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.` | | 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** — `-`; 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 ""` 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` → `.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//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).