Skip to content

Commit 38ddf9e

Browse files
authored
Merge pull request #2 from TinyCloudLabs/feat/cf-worker-front
Cloudflare Worker front for *.tinycloud.link (dstack wildcard SNI unsupported)
2 parents ff9522a + 12fd069 commit 38ddf9e

13 files changed

Lines changed: 1961 additions & 45 deletions

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,11 @@ TUNNEL_MAX_BODY_BYTES=
3333
# Max concurrently registered (authenticated) tunnels; further connection
3434
# attempts are dropped once at the cap. Defaults to 1000.
3535
TUNNEL_MAX_CONCURRENT=
36+
37+
# Shared secret with the Cloudflare Worker front (worker/, see README's
38+
# "Cloudflare Worker front" section): when set, a request presenting a
39+
# matching X-Front-Secret header is routed by X-Forwarded-Host instead of
40+
# Host. Leave unset outside of the Worker-fronted deployment -- with no
41+
# secret configured, X-Forwarded-Host is never trusted. Must match the
42+
# Worker's FRONT_SECRET secret.
43+
TUNNEL_FRONT_SECRET=

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules/
22
dist/
33
.env
44
*.log
5+
.wrangler/

README.md

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -171,18 +171,59 @@ Rules a node client must follow:
171171

172172
### Ingress and TLS for tunnels
173173

174-
TLS for `https://<name>.tinycloud.link` terminates **at the relay's front door, with one wildcard certificate** -- not with per-name certificates, and not inside this Node process.
174+
**Superseded design, kept for history:** the original plan was `dstack-ingress` fronting a wildcard `DOMAIN=*.tinycloud.link` with one relay-side wildcard certificate (see git history for the full writeup). **Verdict: rejected.** A staging CVM confirmed dstack-ingress's own wildcard ACME issuance works, but the dstack gateway silently reset every TLS handshake for a wildcard SNI (`[dstack#545](https://github.com/Dstack-TEE/dstack/pull/545)` support isn't present on this dstack generation) -- see the TC-85 Linear comment for the full repro (`openssl s_client` against `api.tinycloud.link` succeeds; against any `*.staging.tinycloud.link` name, TCP connects and the ClientHello goes out, then the connection is silently reset). dstack-ingress therefore stays single-hostname (`DOMAIN=api.tinycloud.link`, see `docker-compose.phala.yml`) indefinitely, not just as a fallback.
175175

176-
The constraint that forces this: on Phala, this service runs behind `dstack-ingress` (see `docker-compose.phala.yml`), an HAProxy-based L4 TCP proxy that terminates TLS itself using its own certbot/DNS-01 machinery and forwards the decrypted stream to `TARGET_ENDPOINT`. Everything arriving on the CVM's public `:443` goes through it. From inside the CVM there is no per-SNI certificate hook: this repo's process never sees the TLS handshake, and `dstack-ingress` serves the certificates *it* obtained at bootstrap, not ones handed to it at runtime by another container. So the "extend the ACME issuer to obtain a cert for each `<name>.tinycloud.link` on tunnel registration" design is a dead end in this deployment model -- this process could *obtain* those certs (it already has the DNS-01 machinery), but nothing here could ever *serve* them. Design honesty: we don't ship per-name relay certs.
176+
### Cloudflare Worker front
177177

178-
What ships instead:
178+
TLS for `https://<name>.tinycloud.link` terminates **at Cloudflare's edge**, not inside this CVM at all. This is the replacement for the rejected wildcard-ingress design above.
179179

