From 11b062cd3a6bc9d0c18cb1fb19469c0841888cf8 Mon Sep 17 00:00:00 2001 From: codad5 Date: Sat, 11 Jul 2026 16:00:10 +0100 Subject: [PATCH 01/37] docs: rewrite dynamic-domains-tls.md as the full-scope final plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidates what were three separate docs (the architecture doc, an uncommitted setup-guide draft, and an uncommitted trimmed-scope draft) into one canonical file, correcting course on two things: - Drops the earlier self-hosted-DNS-server idea for answering delegated team-domain challenges -- unnecessary. A team's _acme-challenge CNAME can point at an ordinary subdomain of a zone we already manage via a normal DNS provider API (Cloudflare), no custom nameserver needed. - Restores full scope (team custom domain automation included) rather than the previous trimmed/parked version -- the concrete near-term goal is onboarding a team's domain (raba.resurgee.xyz) onto this instance, which the trimmed version would have blocked on. Adds: the anchor-host DNS pattern, the apex-vs-subdomain CNAME rule, full issuance sequence with a real example, cert storage/serving design (disk + DashMap + ResolvesServerCert), the RABA_HTTPS_PORT 443->7222 decision (one variable, no Docker/bare-metal split), and a full §8 task breakdown to work from. Co-Authored-By: Claude Sonnet 5 --- docs/plan/dynamic-domains-tls.md | 603 ++++++++++++++++++------------- 1 file changed, 342 insertions(+), 261 deletions(-) diff --git a/docs/plan/dynamic-domains-tls.md b/docs/plan/dynamic-domains-tls.md index 3d98c78..ea6f0ac 100644 --- a/docs/plan/dynamic-domains-tls.md +++ b/docs/plan/dynamic-domains-tls.md @@ -1,282 +1,363 @@ -# Dynamic Domains & TLS Certificates — Design Plan +# Dynamic Domains & TLS Certificates — Full Plan -Status: **step 1 done, steps 2-4 not started** (see "Rollout plan" at the bottom). -Drafted 2026-07-11, while deploying the first real instance (`tunnel.codad5.me`) surfaced -the full cert story ahead of Phase 10. +Status: **step 1 (manual cert loading) done. Everything below is the design + task +breakdown for full scope — not started, not approved for implementation yet.** + +Supersedes an earlier trimmed/parked version of this doc — decided 2026-07-11 that +full scope (including team custom domain automation) is the actual goal, not a +deferred nice-to-have, because the concrete near-term plan is to onboard a team +(`raba.resurgee.xyz`) onto this instance. Distinction worth keeping in mind: the +instance operator's own domain is already usable *today* via the manual +`RABA_TLS_CERT_PATH`/`certbot` path shipped earlier — what this doc's automation adds +is the ability to onboard *other people's* domains without manually running certbot on +their behalf. Extends `TECH_DOC.md` §3/§7/§11 and **supersedes the "TLS impact" section of -[`multi-tenancy.md`](./multi-tenancy.md)** — that section proposed one wildcard cert per -team custom domain via DNS-01 (team pastes a TXT record); this doc replaces that with -per-exact-hostname on-demand issuance, for reasons argued below. The rest of -multi-tenancy.md (schema, roles, routing) is unaffected and still authoritative. +[`multi-tenancy.md`](./multi-tenancy.md)**. The rest of multi-tenancy.md (schema, +roles, routing) is unaffected and still authoritative. + +--- -## The core principle (unchanged, reaffirmed) +## 1. The core principle (unchanged, reaffirmed) `TECH_DOC.md` §1: *"No nginx — TLS termination, subdomain routing, and static asset -serving all happen inside the Rust binary itself."* Every option evaluated here was -measured against that. The recommendation keeps it fully intact: **raba terminates its -own TLS, on every deployment shape (Docker or bare-metal install script), and never -requires nginx/certbot on the host or in the image.** +serving all happen inside the Rust binary itself."* Every mechanism below keeps this +intact: **raba terminates its own TLS, on every deployment shape, and never requires +nginx/certbot on the host or in the image** as a hard dependency. An optional +passthrough proxy remains available for the one edge case that genuinely needs it +(port `:443` already owned by something else) — covered in §6. + +--- + +## 2. DNS fundamentals (read this before anything else) + +### 2.1 Two independent jobs share the same domain -### Alternatives considered and rejected (2026-07-11) +| | Domain Pointing (routing) | Cert / TLS (trust) | +|---|---|---| +| Question answered | "Which server does this hostname reach?" | "Is this connection trusted + encrypted?" | +| Record type | `A` or `CNAME` | `TXT` (only during the proof step) | +| Checked by | Every DNS resolver, always | Only the CA (Let's Encrypt), only at issuance/renewal | +| Set up | Once, rarely changes | Automated, repeats every ~60-90 days forever | +| If missing | Site unreachable, period | Site reachable, but untrusted/no HTTPS | + +Routing must work before cert proof is even attempted — you can't prove ownership to +traffic that can't reach the server yet. + +### 2.2 The apex-vs-subdomain rule (a real DNS protocol rule, not a raba quirk) -| Alternative | Verdict | Why | +A zone's bare/apex name (`codad5.me` itself, no subdomain) can **never** hold a +`CNAME` — DNS requires the apex to also carry `NS`/`SOA` records, and a name with a +`CNAME` must have no other records. So: + +| Hostname requested | Apex or subdomain? | Record type forced | |---|---|---| -| Bundle nginx + certbot **inside** the Docker image; raba becomes a plain-HTTP backend | **Rejected** | Reverses the §1 design principle that's part of the product pitch (single binary, single image). Multi-process containers (nginx + certbot timer + raba under a supervisor) are an operational anti-pattern. And it doesn't even cover the need: TCP/UDP project listeners (dynamic port range) and the tunnel port speak raw/custom protocols nginx can't meaningfully front or be dynamically reconfigured for — raba must own those listeners natively regardless, so the bundle would add a second TLS/routing system without removing the first. | -| Require the host's own reverse proxy (nginx/Caddy/Traefik) to terminate TLS and forward plain HTTP to raba | **Rejected as a requirement** | Assumes every self-hoster runs a specific proxy; breaks the "one Docker image and you're live" story; the host proxy would need every tunneled hostname's cert, putting us right back at the dynamic-cert problem but now in software we don't control. Also does nothing for TCP/UDP/tunnel ports (same reason as above). | -| Host proxy as **SNI passthrough** (routes still-encrypted bytes by hostname, raba still terminates TLS) | **Accepted — but as a documented optional deployment topology, not architecture** | For hosts whose `:443` is already owned by another app's proxy. nginx `stream`+`ssl_preread` (Caddy/HAProxy equivalents exist) forwards unknown-SNI traffic to raba's port encrypted; raba's own cert handling works unchanged. Zero code impact — this is a docs deliverable (step 2 below). | -| In-process ACME (the Caddy model): raba issues/renews its own certs via ACME libraries | **Accepted — this is the plan** | Cert issuance is a solved library problem in Rust (`rustls-acme`, `instant-acme`); Caddy proved the model at scale. It's the only option that works identically for Docker and the bare-metal install script, keeps the §1 principle, and composes with the SNI resolver multi-tenancy.md already anticipated. | +| `codad5.me` | Apex | `A` only | +| `raba.codad5.me` | Subdomain | `CNAME` (recommended — see §3) | +| `acme-corp.com` | Apex | `A` only | +| `raba.resurgee.xyz` | Subdomain | `CNAME` (recommended) | -## Background: ACME challenges, in one table +Default rule of thumb: **subdomain → CNAME, apex → A.** Some providers offer +non-standard `ALIAS`/`ANAME`/"CNAME flattening" to fake CNAME-at-apex behavior — provider-specific, don't assume every operator's provider supports it. -A CA (Let's Encrypt) only issues a cert after the requester proves control of the name, -via one of three challenge types. Which challenge is available dictates the whole design: +### 2.3 ACME challenge types — why DNS-01, and only DNS-01, gets used here -| Challenge | Proof | Wildcards? | Requires | +| Challenge | Proof mechanism | Wildcards? | Requires | |---|---|---|---| -| HTTP-01 | CA fetches `http:///.well-known/acme-challenge/` from the server the domain resolves to | No | Domain's DNS already points at us; port **80** reachable | -| TLS-ALPN-01 | Same idea, token presented inside a special TLS handshake | No | Domain's DNS already points at us; port **443** reachable | -| DNS-01 | Requester creates a TXT record `_acme-challenge.` | **Yes — the only one** | Ability to create records in the domain's DNS zone: manual paste every ~60-90 days, or DNS-provider API credentials | - -Two structural facts fall out of this: - -1. **Wildcard ⇒ DNS-01.** For a domain *we* control (the instance's own) this is a - one-time setup cost. For a team's custom domain it traditionally means either the - team hands over DNS-provider API credentials, or manually re-pastes a TXT record - every ~60-90 days forever — both bad. (A third option, **CNAME delegation** — - `_acme-challenge.customer-domain` CNAMEs once to a label under a zone *we* control, - so every future DNS-01 challenge is answered by us, no further customer action ever - — avoids both of those specifically. See "Alternative considered" below; it's a real - option, just not the one this doc picks, and for a different reason than "customers - won't cooperate.") -2. **HTTP-01/TLS-ALPN-01 need nothing beyond what a tunneling product already - requires** — the domain pointing at our server is the precondition for any traffic - arriving at all. If the tunnel can work, the challenge can pass. - -Also relevant: Let's Encrypt rate-limits ~50 new certificates per week **per registered -domain**. Per-hostname issuance under *our own* domain (`a.tunnel.codad5.me`, -`b.tunnel.codad5.me`, ...) would burn through that on a busy instance; per-hostname -issuance under *each customer's* domain draws from that customer's own, separate budget -(and a team realistically has a handful of projects). - -## The key decision: per-exact-hostname certs for team domains - -multi-tenancy.md's "TLS impact" section assumed one wildcard cert per team custom domain -(`*.acme-corp.com`), which forces DNS-01 and — in its original form (team pastes their -own TXT record) — either recurring manual work or handing over DNS API credentials. -**Superseded by:** - -- **Instance's own domain: one wildcard cert** (`tunnel.codad5.me` + `*.tunnel.codad5.me`), - via DNS-01. Justified because (a) unbounded subdomain creation under it would hit the - per-registered-domain rate limit with per-hostname certs, and (b) the instance operator - *does* control this DNS zone, so DNS-01 is a one-time setup cost they can pay (manually - today, automated in step 3). -- **Team custom domains: one small cert per exact project hostname** - (`api.acme-corp.com`, `app.acme-corp.com`, ... — never `*.acme-corp.com`), via - TLS-ALPN-01 (or HTTP-01 once the `:80` listener exists), issued automatically. No DNS - involvement beyond the CNAME/A record the team already created to make the tunnel work. - Every hostname that needs a cert is **known to us the moment it needs one** — it's a - row in `projects` (created via `POST /api/projects`) under a `verified` domain. There - is no "unknown future subdomain" problem that would require a wildcard. - -### Alternative considered: CNAME-delegated wildcard DNS-01 (acme-dns pattern) - -Team adds one *extra*, one-time DNS record — `_acme-challenge.acme-corp.com` CNAME → -`acme-corp.acme.tunnel.codad5.me` — after which every DNS-01 challenge for their domain -is answered by us via a TXT record in *our own* zone (Cloudflare API, or similar). This -gets a single `*.acme-corp.com` wildcard cert with no per-project issuance latency and -no ongoing customer action — a real, established pattern (it's what the `acme-dns` -project exists for; Traefik supports it natively). - -**Rejected for now, but for operator-coupling reasons, not customer-friction ones** — -delegation genuinely defeats the "customers must hand over credentials or re-paste -forever" objection above. The actual reason it doesn't win for raba: it requires *the -raba operator's own* DNS zone to be on an API-capable provider (Cloudflare first, then -an open-ended list of others), which self-hosted software running on strangers' -infrastructure can't assume. Per-hostname TLS-ALPN-01 needs zero DNS-provider -integration from the operator — it works identically on every instance regardless of -who they bought their domain from — which matters more for a self-hosted product than -for a centralized SaaS. It also fits raba's actual scale profile better: delegation's -core advantage (unlimited subdomains, no per-project issuance) matters most at -thousands-of-subdomains scale; a typical team here has a handful of projects, well -within per-hostname rate limits. See "Deferred / open questions" below — this can be -revisited as an *opt-in* upgrade later without discarding the per-hostname path, since -both sit on the identical resolver/store/renewal machinery from "Target architecture." - -This (per-hostname, not delegation) is the industry-standard pattern for SaaS custom -domains ("SSL for SaaS" is the -term of art; Caddy's "On-Demand TLS" is the canonical open implementation): DNS points -at the platform, the platform issues per-hostname certs on demand, the customer does -nothing but set one DNS record once. - -DNS note (applies to the team's setup UX): DNS records carry **hostnames or IPs only, -never ports** — a CNAME's value is a hostname, an A record's is an IP. Visitors always -arrive on `:443` because that's what `https://` implies. This is why raba (or an SNI -passthrough in front of it) must be reachable on the standard port; "run on port 9000 -and put the port in DNS" is not a thing DNS can express. - -## Listener/port inventory — what needs a certificate - -The complete cert surface, across every listener the server owns. Only **one** port -terminates TLS today; at most **two** ever will. +| HTTP-01 | CA fetches a token over `http://` from the server the domain resolves to | No | Domain already points here; port 80 reachable | +| TLS-ALPN-01 | Same idea, inside a special TLS handshake | No | Domain already points here; port 443 reachable | +| DNS-01 | A `TXT` record at `_acme-challenge.` | **Yes — the only one that can** | Ability to create records somewhere in that name's DNS chain | + +Wildcards (`*.domain`) require DNS-01. Since both the instance's own domain and every +team's domain get wildcard certs in this plan (§4), DNS-01 is the one mechanism used +everywhere — no HTTP-01/TLS-ALPN-01 needed anywhere in this design. +--- + +## 3. The anchor-host pattern (domain pointing) + +Don't point every hostname at the raw server IP directly. Create **one** dedicated +hostname holding the only `A` record that exists anywhere in the system; everything +else — the instance's own domain, and every team's domain — `CNAME`s to it. Same +pattern Vercel/Netlify/Render use. + +Using the real example: you own `codad5.me`, dedicating `raba.codad5.me` + +`*.raba.codad5.me` to raba (never touching the rest of `codad5.me`, which runs your +portfolio): + +| Type | Name | Value | Why | +|---|---|---|---| +| `A` | `dns.raba.codad5.me` | your server IP | The **one** real IP-pointing record in the whole system | +| `CNAME` | `raba.codad5.me` | `dns.raba.codad5.me` | Instance's own domain — same pattern as everyone else, not special-cased | +| `CNAME` | `*.raba.codad5.me` | `dns.raba.codad5.me` | Every project subdomain, present and future, in one record | + +**Why this beats pointing everything at the raw IP:** if the server's IP ever +changes, you edit the **one** `A` record on `dns.raba.codad5.me`, and every CNAME +anywhere — yours and every team's — inherits the fix automatically. Nobody touches +DNS again after initial setup. + +--- + +## 4. The cert mechanism — one API token, no custom DNS server + +### 4.1 The key design decision + +Two cases, same underlying operation (write a `TXT` record via a DNS provider's API), +different target zone: + +**Case A — the instance's own domain.** You already have API access to `codad5.me`'s +zone (Cloudflare, or whichever provider). raba writes the challenge `TXT` record +directly there. + +**Case B — a team's domain (`raba.resurgee.xyz`).** You have **no** API access to +their zone, and never should (holding a team's DNS credentials is a serious trust +liability). Instead: + +1. The team adds **one** extra, one-time `CNAME`: + `_acme-challenge.raba.resurgee.xyz →