Skip to content

Commit dd60632

Browse files
committed
TC-85: document tunnel API contract + wildcard-cert ingress/TLS design
README: full tunnel relay section -- lifecycle diagram, auth record and close codes, the frame protocol table with the exact rules a Rust node client must follow (src/tunnel/protocol.ts is the source of truth), and the ingress/TLS finding: dstack-ingress terminates all of :443 inside the CVM with certs it obtained itself, so per-name relay certificates obtained by this process could never be served -- the shipped design is one wildcard cert (DOMAIN=*.tinycloud.link on dstack-ingress, native wildcard DNS-01 support) plus Host-header routing in this process, with the dstack-gateway wildcard-TXT support flagged as the open item to verify on a staging CVM before deploying. docker-compose.phala.yml: switch dstack-ingress DOMAIN to the wildcard (constraint + fallback documented inline; NOT deployed by this change) and pass API_HOSTNAME through. .env.example documents API_HOSTNAME.
1 parent fef668e commit dd60632

3 files changed

Lines changed: 113 additions & 7 deletions

File tree

.env.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ ACME_PROPAGATION_TIMEOUT_MS=
2121

2222
# TEE attestation document (Phala dstack), optional outside of TEE deployments
2323
ATTESTATION_DOCUMENT=
24+
25+
# The control-plane API's own hostname. Requests whose Host header is anything
26+
# else under *.tinycloud.link are routed through the matching tunnel (TC-85)
27+
# instead of the /v1 API. Defaults to api.tinycloud.link.
28+
API_HOSTNAME=api.tinycloud.link

README.md

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ PUT /v1/names/:name
5252
DELETE /v1/names/:name
5353
5454
POST /v1/certs/:name
55+
56+
WS /v1/tunnel/:name (see "Remote reachability: the tunnel relay" below)
5557
```
5658

5759
### `PUT /v1/names/:name`
@@ -103,6 +105,81 @@ Public, unsigned. Returns `{ name, subject, lanIps, updatedAt }` or `404`.
103105
- Opens an ACME order against `ACME_DIRECTORY` (Let's Encrypt staging by default), completes it via DNS-01 through the configured `DnsProvider`, finalizes with the node's CSR, and returns `{ certChainPem, notAfter }`.
104106
- The private key backing the CSR is generated and held by the node. It is never transmitted to or stored by this service.
105107

108+
## Remote reachability: the tunnel relay
109+
110+
`*.local.tinycloud.link` only works on the node's own LAN. The tunnel relay (TC-85) adds a second, **remote** namespace for the same names: `https://<name>.tinycloud.link` (the apex zone, no `.local.`) is reachable from anywhere on the internet and is served by this process relaying each HTTPS request down a WebSocket the node itself opened. The node never needs an inbound port, a public IP, or NAT configuration -- it dials **out** to the relay and keeps that one socket alive.
111+
112+
There is one name registry with two surfaces: a name claimed via `PUT /v1/names/:name` gets LAN A/AAAA records (`<name>.local.tinycloud.link`) *and* the right to open a tunnel for `<name>.tinycloud.link`. Same owner, same signing key, same sequence counter.
113+
114+
### Lifecycle
115+
116+
```
117+
node relay (this service)
118+
| |
119+
| 1. WebSocket connect |
120+
|----- wss://api.tinycloud.link/v1/tunnel/<name> ---->|
121+
| |
122+
| 2. auth frame (first WS message, JSON) |
123+
|-------------------------------------------->| verifies: name is claimed,
124+
| {version, action:"tunnel", name, | subject owns it, sequence >
125+
| subject, sequence, signature} | stored, signature valid;
126+
| | then persists the new sequence
127+
| 3. ack frame |
128+
|<--------------------------------------------| {"type":"ack"} tunnel is live
129+
| |
130+
| ... for each HTTPS request to |
131+
| https://<name>.tinycloud.link ... |
132+
| |
133+
| 4. request + requestBody frames |
134+
|<--------------------------------------------|
135+
| 5. response + responseBody frames |
136+
|-------------------------------------------->|
137+
| |
138+
| (relay pings every 30s; the node's WS |
139+
| stack must answer pongs or the socket |
140+
| is terminated as dead) |
141+
```
142+
143+
- **Auth** (`src/names.ts` `TunnelAuthRecord`, enforced in `src/tunnel/upgrade.ts`): the first WS message must be a JSON record signed exactly like every other write -- canonical payload `{"version":1,"action":"tunnel","name":...,"subject":...,"sequence":...}` (fixed key order, `signature` field excluded from the signed bytes), same did:pkh (EIP-191) / did:key (Ed25519, base64url signature) schemes, `sequence` strictly greater than the name record's stored sequence. On success the stored sequence is bumped (same bump-before-side-effect pattern as the cert flow) and the relay sends `{"type":"ack"}`. The node has 5 seconds from socket open to deliver the auth frame.
144+
- **Close codes on rejection** (RFC 6455 private-use range): `4400` malformed auth frame or frame `name` != URL name, `4401` invalid signature, `4403` subject does not own the name, `4404` name not claimed, `4408` auth timeout, `4409` stale sequence, `4410` superseded by a newer connection for the same name.
145+
- **One socket per name, newest wins** (`src/tunnel/registry.ts`): a second authenticated connection for the same name evicts the first (close `4410`) rather than being rejected -- a node reconnecting after a network blip must be able to take over from its own half-dead previous socket. (Each reconnect needs a fresh auth frame with a higher sequence.)
146+
- **Keep-alive**: the relay pings every 30s and terminates the socket if the previous ping got no pong. Standard WS libraries answer pings automatically; a Rust client must confirm its library does (tungstenite does).
147+
148+
### Frame protocol
149+
150+
Defined in `src/tunnel/protocol.ts` -- that file is the source of truth for the Rust node client. Every frame is one JSON **text** message (binary WS messages are never used); body bytes travel base64-encoded inside the JSON. After the ack, the frames are:
151+
152+
| frame | direction | fields |
153+
|---|---|---|
154+
| `request` | relay → node | `id` (UUID string), `method`, `path` (path + query, always starts with `/`), `headers` (string map; `Host` and hop-by-hop headers stripped) |
155+
| `requestBody` | relay → node | `id`, `chunk` (base64, may be `""`), `done` (bool) |
156+
| `response` | node → relay | `id`, `status` (int), `headers` (string map) |
157+
| `responseBody` | node → relay | `id`, `chunk` (base64, may be `""`), `done` (bool) |
158+
| `error` | either direction | `message`, optional `id` (fails just that request without closing the socket) |
159+
160+
Rules a node client must follow:
161+
162+
- Requests are multiplexed: frames for different `id`s interleave freely on the one socket. Echo the request's `id` on every frame you send for it.
163+
- The relay sends `request` first, then at least one `requestBody` (currently exactly one carrying the whole body with `done: true`, but a client must tolerate multiple chunks and treat `done: true` as the boundary).
164+
- The node replies with exactly one `response` frame, then one or more `responseBody` frames ending with `done: true`. An empty body is still one frame: `{"type":"responseBody","id":...,"chunk":"","done":true}`.
165+
- If the node cannot serve a request it sends `{"type":"error","id":...,"message":...}`; the relay answers the HTTP caller with `502`. The relay applies a 30s per-request timeout (caller gets `504`).
166+
- HTTP callers with no live tunnel for their Host get `502` directly from the relay.
167+
168+
### Ingress and TLS for tunnels
169+
170+
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.
171+
172+
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.
173+
174+
What ships instead:
175+
176+
- `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.
177+
- 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`.
178+
- 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.
179+
- **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+
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.
182+
106183
## DNS provider
107184