180-
- `dstack-ingress` runs with `DOMAIN=*.tinycloud.link` -- it supports wildcard domains natively (wildcard DNS-01 order, `issuewild` CAA). One certificate covers `api.tinycloud.link` and every tunnel name.
181-
- DNS needs one wildcard record: `*.tinycloud.link` → the same dstack gateway target `api.tinycloud.link` already points at (human step, same as the original `api` wiring). Tunnels create **no** per-name DNS records; the per-name records this service writes remain what they always were -- LAN A/AAAA under `*.local.tinycloud.link`.
182-
- Behind the ingress, this process routes by `Host` header (`src/tunnel/host-router.ts`): `API_HOSTNAME` (default `api.tinycloud.link`) gets the normal `/v1` API; any other single-label `<name>.tinycloud.link` host is looked up in the tunnel registry and proxied.
183-
- **Open verification item**: dstack-ingress documents that wildcard `DOMAIN` requires dstack-gateway wildcard TXT resolution support ([dstack#545](https://github.com/Dstack-TEE/dstack/pull/545)). Whether the production gateway has it can't be verified from this repo. If it doesn't, the wildcard ACME order fails at ingress bootstrap; the fallback is `DOMAIN=api.tinycloud.link` (control-plane API keeps working exactly as today, tunnels stay dark) until the gateway supports it. Must be checked on a staging CVM before the tunnel-enabled image is deployed.
180+
```
181+
client Cloudflare edge this CVM
182+
| | |
183+
| https://foo.tinycloud.link/x |
184+
|------------------------>| TLS terminated here |
185+
| | (Cloudflare Universal SSL |
186+
| | covers *.tinycloud.link) |
187+
| | |
188+
| | Worker (worker/src/index.ts):
189+
| | fetch("https://api.tinycloud.link/x",
190+
| | { ...same method/body,
191+
| | X-Forwarded-Host: foo.tinycloud.link,
192+
| | X-Front-Secret: <shared secret> })
193+
| |--------------------------->|
194+
| | dstack-ingress
195+
| | (DOMAIN=api.tinycloud.link,
196+
| | unrelated cert, unchanged)
197+
| | |
198+
| | tinycloud-link process:
199+
| | host-router.ts sees a
200+
| | trusted X-Forwarded-Host,
201+
| | resolves it to a tunnel name,
202+
| | proxies down that name's
203+
| | WS tunnel to the node
204+
|<------------------------|<---------------------------|
205+
```
206+
207+
Pieces:
208+
209+
- **DNS**: a **proxied** (orange-cloud) wildcard CNAME `*``api.tinycloud.link` puts every `<name>.tinycloud.link` request through Cloudflare's network. `api.tinycloud.link` itself keeps its existing **DNS-only** (grey-cloud) record pointing at the dstack gateway target -- unchanged, so a node's direct `wss://api.tinycloud.link/v1/tunnel/<name>` connection (the actual tunnel control channel, see "Lifecycle" above) never touches Cloudflare and keeps working exactly as before. DNS resolves the more specific `api` record over the wildcard, so this split is stable, not a race.
210+
- **Certificate**: Cloudflare's free Universal SSL certificate already covers any first-level `*.tinycloud.link` hostname once the zone is on Cloudflare -- no per-name or relay-side wildcard cert needed, and nothing here orders one.
211+
- **Worker** (`worker/`, route `*.tinycloud.link/*`): forwards every request to `https://api.tinycloud.link` unchanged (method, headers, body, streamed), adding `X-Forwarded-Host` (the hostname the client actually asked for) and `X-Front-Secret` (a shared secret proving the pair came from this Worker). Cloudflare route patterns have no exclusion syntax, so the pattern technically also matches `api.tinycloud.link` -- handled defensively in code (see the Worker's `ORIGIN_HOSTNAME` branch), though in practice that hostname's DNS-only status means the Worker never actually sees it.
212+
- **Relay** (`src/tunnel/host-router.ts`): when `TUNNEL_FRONT_SECRET` is configured and a request's `X-Front-Secret` matches it, routing resolves from `X-Forwarded-Host` instead of `Host` (both headers are stripped before anything is forwarded into the tunnel). Without a configured secret, or with a missing/mismatched one, `X-Forwarded-Host` is ignored entirely and routing falls back to `Host` -- byte-for-byte the pre-Worker behavior, so a caller hitting the relay directly can't spoof a hostname it doesn't actually own by forging that header.
213+
- **tunnelRegistry is not separately gated.** `src/index.ts` has unconditionally constructed a `TunnelRegistry` and passed it into `createServer` since TC-85 merged -- there's no env var that turns tunnel routing itself on or off. "Enabling tunnels on redeploy" means redeploying the current image (which already has this wired up) behind the new Cloudflare front, not flipping a flag.
214+
215+
Trade-offs accepted: plaintext HTTP crosses the Cloudflare-edge-to-CVM hop (the same trust boundary the control-plane API traffic already crossed via `dstack-ingress`'s own TLS-terminate-and-forward behavior); Cloudflare is now a dependency for tunnel reachability (TC-85's memo flagged this as a cost of any Cloudflare-fronted option, weighed against the alternative -- no reachability at all, since the dstack-gateway wildcard-SNI path is unavailable on this dstack generation); and the shared `X-Front-Secret` is the only thing standing between a direct caller and forging `X-Forwarded-Host` against the relay, so it must be treated with the same care as any other production credential.
216+
217+
#### Deploy runbook
218+
219+
1. `cd worker && npm install`.
220+
2. `npx wrangler secret put FRONT_SECRET` -- generate a fresh random secret (e.g. `openssl rand -hex 32`); this becomes both the Worker secret and the CVM's `TUNNEL_FRONT_SECRET`.
221+
3. `CLOUDFLARE_ACCOUNT_ID=<account id> npx wrangler deploy` (from `worker/`). `wrangler.toml` has no `account_id` baked in -- it's resolved from this env var, or wrangler prompts interactively if the token has access to more than one account.
222+
4. DNS (Cloudflare dashboard or API, `tinycloud.link` zone): add a **proxied** CNAME record, name `*`, target `api.tinycloud.link`. Leave the existing `api` record as-is (DNS-only/grey-cloud).
223+
5. Add `TUNNEL_FRONT_SECRET` (the same value from step 2) to the CVM's encrypted secrets and redeploy `docker-compose.phala.yml` (already wires it into the `tinycloud-link` service's environment; see below for why a redeploy is also needed for the `dstack-ingress` `DOMAIN` change).
224+
6. Verify: `curl https://api.tinycloud.link/health` (unchanged, direct path) and, once a node has an active tunnel, `curl https://<claimed-name>.tinycloud.link/...` (now via the Worker).
184225

185-
Trade-offs accepted: all tunnel names share one certificate (one CT-log entry for `*.tinycloud.link` rather than a per-name entry -- strictly *less* name disclosure than the LAN cert flow, which CT-logs every name); and tunnel TLS terminates in the ingress container rather than end-to-end at the node, meaning plaintext HTTP crosses the CVM-internal hop between ingress and this process -- the same boundary the control-plane API traffic has always crossed.
226+
**Fresh-`POSTGRES_PASSWORD` redeploy plan**: the current prod CVM's real `POSTGRES_PASSWORD` / `CERTBOT_EMAIL` values are not recoverable from any available session transcript (see the TC-85 Linear comment). Rather than guess and risk breaking live Postgres auth, the redeploy that ships this change generates a **fresh** `POSTGRES_PASSWORD` and accepts a fresh Postgres volume: the `names`/`acme_account`/`cert_issuances` tables are disposable (nodes re-claim their names automatically on their next `PUT /v1/names/:name`, which every node already does periodically), so a clean volume is simpler and safer than trying to recover a lost credential. `CERTBOT_EMAIL` likewise gets a fresh value at redeploy time; it only affects Let's Encrypt account notifications for `dstack-ingress`'s own `api.tinycloud.link` cert; it doesn't gate anything for existing certs.
186227

187228
## DNS provider
188229

@@ -234,7 +275,8 @@ docker run -p 3000:3000 --env-file .env tinycloud/tinycloud-link
234275
- `ACME_EMAIL`
235276
- `CERTBOT_EMAIL` (used by `dstack-ingress` for the API's own front-door cert)
236277
- `DSTACK_GATEWAY_DOMAIN`
237-
4. Point `api.tinycloud.link` at the deployment's dstack gateway (human DNS step, same pattern as `registry.tinycloud.xyz`). For tunnels (TC-85), also point the wildcard `*.tinycloud.link` at the same gateway target and confirm the gateway supports wildcard TXT resolution for the ingress's wildcard cert order -- see "Ingress and TLS for tunnels" above.
278+
- `TUNNEL_FRONT_SECRET` (shared with the Cloudflare Worker's `FRONT_SECRET` -- see "Cloudflare Worker front" above for the full runbook, including the Worker deploy and DNS steps)
279+
4. Point `api.tinycloud.link` at the deployment's dstack gateway (human DNS step, same pattern as `registry.tinycloud.xyz`). This is unchanged from before TC-85: `dstack-ingress`'s `DOMAIN` stays single-hostname (`api.tinycloud.link`), not a wildcard -- see "Ingress and TLS for tunnels" above for why. Tunnel reachability (`<name>.tinycloud.link`) is handled entirely by the Cloudflare Worker front instead, which needs its own DNS/deploy steps (see "Cloudflare Worker front" above), not a change here.
238280
5. Verify:
239281
```bash
240282
curl https://api.tinycloud.link/health

docker-compose.phala.yml

Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,28 @@
11
services:
2-
# Public HTTPS ingress, for BOTH the control-plane API itself
3-
# (api.tinycloud.link) and every tunnel name (<name>.tinycloud.link, the
4-
# TC-85 remote-reachability namespace -- distinct from the LAN-only
5-
# *.local.tinycloud.link zone the service also manages via DNS, which never
6-
# goes through this ingress).
2+
# Public HTTPS ingress for the control-plane API's own hostname
3+
# (api.tinycloud.link) ONLY. dstack-ingress is an HAProxy-based L4 (TCP)
4+
# proxy: it terminates TLS with its own ACME DNS-01 flow and forwards the
5+
# decrypted stream to TARGET_ENDPOINT untouched, Host header and all --
6+
# there is no per-SNI certificate hook from inside this CVM (see README.md
7+
# "Ingress and TLS for tunnels").
78
#
8-
# dstack-ingress is an HAProxy-based L4 (TCP) proxy: it terminates TLS with
9-
# its own ACME DNS-01 flow and forwards the decrypted stream to
10-
# TARGET_ENDPOINT untouched, Host header and all. It natively supports a
11-
# WILDCARD `DOMAIN` (one ACME order for *.tinycloud.link instead of one
12-
# order per name), which is why DOMAIN is a wildcard below rather than the
13-
# single api.tinycloud.link host used before TC-85: any single-label
14-
# subdomain of tinycloud.link -- api.tinycloud.link, mynode.tinycloud.link,
15-
# anything a node claims -- is covered by the same cert and forwarded to
16-
# the same tinycloud-link process, which then routes by Host header
17-
# (api.tinycloud.link -> the normal /v1 API; anything else -> the matching
18-
# tunnel, see src/tunnel/host-router.ts). No per-name ACME order, no TLS
19-
# termination inside this repo's process at all.
20-
#
21-
# CONSTRAINT (unverified, must be confirmed before this is deployed):
22-
# dstack-ingress's own docs say wildcard DOMAIN "requires dstack-gateway
23-
# with wildcard TXT resolution support" (Dstack-TEE/dstack#545). Whether
24-
# this deployment's dstack-gateway includes that support cannot be checked
25-
# from this repo/sandbox. If it doesn't, the ACME order for *.tinycloud.link
26-
# will fail at bootstrap and this config falls back to the single-domain
27-
# form (`DOMAIN=api.tinycloud.link`, i.e. no change from before TC-85) --
28-
# see README.md "Ingress and TLS for tunnels" for that fallback and why a
29-
# per-name-cert-at-the-relay design was considered and rejected.
9+
# DOMAIN is single-hostname, not wildcard: dstack#545 staging verification
10+
# found the gateway does not route wildcard SNI on this dstack generation
11+
# (ACME wildcard cert issuance succeeded, but the gateway silently reset
12+
# every TLS handshake for a wildcard hostname -- see the TC-85 Linear
13+
# comment for the full repro). Per the resulting decision, *.tinycloud.link
14+
# (every tunnel name, TC-85's remote-reachability namespace) is instead
15+
# fronted by a Cloudflare Worker (worker/, README's "Cloudflare Worker
16+
# front" section): Cloudflare terminates TLS for the wildcard at its own
17+
# edge and forwards to this same api.tinycloud.link hostname, carrying the
18+
# tunnel name in X-Forwarded-Host. dstack-ingress and this process are
19+
# unaware anything changed -- they still only ever see api.tinycloud.link.
3020
dstack-ingress:
3121
image: dstacktee/dstack-ingress:20250924@sha256:40429d78060ef3066b5f93676bf3ba7c2e9ac47d4648440febfdda558aed4b32
3222
ports:
3323
- "443:443"
3424
environment:
35-
- DOMAIN=*.tinycloud.link
25+
- DOMAIN=api.tinycloud.link
3626
- TARGET_ENDPOINT=http://tinycloud-link:3000
3727
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
3828
- GATEWAY_DOMAIN=_.${DSTACK_GATEWAY_DOMAIN}
@@ -54,6 +44,13 @@ services:
5444
- ACME_DIRECTORY=${ACME_DIRECTORY:-https://acme-staging-v02.api.letsencrypt.org/directory}
5545
- ATTESTATION_DOCUMENT=${ATTESTATION_DOCUMENT:-}
5646
- API_HOSTNAME=${API_HOSTNAME:-api.tinycloud.link}
47+
# Shared secret with the Cloudflare Worker front -- see README's
48+
# "Cloudflare Worker front" section. Tunnel routing itself (the
49+
# TunnelRegistry, src/tunnel/host-router.ts) has no separate on/off
50+
# env var: it's unconditionally wired up in src/index.ts by every
51+
# build of this image since TC-85 merged. This secret only gates
52+
# whether X-Forwarded-Host is trusted for routing by tunnel name.
53+
- TUNNEL_FRONT_SECRET=${TUNNEL_FRONT_SECRET}
5754
depends_on:
5855
postgres:
5956
condition: service_healthy

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const tunnelMaxBodyBytes = process.env.TUNNEL_MAX_BODY_BYTES
2121
const tunnelMaxConcurrent = process.env.TUNNEL_MAX_CONCURRENT
2222
? Number.parseInt(process.env.TUNNEL_MAX_CONCURRENT, 10)
2323
: undefined;
24+
const tunnelFrontSecret = process.env.TUNNEL_FRONT_SECRET || undefined;
2425

2526
if (!databaseUrl) throw new Error("DATABASE_URL is required");
2627
if (!cloudflareApiToken) throw new Error("CLOUDFLARE_API_TOKEN is required");
@@ -66,6 +67,7 @@ const app = createServer({
6667
tunnelRegistry,
6768
apiHostname,
6869
tunnelMaxBodyBytes,
70+
tunnelFrontSecret,
6971
});
7072

7173
const server = serve({ fetch: app.fetch, port });

src/server.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,17 @@ export interface ServerConfig {
3333
apiHostname?: string;
3434
/** Max bytes accepted for a proxied tunnel request body. Defaults to protocol.ts's DEFAULT_MAX_BODY_BYTES (25MB); overridable via the TUNNEL_MAX_BODY_BYTES env var (see index.ts). */
3535
tunnelMaxBodyBytes?: number;
36+
/**
37+
* Shared secret proving a request's X-Forwarded-Host header actually came
38+
* from the Cloudflare Worker front (see worker/, and README's "Cloudflare
39+
* Worker front" section) rather than a caller spoofing it directly against
40+
* this process. When set, a request presenting a matching X-Front-Secret
41+
* is routed by X-Forwarded-Host instead of Host; otherwise (unset, or
42+
* present but not matching) X-Forwarded-Host is ignored entirely and
43+
* routing falls back to Host, unchanged from before the Worker front
44+
* existed. Set via the TUNNEL_FRONT_SECRET env var (see index.ts).
45+
*/
46+
tunnelFrontSecret?: string;
3647
}
3748

3849
// Prefixes name-update entries so they share the cert rate-limit store without
@@ -52,6 +63,7 @@ export function createServer(config: ServerConfig): Hono {
5263
createTunnelMiddleware(config.tunnelRegistry, {
5364
apiHostname: config.apiHostname ?? DEFAULT_API_HOSTNAME,
5465
maxBodyBytes: config.tunnelMaxBodyBytes,
66+
frontSecret: config.tunnelFrontSecret,
5567
})
5668
);
5769
}

0 commit comments

Comments
 (0)