diff --git a/.env.example b/.env.example index fa41a9d..1ee08d6 100644 --- a/.env.example +++ b/.env.example @@ -21,3 +21,15 @@ ACME_PROPAGATION_TIMEOUT_MS= # TEE attestation document (Phala dstack), optional outside of TEE deployments ATTESTATION_DOCUMENT= + +# The control-plane API's own hostname. Requests whose Host header is anything +# else under *.tinycloud.link are routed through the matching tunnel (TC-85) +# instead of the /v1 API. Defaults to api.tinycloud.link. +API_HOSTNAME=api.tinycloud.link + +# Max bytes accepted for a single proxied tunnel request body. Defaults to +# 26214400 (25MB). +TUNNEL_MAX_BODY_BYTES= +# Max concurrently registered (authenticated) tunnels; further connection +# attempts are dropped once at the cap. Defaults to 1000. +TUNNEL_MAX_CONCURRENT= diff --git a/README.md b/README.md index a2198a7..b547fc2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,8 @@ PUT /v1/names/:name DELETE /v1/names/:name POST /v1/certs/:name + +WS /v1/tunnel/:name (see "Remote reachability: the tunnel relay" below) ``` ### `PUT /v1/names/:name` @@ -97,11 +99,91 @@ Public, unsigned. Returns `{ name, subject, lanIps, updatedAt }` or `404`. ``` - Verifies the requester owns `name` and the signature is valid. -- The CSR's CN and every SAN entry must be **exactly** `.local.tinycloud.link` -- no wildcards, no extra names. +- The CSR's CN and every SAN entry must be **exactly** `.local.tinycloud.link` -- no wildcards, no extra names. The CSR's key may be RSA or ECDSA (P-256); both are validated identically (`src/names.ts`'s `assertCsrMatchesDomain` parses with `@peculiar/x509`, not node-forge, specifically so ECDSA node keys aren't rejected). - Rate-limited to 5 issuances/day per name to protect Let's Encrypt's rate limits. +- **Sequence is bumped before the ACME round trip, not after.** Once the request passes ownership/signature/CSR checks, the handler immediately persists the new `sequence` on the name record and only then calls the ACME issuer. This is deliberate: an ACME DNS-01 order is slow (DNS propagation + CA validation, seconds to tens of seconds) and not idempotent to retry blindly, so the sequence bump has to happen *before* it, not after it succeeds. That way a replayed or concurrent request carrying the same (now-stale) sequence number is rejected with `409` immediately instead of racing a second ACME order for the same name while the first is still in flight. The tradeoff: if the ACME call itself fails after the bump (rate limit, DNS-01 timeout, CA outage), the sequence has still moved forward and the node must retry with a strictly higher sequence, not the same one -- there is no "undo" of the bump on failure. - 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 }`. - The private key backing the CSR is generated and held by the node. It is never transmitted to or stored by this service. +## Remote reachability: the tunnel relay + +`*.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://.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. + +There is one name registry with two surfaces: a name claimed via `PUT /v1/names/:name` gets LAN A/AAAA records (`.local.tinycloud.link`) *and* the right to open a tunnel for `.tinycloud.link`. Same owner, same signing key, same sequence counter. + +**Sequence coordination is per-name, not per-operation**: `claim`/`delete` (`PUT`/`DELETE /v1/names/:name`), `cert` (`POST /v1/certs/:name`), and `tunnel` (the WS auth frame) all read and bump the *same* `sequence` column on the name's row in `names`. There is exactly one counter per name, shared across every action type -- a node must treat it as a single source of truth (e.g. keep one in-memory counter per name and increment it before every signed write, regardless of which endpoint), not maintain a separate counter per action. Reusing a sequence across two different action types for the same name is exactly as stale/rejected as reusing it for the same action twice. + +### Lifecycle + +``` + node relay (this service) + | | + | 1. WebSocket connect | + |----- wss://api.tinycloud.link/v1/tunnel/ ---->| + | | + | 2. auth frame (first WS message, JSON) | + |-------------------------------------------->| verifies: name is claimed, + | {version, action:"tunnel", name, | subject owns it, sequence > + | subject, sequence, signature} | stored, signature valid; + | | then persists the new sequence + | 3. ack frame | + |<--------------------------------------------| {"type":"ack"} tunnel is live + | | + | ... for each HTTPS request to | + | https://.tinycloud.link ... | + | | + | 4. request + requestBody frames | + |<--------------------------------------------| + | 5. response + responseBody frames | + |-------------------------------------------->| + | | + | (relay pings every 30s; the node's WS | + | stack must answer pongs or the socket | + | is terminated as dead) | +``` + +- **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. +- **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. +- **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.) +- **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). +- **Connection-attempt limits** (`src/tunnel/upgrade.ts`, checked in the raw HTTP `upgrade` handler, before the WS handshake and before any Postgres read): at most 30 upgrade attempts per remote IP per minute, at most 10 upgrade attempts per tunnel name per minute (bounds reconnect/eviction churn on one name), and a global cap of 1000 concurrently registered tunnels (env-tunable via `TUNNEL_MAX_CONCURRENT`, see below). An attempt over any of these limits gets the raw TCP socket destroyed -- no WS handshake, no close code, nothing for the node to parse. + +### Frame protocol + +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: + +| frame | direction | fields | +|---|---|---| +| `request` | relay → node | `id` (UUID string), `method`, `path` (path + query, always starts with `/`), `headers` (ordered `[name, value]` pairs; `Host` and hop-by-hop headers stripped) | +| `requestBody` | relay → node | `id`, `chunk` (base64, may be `""`), `done` (bool) | +| `response` | node → relay | `id`, `status` (int), `headers` (ordered `[name, value]` pairs) | +| `responseBody` | node → relay | `id`, `chunk` (base64, may be `""`), `done` (bool) | +| `error` | either direction | `message`, optional `id` (fails just that request without closing the socket) | + +Rules a node client must follow: + +- 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. A frame carrying an `id` the relay/node doesn't recognize (e.g. arriving after that request already failed or timed out) is silently ignored, never treated as a protocol error. +- **Headers are ordered `[name, value]` pairs, not an object** -- so a header repeated on the wire (most importantly `Set-Cookie`, but any header) survives as multiple array entries instead of colliding on one object key. A client must be able to both emit and parse duplicate entries for the same header name. +- The relay sends `request` first, then one or more `requestBody` frames ending with `done: true`; the node replies with exactly one `response` frame, then one or more `responseBody` frames ending with `done: true`. An empty body is still exactly one frame: `{"...Body","id":...,"chunk":"","done":true}`. +- **Size limits**: no single WS frame may exceed `MAX_FRAME_PAYLOAD_BYTES` (1MB) -- the relay's `WebSocketServer` is configured with this as `maxPayload` and will close a connection that sends a larger one (WS close code `1009`). A body larger than `BODY_CHUNK_BYTES` (256KB, chosen to stay well under 1MB once base64-inflated) must be split across multiple body frames; only the last carries `done: true`. The relay itself follows this when sending `requestBody` frames and expects a node to do the same for `responseBody`. Independent of frame size, the full reassembled body (request or response) is capped at `DEFAULT_MAX_BODY_BYTES` (25MB, overridable via the `TUNNEL_MAX_BODY_BYTES` env var, see below): a request over the cap gets `413` directly from the relay before it ever reaches the tunnel; a response over the cap is aborted with `502` to the HTTP caller and an `{"type":"error","id":...}` frame is sent back to the node so it knows to stop sending. +- 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`). +- HTTP callers with no live tunnel for their Host get `502` directly from the relay. + +### Ingress and TLS for tunnels + +TLS for `https://.tinycloud.link` terminates **at the relay's front door, with one wildcard certificate** -- not with per-name certificates, and not inside this Node process. + +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 `.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. + +What ships instead: + +- `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. +- 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`. +- 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 `.tinycloud.link` host is looked up in the tunnel registry and proxied. +- **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. + +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. + ## DNS provider `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. @@ -152,7 +234,7 @@ docker run -p 3000:3000 --env-file .env tinycloud/tinycloud-link - `ACME_EMAIL` - `CERTBOT_EMAIL` (used by `dstack-ingress` for the API's own front-door cert) - `DSTACK_GATEWAY_DOMAIN` -4. Point `api.tinycloud.link` at the deployment's dstack gateway (human DNS step, same pattern as `registry.tinycloud.xyz`). +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. 5. Verify: ```bash curl https://api.tinycloud.link/health diff --git a/docker-compose.phala.yml b/docker-compose.phala.yml index e092839..911ed6c 100644 --- a/docker-compose.phala.yml +++ b/docker-compose.phala.yml @@ -1,15 +1,38 @@ services: - # Public HTTPS ingress for the control-plane API itself. This is separate - # from the *.local.tinycloud.link zone the service manages for nodes: the - # API is reachable at a normal public hostname (api.tinycloud.link), not a - # LAN-only name. Wiring api.tinycloud.link's DNS to this deployment's - # dstack gateway is a human step -- see README.md "Deploy to Phala". + # Public HTTPS ingress, for BOTH the control-plane API itself + # (api.tinycloud.link) and every tunnel name (.tinycloud.link, the + # TC-85 remote-reachability namespace -- distinct from the LAN-only + # *.local.tinycloud.link zone the service also manages via DNS, which never + # goes through this ingress). + # + # dstack-ingress is an HAProxy-based L4 (TCP) proxy: it terminates TLS with + # its own ACME DNS-01 flow and forwards the decrypted stream to + # TARGET_ENDPOINT untouched, Host header and all. It natively supports a + # WILDCARD `DOMAIN` (one ACME order for *.tinycloud.link instead of one + # order per name), which is why DOMAIN is a wildcard below rather than the + # single api.tinycloud.link host used before TC-85: any single-label + # subdomain of tinycloud.link -- api.tinycloud.link, mynode.tinycloud.link, + # anything a node claims -- is covered by the same cert and forwarded to + # the same tinycloud-link process, which then routes by Host header + # (api.tinycloud.link -> the normal /v1 API; anything else -> the matching + # tunnel, see src/tunnel/host-router.ts). No per-name ACME order, no TLS + # termination inside this repo's process at all. + # + # CONSTRAINT (unverified, must be confirmed before this is deployed): + # dstack-ingress's own docs say wildcard DOMAIN "requires dstack-gateway + # with wildcard TXT resolution support" (Dstack-TEE/dstack#545). Whether + # this deployment's dstack-gateway includes that support cannot be checked + # from this repo/sandbox. If it doesn't, the ACME order for *.tinycloud.link + # will fail at bootstrap and this config falls back to the single-domain + # form (`DOMAIN=api.tinycloud.link`, i.e. no change from before TC-85) -- + # see README.md "Ingress and TLS for tunnels" for that fallback and why a + # per-name-cert-at-the-relay design was considered and rejected. dstack-ingress: image: dstacktee/dstack-ingress:20250924@sha256:40429d78060ef3066b5f93676bf3ba7c2e9ac47d4648440febfdda558aed4b32 ports: - "443:443" environment: - - DOMAIN=api.tinycloud.link + - DOMAIN=*.tinycloud.link - TARGET_ENDPOINT=http://tinycloud-link:3000 - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN} - GATEWAY_DOMAIN=_.${DSTACK_GATEWAY_DOMAIN} @@ -30,6 +53,7 @@ services: - ACME_EMAIL=${ACME_EMAIL} - ACME_DIRECTORY=${ACME_DIRECTORY:-https://acme-staging-v02.api.letsencrypt.org/directory} - ATTESTATION_DOCUMENT=${ATTESTATION_DOCUMENT:-} + - API_HOSTNAME=${API_HOSTNAME:-api.tinycloud.link} depends_on: postgres: condition: service_healthy diff --git a/package-lock.json b/package-lock.json index c8dc108..5a09abc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,17 +10,20 @@ "dependencies": { "@hono/node-server": "^1.14.4", "@noble/curves": "^1.9.7", + "@peculiar/x509": "^1.14.3", "acme-client": "^5.4.0", "hono": "^4.6.14", "multiformats": "^13.4.1", "node-forge": "^1.3.1", "pg": "^8.13.1", - "viem": "^2.21.53" + "viem": "^2.21.53", + "ws": "^8.21.1" }, "devDependencies": { "@types/node": "^20.0.0", "@types/node-forge": "^1.3.11", "@types/pg": "^8.11.10", + "@types/ws": "^8.18.1", "tsx": "^4.19.2", "typescript": "^5.0.0" } @@ -749,6 +752,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -1548,7 +1561,7 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/ws": { + "node_modules/viem/node_modules/ws": { "version": "8.21.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", @@ -1569,6 +1582,27 @@ } } }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 5519d1c..615d580 100644 --- a/package.json +++ b/package.json @@ -16,17 +16,20 @@ "dependencies": { "@hono/node-server": "^1.14.4", "@noble/curves": "^1.9.7", + "@peculiar/x509": "^1.14.3", "acme-client": "^5.4.0", "hono": "^4.6.14", "multiformats": "^13.4.1", "node-forge": "^1.3.1", "pg": "^8.13.1", - "viem": "^2.21.53" + "viem": "^2.21.53", + "ws": "^8.21.1" }, "devDependencies": { "@types/node": "^20.0.0", "@types/node-forge": "^1.3.11", "@types/pg": "^8.11.10", + "@types/ws": "^8.18.1", "tsx": "^4.19.2", "typescript": "^5.0.0" } diff --git a/src/index.ts b/src/index.ts index 2289010..cd2f61a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,9 @@ import { DnsO1AcmeIssuer } from "./acme.js"; import { CloudflareDnsProvider } from "./dns/cloudflare.js"; import { PostgresAcmeAccountStore, PostgresCertRateLimiter, PostgresNameStore } from "./postgres.js"; import { createServer } from "./server.js"; +import { DEFAULT_API_HOSTNAME } from "./tunnel/host-router.js"; +import { TunnelRegistry } from "./tunnel/registry.js"; +import { attachTunnelUpgrade } from "./tunnel/upgrade.js"; const port = Number.parseInt(process.env.PORT ?? "3000", 10); const databaseUrl = process.env.DATABASE_URL; @@ -12,6 +15,12 @@ const cloudflareZoneId = process.env.CLOUDFLARE_ZONE_ID; const acmeEmail = process.env.ACME_EMAIL; const acmeDirectory = process.env.ACME_DIRECTORY ?? "https://acme-staging-v02.api.letsencrypt.org/directory"; +const tunnelMaxBodyBytes = process.env.TUNNEL_MAX_BODY_BYTES + ? Number.parseInt(process.env.TUNNEL_MAX_BODY_BYTES, 10) + : undefined; +const tunnelMaxConcurrent = process.env.TUNNEL_MAX_CONCURRENT + ? Number.parseInt(process.env.TUNNEL_MAX_CONCURRENT, 10) + : undefined; if (!databaseUrl) throw new Error("DATABASE_URL is required"); if (!cloudflareApiToken) throw new Error("CLOUDFLARE_API_TOKEN is required"); @@ -45,15 +54,26 @@ const acmeIssuer = new DnsO1AcmeIssuer({ dnsProvider, }); +const tunnelRegistry = new TunnelRegistry(); +const apiHostname = process.env.API_HOSTNAME ?? DEFAULT_API_HOSTNAME; + const app = createServer({ nameStore, dnsProvider, acmeIssuer, rateLimiter, attestationDocument: process.env.ATTESTATION_DOCUMENT, + tunnelRegistry, + apiHostname, + tunnelMaxBodyBytes, }); -serve({ fetch: app.fetch, port }); +const server = serve({ fetch: app.fetch, port }); +attachTunnelUpgrade(server, { + registry: tunnelRegistry, + nameStore, + maxConcurrentTunnels: tunnelMaxConcurrent, +}); console.log(`tinycloud-link listening on :${port}`); const shutdown = async () => { diff --git a/src/names.test.ts b/src/names.test.ts index 99123e7..0eaacda 100644 --- a/src/names.test.ts +++ b/src/names.test.ts @@ -6,16 +6,19 @@ import { canonicalCertRequestPayload, canonicalClaimPayload, canonicalDeletePayload, + canonicalTunnelAuthPayload, fqdnForName, validateCertRequest, validateNameClaim, validateNameDelete, validateNameLabel, + validateTunnelAuth, verifyCertRequest, verifyNameClaim, verifyNameDelete, + verifyTunnelAuth, } from "./names.js"; -import { createTestCsr } from "./test-support/csr.js"; +import { createTestCsr, createTestEcCsr } from "./test-support/csr.js"; import { didKeySigner, pkhSigner } from "./test-support/signing.js"; test("validates and verifies a did:key name claim", async () => { @@ -241,6 +244,53 @@ test("validates and verifies a name delete record", async () => { assert.equal(await verifyNameDelete(record), true); }); +test("validates and verifies a tunnel auth record", async () => { + const signer = didKeySigner(40); + const unsigned = { + version: 1 as const, + action: "tunnel" as const, + name: "tunnelnode", + subject: signer.subject, + sequence: 2, + }; + const signature = await signer.sign(canonicalTunnelAuthPayload(unsigned)); + const record = { ...unsigned, signature }; + + assert.deepEqual(validateTunnelAuth(record), record); + assert.equal(await verifyTunnelAuth(record), true); +}); + +test("rejects a tunnel auth record signed by the wrong key", async () => { + const signer = didKeySigner(41); + const other = didKeySigner(42); + const unsigned = { + version: 1 as const, + action: "tunnel" as const, + name: "tunnelnode", + subject: signer.subject, + sequence: 2, + }; + const signature = await other.sign(canonicalTunnelAuthPayload(unsigned)); + const record = { ...unsigned, signature }; + + assert.equal(await verifyTunnelAuth(record), false); +}); + +test("rejects a tunnel auth record with the wrong action", () => { + assert.throws( + () => + validateTunnelAuth({ + version: 1, + action: "claim", + name: "tunnelnode", + subject: didKeySigner(43).subject, + sequence: 1, + signature: "x", + }), + NameError + ); +}); + test("validates and verifies a cert request record", async () => { const signer = didKeySigner(13); const domain = fqdnForName("certnode"); @@ -283,3 +333,15 @@ test("csr domain check rejects a csr with a non-dNSName SAN entry", () => { const csr = createTestCsr(domain, [domain, { type: 7, ip: "8.8.8.8" }]); // iPAddress assert.throws(() => assertCsrMatchesDomain(csr, domain), /only a dNSName entry/); }); + +test("csr domain check accepts an ECDSA P-256 CSR with an exact CN/SAN match", async () => { + const domain = fqdnForName("ecnode"); + const csr = await createTestEcCsr(domain); + assert.doesNotThrow(() => assertCsrMatchesDomain(csr, domain)); +}); + +test("csr domain check rejects an ECDSA P-256 CSR with a mismatched domain", async () => { + const domain = fqdnForName("ecnode"); + const csr = await createTestEcCsr(domain); + assert.throws(() => assertCsrMatchesDomain(csr, fqdnForName("otherecnode")), NameError); +}); diff --git a/src/names.ts b/src/names.ts index b2d1631..b537ff3 100644 --- a/src/names.ts +++ b/src/names.ts @@ -1,4 +1,4 @@ -import forge from "node-forge"; +import * as x509 from "@peculiar/x509"; import { isPrivateAddress } from "./ip.js"; import { isSupportedSubject, verifySignedPayload } from "./crypto-verify.js"; @@ -9,6 +9,17 @@ export function fqdnForName(name: string): string { return `${name}.${DOMAIN_SUFFIX}`; } +// The tunnel relay's namespace: .tinycloud.link (the public apex zone, +// not the LAN-only local.tinycloud.link zone above). A name claimed via +// PUT /v1/names/:name is the same name a subject can open a tunnel for -- +// there is one name registry, two surfaces (LAN A/AAAA records vs. a remote +// WebSocket tunnel). +export const REMOTE_DOMAIN_SUFFIX = "tinycloud.link"; + +export function remoteFqdnForName(name: string): string { + return `${name}.${REMOTE_DOMAIN_SUFFIX}`; +} + const NAME_LABEL_PATTERN = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/; // Reserved so a claimed name can never collide with infrastructure, the @@ -209,6 +220,61 @@ export async function verifyNameDelete(record: NameDeleteRecord): Promise; + if (body.version !== 1) { + throw new NameError("version must be 1"); + } + if (body.action !== "tunnel") { + throw new NameError('action must be "tunnel"'); + } + + return { + version: 1, + action: "tunnel", + name: validateNameLabel(body.name), + subject: validateSubject(body.subject), + sequence: validateSequence(body.sequence), + signature: validateSignature(body.signature), + }; +} + +export async function verifyTunnelAuth(record: TunnelAuthRecord): Promise { + return verifySignedPayload(record.subject, canonicalTunnelAuthPayload(record), record.signature); +} + // --- cert request (POST /v1/certs/:name) --- export interface CertRequestPayload { @@ -265,30 +331,35 @@ export async function verifyCertRequest(record: CertRequestRecord): Promise.local.tinycloud.link domain as a single dNSName entry -- * nothing broader, nothing else, no SAN entries of any other type. + * + * Parses with @peculiar/x509 (not node-forge): forge cannot parse CSRs with + * an EC (e.g. ECDSA P-256) public key at all ("OID is not RSA"), which would + * reject every ECDSA node CSR outright regardless of its CN/SAN. @peculiar/x509 + * parses the CSR's subject and extensions independently of the key algorithm, + * so RSA and ECDSA CSRs are validated identically. */ export function assertCsrMatchesDomain(csrPem: string, expectedDomain: string): void { - let csr: forge.pki.CertificateSigningRequest; + let csr: x509.Pkcs10CertificateRequest; try { - csr = forge.pki.certificationRequestFromPem(csrPem); + csr = new x509.Pkcs10CertificateRequest(csrPem); } catch { throw new NameError("csr must be a valid PEM-encoded PKCS#10 certificate request"); } - const cnField = csr.subject.getField("CN"); - const cn = cnField ? cnField.value : undefined; + const cn = csr.subjectName.getField("CN")[0]; - const extensionRequest = csr.getAttribute({ name: "extensionRequest" }); - const sanExtension = (extensionRequest?.extensions ?? []).find( - (ext: { name?: string }) => ext.name === "subjectAltName" - ) as { altNames?: Array<{ type: number; value: string }> } | undefined; - const altNames = sanExtension?.altNames ?? []; + const sanExtension = csr.extensions.find((ext) => ext.type === SUBJECT_ALT_NAME_OID); + const altNames = + sanExtension instanceof x509.SubjectAlternativeNameExtension ? sanExtension.names.items : []; for (const entry of altNames) { - if (entry.type !== 2) { - // 2 = dNSName + if (entry.type !== "dns") { throw new NameError( `csr subjectAltName must contain only a dNSName entry for ${expectedDomain} (found an entry of type ${entry.type})` ); diff --git a/src/server.test.ts b/src/server.test.ts index a84f291..9f131e5 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -10,7 +10,7 @@ import { fqdnForName, } from "./names.js"; import { createServer } from "./server.js"; -import { createTestCsr } from "./test-support/csr.js"; +import { createTestCsr, createTestEcCsr } from "./test-support/csr.js"; import { FakeAcmeClient } from "./test-support/fake-acme-client.js"; import { InMemoryCertRateLimiter, InMemoryNameStore } from "./test-support/memory-stores.js"; import { didKeySigner, type Signer } from "./test-support/signing.js"; @@ -239,6 +239,32 @@ test("issues a certificate for an owned name via DNS-01", async () => { assert.equal(Number.isNaN(Date.parse(body.notAfter)), false); }); +test("issues a certificate for an owned name via an ECDSA P-256 CSR", async () => { + const { app } = buildApp(); + const signer = didKeySigner(39); + await claim(app, "ecdsanode", signer, ["192.168.1.22"], 1); + const domain = fqdnForName("ecdsanode"); + const csr = await createTestEcCsr(domain); + const unsigned = { + version: 1 as const, + action: "cert" as const, + name: "ecdsanode", + subject: signer.subject, + csr, + sequence: 2, + }; + const signature = await signer.sign(canonicalCertRequestPayload(unsigned)); + const res = await app.request("/v1/certs/ecdsanode", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...unsigned, signature }), + }); + assert.equal(res.status, 200); + const body = (await res.json()) as { certChainPem: string; notAfter: string }; + assert.match(body.certChainPem, /BEGIN CERTIFICATE/); + assert.equal(Number.isNaN(Date.parse(body.notAfter)), false); +}); + test("rejects a cert request whose CSR domain does not match the claimed name", async () => { const { app } = buildApp(); const signer = didKeySigner(34); diff --git a/src/server.ts b/src/server.ts index 18f955d..41b4498 100644 --- a/src/server.ts +++ b/src/server.ts @@ -14,6 +14,8 @@ import { verifyNameDelete, } from "./names.js"; import type { CertRateLimiter, NameStore } from "./storage.js"; +import { DEFAULT_API_HOSTNAME, createTunnelMiddleware } from "./tunnel/host-router.js"; +import type { TunnelRegistry } from "./tunnel/registry.js"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -25,6 +27,12 @@ export interface ServerConfig { attestationDocument?: string; certRateLimitPerDay?: number; nameUpdateRateLimitPerDay?: number; + /** When set, requests whose Host header names a claimed tunnel (rather than `apiHostname`) are proxied through it. Omit to leave tunnel routing disabled entirely (no behavior change to the /v1 API). */ + tunnelRegistry?: TunnelRegistry; + /** The control-plane API's own hostname, exempted from tunnel routing. Defaults to "api.tinycloud.link". */ + apiHostname?: string; + /** 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). */ + tunnelMaxBodyBytes?: number; } // Prefixes name-update entries so they share the cert rate-limit store without @@ -38,6 +46,16 @@ export function createServer(config: ServerConfig): Hono { const rateLimit = config.certRateLimitPerDay ?? 5; const nameUpdateRateLimit = config.nameUpdateRateLimitPerDay ?? 30; + if (config.tunnelRegistry) { + app.use( + "*", + createTunnelMiddleware(config.tunnelRegistry, { + apiHostname: config.apiHostname ?? DEFAULT_API_HOSTNAME, + maxBodyBytes: config.tunnelMaxBodyBytes, + }) + ); + } + app.get("/health", (c) => c.json({ ok: true })); app.get("/attestation", (c) => { diff --git a/src/test-support/csr.ts b/src/test-support/csr.ts index 1062129..fe68d60 100644 --- a/src/test-support/csr.ts +++ b/src/test-support/csr.ts @@ -1,4 +1,8 @@ +import { webcrypto } from "node:crypto"; import forge from "node-forge"; +import * as x509 from "@peculiar/x509"; + +x509.cryptoProvider.set(webcrypto as unknown as Crypto); /** A SAN entry: a bare string becomes a dNSName; an object passes through to forge as-is. */ export type TestAltName = string | { type: number; value?: string; ip?: string }; @@ -25,3 +29,30 @@ export function createTestCsr(commonName: string, altNames: TestAltName[] = [com csr.sign(keys.privateKey, forge.md.sha256.create()); return forge.pki.certificationRequestToPem(csr); } + +/** + * Builds a real PKCS#10 CSR PEM with an ECDSA P-256 key for tests. node-forge + * cannot generate (or even parse) EC CSRs, so this uses @peculiar/x509 + + * Node's WebCrypto directly -- the same library production code now uses to + * validate CSRs (see assertCsrMatchesDomain in ../names.ts). + */ +export async function createTestEcCsr( + commonName: string, + altNames: string[] = [commonName] +): Promise { + const keys = await webcrypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [ + "sign", + "verify", + ]); + const csr = await x509.Pkcs10CertificateRequestGenerator.create({ + name: `CN=${commonName}`, + keys, + signingAlgorithm: { name: "ECDSA", hash: "SHA-256" }, + extensions: [ + new x509.SubjectAlternativeNameExtension( + altNames.map((value) => ({ type: "dns" as const, value })) + ), + ], + }); + return csr.toString("pem"); +} diff --git a/src/test-support/fake-acme-client.ts b/src/test-support/fake-acme-client.ts index 3793960..60b7720 100644 --- a/src/test-support/fake-acme-client.ts +++ b/src/test-support/fake-acme-client.ts @@ -1,17 +1,27 @@ -import forge from "node-forge"; +import { webcrypto } from "node:crypto"; +import * as x509 from "@peculiar/x509"; import type { AcmeAuthorization, AcmeChallenge, AcmeClientLike, AcmeOrder } from "../acme.js"; +x509.cryptoProvider.set(webcrypto as unknown as Crypto); + /** * In-process fake of acme-client's Client. Stands in for a real ACME * directory in tests: drives the same createOrder -> getAuthorizations -> * completeChallenge -> finalizeOrder -> getCertificate flow as * DnsO1AcmeIssuer expects, and issues a self-signed leaf certificate for the * CSR's public key once challenges are "completed". No network calls. + * + * Uses @peculiar/x509 (not node-forge) so it can parse and sign CSRs for + * either RSA or ECDSA node keys -- forge cannot parse an EC CSR at all. */ export class FakeAcmeClient implements AcmeClientLike { readonly completedChallenges: AcmeChallenge[] = []; private lastCsr: string | Buffer | undefined; - private readonly caKeys = forge.pki.rsa.generateKeyPair(1024); + private caKeysPromise = webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"] + ); async createAccount(): Promise { return { status: "valid" }; @@ -63,17 +73,20 @@ export class FakeAcmeClient implements AcmeClientLike { if (!this.lastCsr) { throw new Error("finalizeOrder must be called before getCertificate"); } - const csr = forge.pki.certificationRequestFromPem(this.lastCsr.toString()); + const csr = new x509.Pkcs10CertificateRequest(this.lastCsr.toString()); + const caKeys = await this.caKeysPromise; - const cert = forge.pki.createCertificate(); - cert.publicKey = csr.publicKey!; - cert.serialNumber = "01"; - cert.validity.notBefore = new Date(); - cert.validity.notAfter = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000); - cert.setSubject(csr.subject.attributes); - cert.setIssuer([{ name: "commonName", value: "Fake Test CA" }]); - cert.sign(this.caKeys.privateKey, forge.md.sha256.create()); + const cert = await x509.X509CertificateGenerator.create({ + serialNumber: "01", + subject: csr.subjectName, + issuer: "CN=Fake Test CA", + notBefore: new Date(), + notAfter: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000), + signingAlgorithm: { name: "ECDSA", hash: "SHA-256" }, + publicKey: csr.publicKey, + signingKey: caKeys.privateKey, + }); - return forge.pki.certificateToPem(cert); + return cert.toString("pem"); } } diff --git a/src/tunnel.test.ts b/src/tunnel.test.ts new file mode 100644 index 0000000..62dbfc3 --- /dev/null +++ b/src/tunnel.test.ts @@ -0,0 +1,553 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { AddressInfo } from "node:net"; +import { serve } from "@hono/node-server"; +import type { Hono } from "hono"; +import WebSocket from "ws"; +import { DnsO1AcmeIssuer } from "./acme.js"; +import { InMemoryDnsProvider } from "./dns/memory.js"; +import { canonicalClaimPayload, canonicalTunnelAuthPayload } from "./names.js"; +import { createServer } from "./server.js"; +import { FakeAcmeClient } from "./test-support/fake-acme-client.js"; +import { InMemoryCertRateLimiter, InMemoryNameStore } from "./test-support/memory-stores.js"; +import { didKeySigner, type Signer } from "./test-support/signing.js"; +import { MAX_FRAME_PAYLOAD_BYTES, encodeFrame, parseFrame } from "./tunnel/protocol.js"; +import { SUPERSEDED_CLOSE_CODE, TunnelRegistry } from "./tunnel/registry.js"; +import { + CLOSE_INVALID_SIGNATURE, + CLOSE_NAME_NOT_CLAIMED, + CLOSE_NOT_OWNER, + CLOSE_STALE_SEQUENCE, + type AttachTunnelUpgradeOptions, + attachTunnelUpgrade, +} from "./tunnel/upgrade.js"; + +const API_HOSTNAME = "api.tinycloud.link"; + +type UpgradeOverrides = Partial>; + +async function startTunnelServer( + upgradeOverrides: UpgradeOverrides = {}, + tunnelMaxBodyBytes?: number +) { + const nameStore = new InMemoryNameStore(); + const dnsProvider = new InMemoryDnsProvider(); + const rateLimiter = new InMemoryCertRateLimiter(); + const fakeAcmeClient = new FakeAcmeClient(); + const acmeIssuer = new DnsO1AcmeIssuer({ + directoryUrl: "https://fake-acme.test/directory", + accountKeyPem: "fake-account-key", + email: "ops@tinycloud.xyz", + dnsProvider, + clientFactory: () => fakeAcmeClient, + checkPropagation: async () => {}, + }); + const registry = new TunnelRegistry(); + const app = createServer({ + nameStore, + dnsProvider, + acmeIssuer, + rateLimiter, + tunnelRegistry: registry, + apiHostname: API_HOSTNAME, + tunnelMaxBodyBytes, + }); + + const server = await new Promise>((resolve) => { + const s = serve({ fetch: app.fetch, port: 0 }, () => resolve(s)); + }); + attachTunnelUpgrade(server, { registry, nameStore, authTimeoutMs: 1000, ...upgradeOverrides }); + const port = (server.address() as AddressInfo).port; + + const sockets = new Set(); + + return { + app, + nameStore, + registry, + port, + /** Opens a tunnel WebSocket client and tracks it so `close()` can force-terminate any left open by a failing test. */ + connect(name: string): WebSocket { + const ws = new WebSocket(`ws://127.0.0.1:${port}/v1/tunnel/${name}`); + sockets.add(ws); + ws.once("close", () => sockets.delete(ws)); + return ws; + }, + async close(): Promise { + for (const ws of sockets) { + ws.terminate(); + } + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +type TunnelTestHarness = Awaited>; + +/** Runs a test body against a fresh harness, guaranteeing the server (and any sockets it opened) is torn down even if the body throws. */ +async function withHarness( + body: (harness: TunnelTestHarness) => Promise, + upgradeOverrides: UpgradeOverrides = {}, + tunnelMaxBodyBytes?: number +): Promise { + const harness = await startTunnelServer(upgradeOverrides, tunnelMaxBodyBytes); + try { + await body(harness); + } finally { + await harness.close(); + } +} + +/** Resolves true if the WS connection is rejected (errors or closes) before ever reaching 'open', false if it opens. Used to assert an upgrade-time limiter dropped the connection pre-handshake. */ +function connectionWasRejected(ws: WebSocket): Promise { + return new Promise((resolve) => { + ws.once("open", () => resolve(false)); + ws.once("error", () => resolve(true)); + ws.once("close", () => resolve(true)); + }); +} + +async function claim(app: Hono, name: string, signer: Signer, sequence: number): Promise { + const unsigned = { + version: 1 as const, + action: "claim" as const, + name, + subject: signer.subject, + lanIps: ["192.168.1.50"], + sequence, + }; + const signature = await signer.sign(canonicalClaimPayload(unsigned)); + const res = await app.request(`/v1/names/${name}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...unsigned, signature }), + }); + assert.equal(res.status, 201); +} + +async function sendAuth(ws: WebSocket, signer: Signer, name: string, sequence: number): Promise { + const unsigned = { + version: 1 as const, + action: "tunnel" as const, + name, + subject: signer.subject, + sequence, + }; + const signature = await signer.sign(canonicalTunnelAuthPayload(unsigned)); + await new Promise((resolve, reject) => { + ws.once("open", () => { + ws.send(JSON.stringify({ ...unsigned, signature })); + resolve(); + }); + ws.once("error", reject); + }); +} + +function waitForClose(ws: WebSocket): Promise<{ code: number; reason: string }> { + return new Promise((resolve) => { + ws.once("close", (code, reasonBuf) => resolve({ code, reason: reasonBuf.toString() })); + }); +} + +function waitForMessage(ws: WebSocket): Promise { + return new Promise((resolve, reject) => { + ws.once("message", (data) => { + try { + resolve(JSON.parse(data.toString())); + } catch (error) { + reject(error); + } + }); + ws.once("close", (code, reasonBuf) => reject(new Error(`closed before message: ${code} ${reasonBuf}`))); + }); +} + +/** A minimal "node" that answers every proxied request with a fixed response, echoing the request body back. */ +function runEchoNode(ws: WebSocket): void { + ws.on("message", (data) => { + const frame = parseFrame(data.toString()); + if (frame.type !== "requestBody" || !frame.done) return; + const { id } = frame; + ws.send( + encodeFrame({ + type: "response", + id, + status: 200, + headers: [["content-type", "text/plain"]], + }) + ); + ws.send(encodeFrame({ type: "responseBody", id, chunk: frame.chunk, done: true })); + }); +} + +test("tunnel auth is rejected for an unclaimed name", () => + withHarness(async (harness) => { + const signer = didKeySigner(101); + const ws = harness.connect("ghost"); + await sendAuth(ws, signer, "ghost", 1); + const { code } = await waitForClose(ws); + assert.equal(code, CLOSE_NAME_NOT_CLAIMED); + })); + +test("tunnel auth is rejected for a non-owning subject", () => + withHarness(async (harness) => { + const owner = didKeySigner(102); + const attacker = didKeySigner(103); + await claim(harness.app, "notyours", owner, 1); + + const ws = harness.connect("notyours"); + await sendAuth(ws, attacker, "notyours", 2); + const { code } = await waitForClose(ws); + assert.equal(code, CLOSE_NOT_OWNER); + })); + +test("tunnel auth is rejected when signed by the wrong key", () => + withHarness(async (harness) => { + const owner = didKeySigner(104); + const forger = didKeySigner(105); + await claim(harness.app, "forged-tunnel", owner, 1); + + const ws = harness.connect("forged-tunnel"); + const unsigned = { + version: 1 as const, + action: "tunnel" as const, + name: "forged-tunnel", + subject: owner.subject, + sequence: 2, + }; + const signature = await forger.sign(canonicalTunnelAuthPayload(unsigned)); + await new Promise((resolve) => ws.once("open", () => resolve())); + ws.send(JSON.stringify({ ...unsigned, signature })); + const { code } = await waitForClose(ws); + assert.equal(code, CLOSE_INVALID_SIGNATURE); + })); + +test("tunnel auth is rejected for a stale sequence", () => + withHarness(async (harness) => { + const signer = didKeySigner(106); + await claim(harness.app, "stale-tunnel", signer, 5); + + const ws = harness.connect("stale-tunnel"); + await sendAuth(ws, signer, "stale-tunnel", 5); + const { code } = await waitForClose(ws); + assert.equal(code, CLOSE_STALE_SEQUENCE); + })); + +test("valid tunnel auth is acked and bumps the stored sequence", () => + withHarness(async (harness) => { + const signer = didKeySigner(107); + await claim(harness.app, "goodtunnel", signer, 1); + + const ws = harness.connect("goodtunnel"); + const messagePromise = waitForMessage(ws); + await sendAuth(ws, signer, "goodtunnel", 2); + const ack = await messagePromise; + assert.deepEqual(ack, { type: "ack" }); + + const record = await harness.nameStore.get("goodtunnel"); + assert.equal(record?.sequence, 2); + })); + +test("proxies an HTTP request through the tunnel to a mock node and back (framing roundtrip)", () => + withHarness(async (harness) => { + const signer = didKeySigner(108); + await claim(harness.app, "echonode", signer, 1); + + const ws = harness.connect("echonode"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "echonode", 2); + await ackPromise; + runEchoNode(ws); + + const res = await harness.app.request("/hello?x=1", { + method: "POST", + headers: { host: "echonode.tinycloud.link", "content-type": "text/plain" }, + body: "ping", + }); + assert.equal(res.status, 200); + assert.equal(res.headers.get("content-type"), "text/plain"); + assert.equal(await res.text(), "ping"); + })); + +test("returns 502 when no tunnel is connected for the requested host", () => + withHarness(async (harness) => { + const res = await harness.app.request("/anything", { + headers: { host: "notconnected.tinycloud.link" }, + }); + assert.equal(res.status, 502); + })); + +test("requests for the API's own hostname are never proxied through a tunnel", () => + withHarness(async (harness) => { + const res = await harness.app.request("/health", { headers: { host: API_HOSTNAME } }); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { ok: true }); + })); + +test("newest tunnel connection wins: the older socket is evicted and the new one serves the tunnel", () => + withHarness(async (harness) => { + const signer = didKeySigner(109); + await claim(harness.app, "handoff", signer, 1); + + const first = harness.connect("handoff"); + const firstAck = waitForMessage(first); + await sendAuth(first, signer, "handoff", 2); + await firstAck; + + const firstClosed = waitForClose(first); + + const second = harness.connect("handoff"); + const secondAck = waitForMessage(second); + await sendAuth(second, signer, "handoff", 3); + await secondAck; + runEchoNode(second); + + const { code } = await firstClosed; + assert.equal(code, SUPERSEDED_CLOSE_CODE); + + // Proves the *second* connection is the one actually serving the tunnel now. + const res = await harness.app.request("/still-alive", { + headers: { host: "handoff.tinycloud.link" }, + }); + assert.equal(res.status, 200); + })); + +test("request headers travel to the node as an array of [name, value] pairs", () => + withHarness(async (harness) => { + const signer = didKeySigner(111); + await claim(harness.app, "headerscheck", signer, 1); + + const ws = harness.connect("headerscheck"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "headerscheck", 2); + await ackPromise; + + let capturedHeaders: Array<[string, string]> | undefined; + ws.on("message", (data) => { + const frame = parseFrame(data.toString()); + if (frame.type === "request") { + capturedHeaders = frame.headers; + } + }); + runEchoNode(ws); + + const res = await harness.app.request("/x", { + headers: { host: "headerscheck.tinycloud.link", "x-custom": "hello" }, + }); + assert.equal(res.status, 200); + assert.ok(Array.isArray(capturedHeaders)); + assert.ok(capturedHeaders?.some(([key, value]) => key.toLowerCase() === "x-custom" && value === "hello")); + })); + +test("duplicate Set-Cookie response headers survive the tunnel roundtrip", () => + withHarness(async (harness) => { + const signer = didKeySigner(112); + await claim(harness.app, "cookienode", signer, 1); + + const ws = harness.connect("cookienode"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "cookienode", 2); + await ackPromise; + + ws.on("message", (data) => { + const frame = parseFrame(data.toString()); + if (frame.type !== "requestBody" || !frame.done) return; + const { id } = frame; + ws.send( + encodeFrame({ + type: "response", + id, + status: 200, + headers: [ + ["set-cookie", "a=1"], + ["set-cookie", "b=2"], + ], + }) + ); + ws.send(encodeFrame({ type: "responseBody", id, chunk: "", done: true })); + }); + + const res = await harness.app.request("/set-cookies", { + headers: { host: "cookienode.tinycloud.link" }, + }); + assert.equal(res.status, 200); + assert.deepEqual(res.headers.getSetCookie(), ["a=1", "b=2"]); + })); + +test("a request body larger than one body-frame chunk is split across multiple requestBody frames and reassembles correctly", () => + withHarness(async (harness) => { + const signer = didKeySigner(113); + await claim(harness.app, "bigbody", signer, 1); + + const ws = harness.connect("bigbody"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "bigbody", 2); + await ackPromise; + + // Larger than protocol.ts's BODY_CHUNK_BYTES (256KB), so the relay must + // split it across at least two requestBody frames. + const bigBody = "x".repeat(300 * 1024); + + let requestBodyFrameCount = 0; + const chunks: string[] = []; + ws.on("message", (data) => { + const frame = parseFrame(data.toString()); + if (frame.type !== "requestBody") return; + requestBodyFrameCount += 1; + chunks.push(frame.chunk); + if (!frame.done) return; + const id = frame.id; + const body = Buffer.concat(chunks.map((c) => Buffer.from(c, "base64"))).toString("utf8"); + ws.send(encodeFrame({ type: "response", id, status: 200, headers: [["content-type", "text/plain"]] })); + ws.send(encodeFrame({ type: "responseBody", id, chunk: Buffer.from(body).toString("base64"), done: true })); + }); + + const res = await harness.app.request("/upload", { + method: "POST", + headers: { host: "bigbody.tinycloud.link" }, + body: bigBody, + }); + assert.equal(res.status, 200); + assert.equal(await res.text(), bigBody); + assert.ok(requestBodyFrameCount > 1, `expected multiple requestBody frames, got ${requestBodyFrameCount}`); + })); + +test("a request body over the configured limit is rejected with 413 before reaching the tunnel", () => + withHarness( + async (harness) => { + const signer = didKeySigner(114); + await claim(harness.app, "toobig", signer, 1); + + const ws = harness.connect("toobig"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "toobig", 2); + await ackPromise; + runEchoNode(ws); + + const res = await harness.app.request("/upload", { + method: "POST", + headers: { host: "toobig.tinycloud.link" }, + body: "x".repeat(200), + }); + assert.equal(res.status, 413); + }, + {}, + 100 // TUNNEL_MAX_BODY_BYTES override for this test + )); + +test("a response body over the configured limit is aborted with 502 and the node is told via an error frame", () => + withHarness( + async (harness) => { + const signer = didKeySigner(115); + await claim(harness.app, "hugeresponse", signer, 1); + + const ws = harness.connect("hugeresponse"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "hugeresponse", 2); + await ackPromise; + + let sawErrorFrame = false; + ws.on("message", (data) => { + const frame = parseFrame(data.toString()); + if (frame.type === "error") { + sawErrorFrame = true; + return; + } + if (frame.type !== "requestBody" || !frame.done) return; + const { id } = frame; + ws.send(encodeFrame({ type: "response", id, status: 200, headers: [] })); + // Stream a body well past the 100-byte test limit. + ws.send(encodeFrame({ type: "responseBody", id, chunk: Buffer.from("x".repeat(200)).toString("base64"), done: false })); + ws.send(encodeFrame({ type: "responseBody", id, chunk: "", done: true })); + }); + + const res = await harness.app.request("/download", { + headers: { host: "hugeresponse.tinycloud.link" }, + }); + assert.equal(res.status, 502); + // Give the node's message handler a tick to observe the error frame the relay sent back. + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(sawErrorFrame, true); + }, + {}, + 100 // TUNNEL_MAX_BODY_BYTES override for this test + )); + +test("the WebSocketServer enforces a max frame payload: an oversized single frame from the node closes the tunnel", () => + withHarness(async (harness) => { + const signer = didKeySigner(116); + await claim(harness.app, "oversizedframe", signer, 1); + + const ws = harness.connect("oversizedframe"); + const ackPromise = waitForMessage(ws); + await sendAuth(ws, signer, "oversizedframe", 2); + await ackPromise; + + const closed = waitForClose(ws); + const oversizedChunk = "a".repeat(MAX_FRAME_PAYLOAD_BYTES + 1024); + ws.send(encodeFrame({ type: "responseBody", id: "irrelevant", chunk: oversizedChunk, done: true })); + + const { code } = await closed; + assert.equal(code, 1009); // RFC 6455 CLOSE_TOO_LARGE + })); + +test("per-IP connection attempts beyond the configured limit are dropped before the WS handshake completes", () => + withHarness( + async (harness) => { + const signer = didKeySigner(117); + await claim(harness.app, "ratelimited", signer, 1); + + const first = harness.connect("ratelimited"); + assert.equal(await connectionWasRejected(first), false); + first.terminate(); + + const second = harness.connect("ratelimited"); + assert.equal(await connectionWasRejected(second), false); + second.terminate(); + + // The limit is 2/minute; this third attempt within the window must be dropped pre-handshake. + const third = harness.connect("ratelimited"); + assert.equal(await connectionWasRejected(third), true); + }, + { ipConnectionLimitPerMinute: 2 } + )); + +test("per-name churn beyond the configured limit drops further connection attempts for that name", () => + withHarness( + async (harness) => { + const signer = didKeySigner(118); + await claim(harness.app, "churny", signer, 1); + + const first = harness.connect("churny"); + assert.equal(await connectionWasRejected(first), false); + first.terminate(); + + const second = harness.connect("churny"); + assert.equal(await connectionWasRejected(second), false); + second.terminate(); + + // The name-churn limit is 2/minute; this third attempt for the same name must be dropped. + const third = harness.connect("churny"); + assert.equal(await connectionWasRejected(third), true); + }, + { ipConnectionLimitPerMinute: 100, nameChurnLimitPerMinute: 2 } + )); + +test("a global concurrent-tunnel cap drops further connection attempts once reached", () => + withHarness( + async (harness) => { + const ownerA = didKeySigner(119); + const ownerB = didKeySigner(120); + await claim(harness.app, "capped-a", ownerA, 1); + await claim(harness.app, "capped-b", ownerB, 1); + + const first = harness.connect("capped-a"); + const firstAck = waitForMessage(first); + await sendAuth(first, ownerA, "capped-a", 2); + await firstAck; // registry.size() === 1, at the configured cap. + + const second = harness.connect("capped-b"); + assert.equal(await connectionWasRejected(second), true); + }, + { maxConcurrentTunnels: 1 } + )); diff --git a/src/tunnel/host-router.ts b/src/tunnel/host-router.ts new file mode 100644 index 0000000..76c1bb7 --- /dev/null +++ b/src/tunnel/host-router.ts @@ -0,0 +1,145 @@ +import type { Context, MiddlewareHandler } from "hono"; +import { REMOTE_DOMAIN_SUFFIX } from "../names.js"; +import { DEFAULT_MAX_BODY_BYTES } from "./protocol.js"; +import { TunnelProxyError, TunnelProxyTimeoutError, proxyRequest } from "./proxy.js"; +import type { TunnelRegistry } from "./registry.js"; + +export const DEFAULT_API_HOSTNAME = "api.tinycloud.link"; + +class RequestBodyTooLargeError extends Error {} + +/** + * Reads a Request body while enforcing `maxBytes`, without ever buffering + * more than the limit in memory: a Content-Length over the cap is rejected + * without reading the stream at all, and a chunked/unsized body is read + * incrementally and aborted the moment the running total crosses the cap. + */ +async function readLimitedBody(request: Request, maxBytes: number): Promise { + if (!request.body) return new Uint8Array(0); + + const contentLength = request.headers.get("content-length"); + if (contentLength !== null && Number(contentLength) > maxBytes) { + throw new RequestBodyTooLargeError(); + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.length; + if (total > maxBytes) { + await reader.cancel(); + throw new RequestBodyTooLargeError(); + } + chunks.push(value); + } + + const body = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.length; + } + return body; +} + +/** + * Resolves an inbound Host header to a tunnel name, or null if this request + * isn't for the tunnel namespace (either it's the control-plane API's own + * hostname, or some other/malformed Host). `.tinycloud.link` must be + * exactly one label under the apex zone -- deeper labels are never tunnel + * names (a claimed name is always a single DNS label, see names.ts). + */ +export function remoteNameFromHost(host: string | undefined, apiHostname: string): string | null { + if (!host) return null; + const hostname = host.split(":")[0]?.toLowerCase(); + if (!hostname || hostname === apiHostname.toLowerCase()) return null; + + const suffix = `.${REMOTE_DOMAIN_SUFFIX}`; + if (!hostname.endsWith(suffix)) return null; + + const label = hostname.slice(0, -suffix.length); + if (!label || label.includes(".")) return null; + return label; +} + +// Headers that describe the hop to the relay itself, not the tunneled +// request -- stripped so they aren't forwarded to the node as-is. +const HOP_BY_HOP_HEADERS = new Set(["connection", "keep-alive", "transfer-encoding", "upgrade", "host"]); + +/** + * Hono middleware that, for any request whose Host header names a claimed + * tunnel (rather than the control-plane API's own hostname), proxies the + * request through that name's WebSocket tunnel instead of running it through + * the normal /v1 routes below. See README's "Ingress and TLS" section for + * how *.tinycloud.link traffic reaches this process in the first place. + */ +export function createTunnelMiddleware( + registry: TunnelRegistry, + opts: { apiHostname: string; maxBodyBytes?: number } +): MiddlewareHandler { + const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES; + + return async (c: Context, next) => { + const name = remoteNameFromHost(c.req.header("host"), opts.apiHostname); + if (!name) { + await next(); + return; + } + + const socket = registry.get(name); + if (!socket) { + return c.json({ error: `no active tunnel for "${name}"` }, 502); + } + + const headers: Array<[string, string]> = []; + c.req.raw.headers.forEach((value, key) => { + if (!HOP_BY_HOP_HEADERS.has(key.toLowerCase())) { + headers.push([key, value]); + } + }); + + const url = new URL(c.req.url); + + let body: Uint8Array; + try { + body = await readLimitedBody(c.req.raw, maxBodyBytes); + } catch (error) { + if (error instanceof RequestBodyTooLargeError) { + return c.json({ error: `request body exceeds ${maxBodyBytes} byte limit` }, 413); + } + throw error; + } + + try { + const response = await proxyRequest( + socket, + { + method: c.req.method, + path: `${url.pathname}${url.search}`, + headers, + body, + }, + { maxResponseBytes: maxBodyBytes } + ); + const responseHeaders = new Headers(); + for (const [key, value] of response.headers) { + responseHeaders.append(key, value); + } + return new Response(new Uint8Array(response.body), { + status: response.status, + headers: responseHeaders, + }); + } catch (error) { + if (error instanceof TunnelProxyTimeoutError) { + return c.json({ error: "tunnel request timed out" }, 504); + } + if (error instanceof TunnelProxyError) { + return c.json({ error: "tunnel request failed" }, 502); + } + throw error; + } + }; +} diff --git a/src/tunnel/protocol.ts b/src/tunnel/protocol.ts new file mode 100644 index 0000000..339e15d --- /dev/null +++ b/src/tunnel/protocol.ts @@ -0,0 +1,119 @@ +/** + * Wire protocol for the tunnel relay's HTTP-over-WebSocket framing. + * + * A node opens one outbound WebSocket to wss://api.tinycloud.link/v1/tunnel/ + * and authenticates with a single `TunnelAuthFrame` (see ../names.ts's + * TunnelAuthRecord). After the relay acks, every subsequent frame on the + * socket is one of the request/response frames below: the relay sends + * `request` + `requestBody` frames for each inbound HTTPS request to + * .tinycloud.link, and the node replies with `response` + + * `responseBody` frames carrying the same `id`. Frames are JSON text + * messages, one per WebSocket message (no batching). + * + * This file is the source of truth for a Rust client implementation -- + * every frame shape a node must produce or consume is defined here. + * + * Size limits (see README's "Remote reachability: the tunnel relay" section + * for the full contract): the relay's WebSocketServer enforces + * `MAX_FRAME_PAYLOAD_BYTES` on every inbound WS message (src/tunnel/upgrade.ts), + * so no single frame -- request or response -- may serialize to more than + * that many bytes. A body larger than `BODY_CHUNK_BYTES` must be split across + * multiple `requestBody`/`responseBody` frames (only the last carries + * `done: true`); `BODY_CHUNK_BYTES` is sized so a base64-encoded chunk plus + * its JSON envelope always stays well under `MAX_FRAME_PAYLOAD_BYTES`. + * `DEFAULT_MAX_BODY_BYTES` bounds the total (reassembled) size of a request + * or response body; a node must not send, and the relay will not forward, a + * body larger than this (configurable via `TUNNEL_MAX_BODY_BYTES` on the + * relay -- see README). + */ +import type { TunnelAuthRecord } from "../names.js"; + +/** Max size in bytes of a single WebSocket message the relay will accept from a node (see src/tunnel/upgrade.ts's WebSocketServer maxPayload). */ +export const MAX_FRAME_PAYLOAD_BYTES = 1 * 1024 * 1024; + +/** Chunk size (pre-base64, in bytes) used to split a request/response body across multiple body frames. Base64 expands this by ~4/3, plus JSON envelope overhead, staying comfortably under MAX_FRAME_PAYLOAD_BYTES. */ +export const BODY_CHUNK_BYTES = 256 * 1024; + +/** Default cap (bytes) on a full reassembled request or response body; overridable via the TUNNEL_MAX_BODY_BYTES env var. */ +export const DEFAULT_MAX_BODY_BYTES = 25 * 1024 * 1024; + +/** First message a node sends after the WebSocket opens. */ +export type TunnelAuthFrame = TunnelAuthRecord & { type?: undefined }; + +/** Sent by the relay once auth succeeds. The tunnel is live after this. */ +export interface TunnelAckFrame { + type: "ack"; +} + +/** + * Sent by the relay on auth failure (immediately before closing the socket), + * or by either side on a per-request failure (carries the request `id`). + */ +export interface TunnelErrorFrame { + type: "error"; + id?: string; + message: string; +} + +/** Relay -> node: the head of an inbound HTTP request for .tinycloud.link. */ +export interface TunnelRequestFrame { + type: "request"; + id: string; + method: string; + /** Path + query string, e.g. "/foo?bar=1". Always starts with "/". */ + path: string; + /** Ordered [name, value] pairs, one per header line -- an array (not an object) so duplicate header names (e.g. multiple Cookie lines) survive rather than colliding on one object key. */ + headers: Array<[string, string]>; +} + +/** Relay -> node: a chunk of the request body. Always sent at least once per request, even if empty. A body larger than BODY_CHUNK_BYTES is split across multiple requestBody frames; only the last has done: true. */ +export interface TunnelRequestBodyFrame { + type: "requestBody"; + id: string; + /** Base64-encoded body bytes for this chunk (may be an empty string). */ + chunk: string; + done: boolean; +} + +/** Node -> relay: the head of the response to a proxied request. */ +export interface TunnelResponseFrame { + type: "response"; + id: string; + status: number; + /** Ordered [name, value] pairs -- an array (not an object) so duplicate header names, most importantly Set-Cookie, survive rather than colliding on one object key. */ + headers: Array<[string, string]>; +} + +/** Node -> relay: a chunk of the response body. Always sent at least once per request, even if empty. A body larger than BODY_CHUNK_BYTES should be split across multiple responseBody frames; only the last has done: true. */ +export interface TunnelResponseBodyFrame { + type: "responseBody"; + id: string; + chunk: string; + done: boolean; +} + +export type TunnelFrame = + | TunnelAckFrame + | TunnelErrorFrame + | TunnelRequestFrame + | TunnelRequestBodyFrame + | TunnelResponseFrame + | TunnelResponseBodyFrame; + +export function encodeFrame(frame: TunnelFrame | TunnelAuthFrame): string { + return JSON.stringify(frame); +} + +/** Parses and minimally shape-checks a raw WebSocket text message into a TunnelFrame. */ +export function parseFrame(raw: string): TunnelFrame { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new Error("frame is not valid JSON"); + } + if (parsed === null || typeof parsed !== "object" || typeof (parsed as { type?: unknown }).type !== "string") { + throw new Error('frame must be an object with a string "type"'); + } + return parsed as TunnelFrame; +} diff --git a/src/tunnel/proxy.ts b/src/tunnel/proxy.ts new file mode 100644 index 0000000..c128acf --- /dev/null +++ b/src/tunnel/proxy.ts @@ -0,0 +1,140 @@ +import { randomUUID } from "node:crypto"; +import type WebSocket from "ws"; +import { BODY_CHUNK_BYTES, DEFAULT_MAX_BODY_BYTES, encodeFrame, parseFrame } from "./protocol.js"; +import { rawDataToString } from "./ws-util.js"; + +export interface TunnelProxyRequest { + method: string; + /** Path + query string, e.g. "/foo?bar=1". */ + path: string; + /** Ordered [name, value] pairs (see protocol.ts) so duplicate header names survive. */ + headers: Array<[string, string]>; + body: Uint8Array; +} + +export interface TunnelProxyResponse { + status: number; + /** Ordered [name, value] pairs (see protocol.ts) so duplicate header names, e.g. Set-Cookie, survive. */ + headers: Array<[string, string]>; + body: Uint8Array; +} + +export class TunnelProxyError extends Error {} +export class TunnelProxyTimeoutError extends TunnelProxyError {} +export class TunnelProxyBodyTooLargeError extends TunnelProxyError {} + +const DEFAULT_TIMEOUT_MS = 30_000; + +/** + * Sends one HTTP request down an authenticated tunnel socket and resolves + * with the aggregated response once the node signals `done`. Concurrency + * safe: each call uses its own request id and only inspects frames carrying + * that id, so multiple proxyRequest calls can run concurrently over the same + * socket. + */ +export function proxyRequest( + socket: WebSocket, + request: TunnelProxyRequest, + opts: { timeoutMs?: number; maxResponseBytes?: number } = {} +): Promise { + const id = randomUUID(); + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const maxResponseBytes = opts.maxResponseBytes ?? DEFAULT_MAX_BODY_BYTES; + + return new Promise((resolve, reject) => { + let status: number | undefined; + let headers: Array<[string, string]> | undefined; + const bodyChunks: Buffer[] = []; + let bodyBytes = 0; + let settled = false; + + const cleanup = () => { + settled = true; + clearTimeout(timer); + socket.off("message", onMessage); + socket.off("close", onClose); + }; + + const fail = (error: Error) => { + if (settled) return; + cleanup(); + reject(error); + }; + + const timer = setTimeout(() => { + fail(new TunnelProxyTimeoutError(`tunnel request ${id} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + const onClose = () => { + fail(new TunnelProxyError(`tunnel socket closed before request ${id} completed`)); + }; + + const onMessage = (data: WebSocket.RawData) => { + if (settled) return; + let frame; + try { + frame = parseFrame(rawDataToString(data)); + } catch { + return; // ignore malformed frames rather than tearing down the socket + } + if (frame.type === "response" && frame.id === id) { + status = frame.status; + headers = frame.headers; + return; + } + if (frame.type === "responseBody" && frame.id === id) { + if (frame.chunk.length > 0) { + const chunk = Buffer.from(frame.chunk, "base64"); + bodyBytes += chunk.length; + if (bodyBytes > maxResponseBytes) { + socket.send( + encodeFrame({ type: "error", id, message: `response body exceeds ${maxResponseBytes} byte limit` }) + ); + fail(new TunnelProxyBodyTooLargeError(`response body for request ${id} exceeded ${maxResponseBytes} bytes`)); + return; + } + bodyChunks.push(chunk); + } + if (frame.done) { + if (status === undefined || headers === undefined) { + fail(new TunnelProxyError(`request ${id} completed without a response head`)); + return; + } + cleanup(); + resolve({ status, headers, body: Buffer.concat(bodyChunks) }); + } + return; + } + if (frame.type === "error" && frame.id === id) { + fail(new TunnelProxyError(`node reported an error for request ${id}: ${frame.message}`)); + } + }; + + socket.on("message", onMessage); + socket.on("close", onClose); + + socket.send( + encodeFrame({ type: "request", id, method: request.method, path: request.path, headers: request.headers }) + ); + + // Split the body across multiple requestBody frames of at most + // BODY_CHUNK_BYTES each so no single WS message risks tripping the + // relay's/node's WebSocketServer maxPayload (see protocol.ts). An empty + // body still sends exactly one frame, done: true. + const body = Buffer.from(request.body); + let offset = 0; + do { + const end = Math.min(offset + BODY_CHUNK_BYTES, body.length); + const done = end >= body.length; + socket.send( + encodeFrame({ + type: "requestBody", + id, + chunk: body.subarray(offset, end).toString("base64"), + done, + }) + ); + offset = end; + } while (offset < body.length); + }); +} diff --git a/src/tunnel/rate-limit.ts b/src/tunnel/rate-limit.ts new file mode 100644 index 0000000..65bec93 --- /dev/null +++ b/src/tunnel/rate-limit.ts @@ -0,0 +1,23 @@ +/** + * In-memory sliding-window attempt counter for the tunnel WS upgrade path + * (per-IP connection attempts, per-name churn). Same count/prune pattern as + * the Postgres-backed cert-issuance rate limiter (src/postgres.ts's + * PostgresCertRateLimiter and its in-memory test double), but deliberately + * not backed by Postgres: these are short (per-minute) windows checked on + * every single upgrade attempt, before any Postgres read happens (see + * src/tunnel/upgrade.ts) -- adding a Postgres round trip here would defeat + * the point, and resetting on process restart is fine for this use case. + */ +export class AttemptLimiter { + private readonly attempts = new Map(); + + /** Records one attempt for `key` now, and returns how many attempts for `key` fall within the last `windowMs` (including this one), pruning older entries as a side effect. */ + recordAndCount(key: string, windowMs: number): number { + const cutoff = Date.now() - windowMs; + const existing = this.attempts.get(key); + const pruned = existing ? existing.filter((at) => at >= cutoff) : []; + pruned.push(Date.now()); + this.attempts.set(key, pruned); + return pruned.length; + } +} diff --git a/src/tunnel/registry.ts b/src/tunnel/registry.ts new file mode 100644 index 0000000..8270776 --- /dev/null +++ b/src/tunnel/registry.ts @@ -0,0 +1,47 @@ +import type WebSocket from "ws"; + +// Close code for a socket evicted by a newer registration for the same name. +// 4000-4999 is the private-use range reserved by RFC 6455. Distinct from +// upgrade.ts's CLOSE_STALE_SEQUENCE (4409): that's an auth-time rejection, +// this is a normal lifecycle eviction of an already-authenticated tunnel. +export const SUPERSEDED_CLOSE_CODE = 4410; + +interface Entry { + socket: WebSocket; + connectedAt: number; +} + +/** + * Tracks the single live tunnel socket per name. "Newest wins": registering + * a new socket for a name that already has one closes the old one instead of + * rejecting the new connection, matching a node that reconnects (e.g. after + * a network blip) taking over from a stale socket the relay hasn't noticed + * is dead yet. + */ +export class TunnelRegistry { + private readonly entries = new Map(); + + register(name: string, socket: WebSocket): void { + const existing = this.entries.get(name); + if (existing && existing.socket !== socket) { + existing.socket.close(SUPERSEDED_CLOSE_CODE, "superseded by a newer connection"); + } + this.entries.set(name, { socket, connectedAt: Date.now() }); + } + + get(name: string): WebSocket | undefined { + return this.entries.get(name)?.socket; + } + + /** Removes `socket` from the registry, but only if it's still the current entry for `name` -- avoids a stale socket's close/error handler clobbering a newer registration. */ + unregister(name: string, socket: WebSocket): void { + const existing = this.entries.get(name); + if (existing && existing.socket === socket) { + this.entries.delete(name); + } + } + + size(): number { + return this.entries.size; + } +} diff --git a/src/tunnel/upgrade.ts b/src/tunnel/upgrade.ts new file mode 100644 index 0000000..3b456b3 --- /dev/null +++ b/src/tunnel/upgrade.ts @@ -0,0 +1,190 @@ +import type { ServerType } from "@hono/node-server"; +import { WebSocketServer } from "ws"; +import type WebSocket from "ws"; +import { validateTunnelAuth, verifyTunnelAuth } from "../names.js"; +import type { NameStore } from "../storage.js"; +import { TunnelRegistry } from "./registry.js"; +import { MAX_FRAME_PAYLOAD_BYTES, encodeFrame } from "./protocol.js"; +import { AttemptLimiter } from "./rate-limit.js"; +import { rawDataToString } from "./ws-util.js"; + +const TUNNEL_PATH_PATTERN = /^\/v1\/tunnel\/([^/]+)\/?$/; + +// Close codes are in the 4000-4999 private-use range reserved by RFC 6455. +export const CLOSE_AUTH_TIMEOUT = 4408; +export const CLOSE_BAD_FRAME = 4400; +export const CLOSE_NAME_NOT_CLAIMED = 4404; +export const CLOSE_NOT_OWNER = 4403; +export const CLOSE_STALE_SEQUENCE = 4409; +export const CLOSE_INVALID_SIGNATURE = 4401; + +const DEFAULT_AUTH_TIMEOUT_MS = 5_000; +const HEARTBEAT_INTERVAL_MS = 30_000; +const RATE_WINDOW_MS = 60_000; +const DEFAULT_IP_CONNECTION_LIMIT_PER_MINUTE = 30; +const DEFAULT_NAME_CHURN_LIMIT_PER_MINUTE = 10; +const DEFAULT_MAX_CONCURRENT_TUNNELS = 1000; + +export interface AttachTunnelUpgradeOptions { + registry: TunnelRegistry; + nameStore: NameStore; + authTimeoutMs?: number; + /** Max WS upgrade attempts allowed per remote IP per minute before the connection is dropped pre-handshake. */ + ipConnectionLimitPerMinute?: number; + /** Max WS upgrade attempts allowed per tunnel name per minute (limits reconnect/eviction churn on a single name). */ + nameChurnLimitPerMinute?: number; + /** Max concurrently registered (authenticated) tunnels; further upgrade attempts are dropped once at the cap. Env-tunable via TUNNEL_MAX_CONCURRENT (see index.ts). */ + maxConcurrentTunnels?: number; +} + +/** + * Wires up wss:///v1/tunnel/:name on the raw Node HTTP server behind a + * Hono app (Hono/`@hono/node-server` don't handle WebSocket upgrades; this + * hooks the server's 'upgrade' event directly, the standard `ws` pattern). + */ +export function attachTunnelUpgrade(server: ServerType, opts: AttachTunnelUpgradeOptions): void { + const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_PAYLOAD_BYTES }); + const ipLimiter = new AttemptLimiter(); + const nameLimiter = new AttemptLimiter(); + const ipConnectionLimit = opts.ipConnectionLimitPerMinute ?? DEFAULT_IP_CONNECTION_LIMIT_PER_MINUTE; + const nameChurnLimit = opts.nameChurnLimitPerMinute ?? DEFAULT_NAME_CHURN_LIMIT_PER_MINUTE; + const maxConcurrentTunnels = opts.maxConcurrentTunnels ?? DEFAULT_MAX_CONCURRENT_TUNNELS; + + server.on("upgrade", (req, socket, head) => { + const url = new URL(req.url ?? "", "http://tunnel.invalid"); + const match = TUNNEL_PATH_PATTERN.exec(url.pathname); + if (!match) { + socket.destroy(); + return; + } + const urlName = decodeURIComponent(match[1]).toLowerCase(); + + // Every check below runs before any Postgres read (the first one is + // nameStore.get, in authenticate() -- only reached after a WS handshake + // and a first message). Rejecting here means an attacker exhausting + // these limits never gets a completed WS handshake, let alone triggers a + // database query. + const ip = socket.remoteAddress ?? "unknown"; + if (ipLimiter.recordAndCount(`ip:${ip}`, RATE_WINDOW_MS) > ipConnectionLimit) { + socket.destroy(); + return; + } + if (opts.registry.size() >= maxConcurrentTunnels) { + socket.destroy(); + return; + } + if (nameLimiter.recordAndCount(`name:${urlName}`, RATE_WINDOW_MS) > nameChurnLimit) { + socket.destroy(); + return; + } + + wss.handleUpgrade(req, socket, head, (ws) => { + handleConnection(ws, urlName, opts); + }); + }); +} + +function handleConnection(ws: WebSocket, urlName: string, opts: AttachTunnelUpgradeOptions): void { + // 'ws' emits 'error' as a plain EventEmitter event -- with no listener, + // Node treats it as uncaught and crashes the process. This fires for + // things like a frame exceeding the WebSocketServer's maxPayload (e.g. a + // misbehaving node), which must close the one offending tunnel, not take + // down the relay. The library already closes the socket itself in that + // case (see ws's receiverOnError); this handler only exists to observe + // the event so it isn't unhandled. + ws.on("error", () => {}); + + const authTimeoutMs = opts.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS; + const authTimer = setTimeout(() => { + ws.close(CLOSE_AUTH_TIMEOUT, "no auth frame received in time"); + }, authTimeoutMs); + + ws.once("message", (data) => { + clearTimeout(authTimer); + authenticate(ws, urlName, data, opts).catch(() => { + ws.close(1011, "internal error during authentication"); + }); + }); +} + +async function authenticate( + ws: WebSocket, + urlName: string, + data: WebSocket.RawData, + opts: AttachTunnelUpgradeOptions +): Promise { + let auth; + try { + auth = validateTunnelAuth(JSON.parse(rawDataToString(data))); + } catch (error) { + ws.close(CLOSE_BAD_FRAME, error instanceof Error ? error.message.slice(0, 120) : "invalid auth frame"); + return; + } + if (auth.name !== urlName) { + ws.close(CLOSE_BAD_FRAME, "auth frame name must match the connection URL"); + return; + } + + const existing = await opts.nameStore.get(auth.name); + if (!existing) { + ws.close(CLOSE_NAME_NOT_CLAIMED, "name is not claimed; claim it via PUT /v1/names/:name first"); + return; + } + if (existing.subject !== auth.subject) { + ws.close(CLOSE_NOT_OWNER, "subject does not own this name"); + return; + } + if (existing.sequence >= auth.sequence) { + ws.close(CLOSE_STALE_SEQUENCE, "stale sequence"); + return; + } + + let verified = false; + try { + verified = await verifyTunnelAuth(auth); + } catch { + verified = false; + } + if (!verified) { + ws.close(CLOSE_INVALID_SIGNATURE, "invalid record signature"); + return; + } + + const bumpStatus = await opts.nameStore.put({ + ...existing, + sequence: auth.sequence, + updatedAt: new Date().toISOString(), + }); + if (bumpStatus === "stale") { + ws.close(CLOSE_STALE_SEQUENCE, "stale sequence"); + return; + } + + // Multiple concurrent proxied requests each add their own 'message'/'close' + // listener (see proxy.ts); raise the default EventEmitter cap to avoid + // spurious MaxListenersExceededWarning noise under load. + ws.setMaxListeners(100); + + opts.registry.register(auth.name, ws); + ws.send(encodeFrame({ type: "ack" })); + + ws.on("close", () => opts.registry.unregister(auth.name, ws)); + + // Standard ws heartbeat: ping every interval, and if the previous ping + // never got a pong back, assume the peer is gone and terminate. Detects a + // node that dropped off the network without a clean WS close (e.g. power + // loss, NAT timeout) so a dead socket doesn't linger as "registered". + let alive = true; + ws.on("pong", () => { + alive = true; + }); + const heartbeat = setInterval(() => { + if (!alive) { + ws.terminate(); + return; + } + alive = false; + ws.ping(); + }, HEARTBEAT_INTERVAL_MS); + ws.on("close", () => clearInterval(heartbeat)); +} diff --git a/src/tunnel/ws-util.ts b/src/tunnel/ws-util.ts new file mode 100644 index 0000000..882059c --- /dev/null +++ b/src/tunnel/ws-util.ts @@ -0,0 +1,12 @@ +import type WebSocket from "ws"; + +/** ws's default binaryType ("nodebuffer") always delivers a Buffer; this also covers the other RawData shapes defensively. */ +export function rawDataToString(data: WebSocket.RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + return data.toString("utf8"); +}