108185
`src/dns/provider.ts` defines the `DnsProvider` interface (`upsertAddressRecords`, `deleteAddressRecords`, `createTxtRecord`, `deleteTxtRecord`). `src/dns/cloudflare.ts` is the production implementation against the Cloudflare API (zone id + token from env). `src/dns/memory.ts` is an in-memory fake used by tests -- no test ever makes a live DNS or ACME call.
@@ -153,7 +230,7 @@ docker run -p 3000:3000 --env-file .env tinycloud/tinycloud-link
153230
- `ACME_EMAIL`
154231
- `CERTBOT_EMAIL` (used by `dstack-ingress` for the API's own front-door cert)
155232
- `DSTACK_GATEWAY_DOMAIN`
156-
4. Point `api.tinycloud.link` at the deployment's dstack gateway (human DNS step, same pattern as `registry.tinycloud.xyz`).
233+
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.
157234
5. Verify:
158235
```bash
159236
curl https://api.tinycloud.link/health

docker-compose.phala.yml

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,38 @@
11
services:
2-
# Public HTTPS ingress for the control-plane API itself. This is separate
3-
# from the *.local.tinycloud.link zone the service manages for nodes: the
4-
# API is reachable at a normal public hostname (api.tinycloud.link), not a
5-
# LAN-only name. Wiring api.tinycloud.link's DNS to this deployment's
6-
# dstack gateway is a human step -- see README.md "Deploy to Phala".
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).
7+
#
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.
730
dstack-ingress:
831
image: dstacktee/dstack-ingress:20250924@sha256:40429d78060ef3066b5f93676bf3ba7c2e9ac47d4648440febfdda558aed4b32
932
ports:
1033
- "443:443"
1134
environment:
12-
- DOMAIN=api.tinycloud.link
35+
- DOMAIN=*.tinycloud.link
1336
- TARGET_ENDPOINT=http://tinycloud-link:3000
1437
- CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN}
1538
- GATEWAY_DOMAIN=_.${DSTACK_GATEWAY_DOMAIN}
@@ -30,6 +53,7 @@ services:
3053
- ACME_EMAIL=${ACME_EMAIL}
3154
- ACME_DIRECTORY=${ACME_DIRECTORY:-https://acme-staging-v02.api.letsencrypt.org/directory}
3255
- ATTESTATION_DOCUMENT=${ATTESTATION_DOCUMENT:-}
56+
- API_HOSTNAME=${API_HOSTNAME:-api.tinycloud.link}
3357
depends_on:
3458
postgres:
3559
condition: service_healthy

0 commit comments

Comments
 (0)