Skip to content

Commit fef668e

Browse files
committed
TC-85: tunnel relay -- WS registration, HTTP-over-WS framing, Host routing
Adds the remote-reachability tunnel relay: a node opens one outbound WebSocket to wss://<host>/v1/tunnel/<name>, authenticates with the same signed-payload scheme as claim/delete/cert (action: "tunnel", subject must own <name>, sequence anti-replay -- src/names.ts), and the relay then proxies inbound HTTPS requests for <name>.tinycloud.link down that socket as simple JSON request/response frames (src/tunnel/protocol.ts). - src/tunnel/registry.ts: one live socket per name, newest-wins eviction (a fresh registration closes the prior socket with a distinct close code rather than rejecting the new connection). - src/tunnel/proxy.ts: sends request/requestBody frames, demuxes response/responseBody/error frames by request id (concurrency-safe, many in-flight requests can share one socket), with a timeout. - src/tunnel/upgrade.ts: the WS handshake -- auth-frame timeout, name ownership + signature verification, sequence bump (same bump-before-side-effect pattern as the cert flow), ws ping/pong heartbeat with dead-peer termination. - src/tunnel/host-router.ts: Hono middleware that proxies any request whose Host header names a claimed tunnel through its socket, and leaves the control-plane API's own hostname (api.tinycloud.link) untouched. Wired into ServerConfig as an opt-in `tunnelRegistry` field -- omitting it (as all existing callers/tests do) leaves /v1 routing byte-for-byte unchanged. - src/index.ts: constructs the registry and attaches the WS upgrade handler to the same http.Server @hono/node-server returns. Node-side (Rust client) implementation is out of scope; the wire protocol in src/tunnel/protocol.ts and the auth/lifecycle rules in src/tunnel/upgrade.ts are the contract for it (README documents both precisely). Ingress/TLS wiring for *.tinycloud.link traffic is a separate, deliberately unresolved question here -- see the README's "Ingress and TLS for tunnels" section and the follow-up docker-compose change, neither of which is deployed by this PR.
1 parent 2fdfd31 commit fef668e

11 files changed

Lines changed: 934 additions & 1 deletion

File tree

src/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { DnsO1AcmeIssuer } from "./acme.js";
44
import { CloudflareDnsProvider } from "./dns/cloudflare.js";
55
import { PostgresAcmeAccountStore, PostgresCertRateLimiter, PostgresNameStore } from "./postgres.js";
66
import { createServer } from "./server.js";
7+
import { DEFAULT_API_HOSTNAME } from "./tunnel/host-router.js";
8+
import { TunnelRegistry } from "./tunnel/registry.js";
9+
import { attachTunnelUpgrade } from "./tunnel/upgrade.js";
710

811
const port = Number.parseInt(process.env.PORT ?? "3000", 10);
912
const databaseUrl = process.env.DATABASE_URL;
@@ -45,15 +48,21 @@ const acmeIssuer = new DnsO1AcmeIssuer({
4548
dnsProvider,
4649
});
4750

51+
const tunnelRegistry = new TunnelRegistry();
52+
const apiHostname = process.env.API_HOSTNAME ?? DEFAULT_API_HOSTNAME;
53+
4854
const app = createServer({
4955
nameStore,
5056
dnsProvider,
5157
acmeIssuer,
5258
rateLimiter,
5359
attestationDocument: process.env.ATTESTATION_DOCUMENT,
60+
tunnelRegistry,
61+
apiHostname,
5462
});
5563

56-
serve({ fetch: app.fetch, port });
64+
const server = serve({ fetch: app.fetch, port });
65+
attachTunnelUpgrade(server, { registry: tunnelRegistry, nameStore });
5766
console.log(`tinycloud-link listening on :${port}`);
5867

5968
const shutdown = async () => {

src/names.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@ import {
66
canonicalCertRequestPayload,
77
canonicalClaimPayload,
88
canonicalDeletePayload,
9+
canonicalTunnelAuthPayload,
910
fqdnForName,
1011
validateCertRequest,
1112
validateNameClaim,
1213
validateNameDelete,
1314
validateNameLabel,
15+
validateTunnelAuth,
1416
verifyCertRequest,
1517
verifyNameClaim,
1618
verifyNameDelete,
19+
verifyTunnelAuth,
1720
} from "./names.js";
1821
import { createTestCsr, createTestEcCsr } from "./test-support/csr.js";
1922
import { didKeySigner, pkhSigner } from "./test-support/signing.js";
@@ -241,6 +244,53 @@ test("validates and verifies a name delete record", async () => {
241244
assert.equal(await verifyNameDelete(record), true);
242245
});
243246

247+
test("validates and verifies a tunnel auth record", async () => {
248+
const signer = didKeySigner(40);
249+
const unsigned = {
250+
version: 1 as const,
251+
action: "tunnel" as const,
252+
name: "tunnelnode",
253+
subject: signer.subject,
254+
sequence: 2,
255+
};
256+
const signature = await signer.sign(canonicalTunnelAuthPayload(unsigned));
257+
const record = { ...unsigned, signature };
258+
259+
assert.deepEqual(validateTunnelAuth(record), record);
260+
assert.equal(await verifyTunnelAuth(record), true);
261+
});
262+
263+
test("rejects a tunnel auth record signed by the wrong key", async () => {
264+
const signer = didKeySigner(41);
265+
const other = didKeySigner(42);
266+
const unsigned = {
267+
version: 1 as const,
268+
action: "tunnel" as const,
269+
name: "tunnelnode",
270+
subject: signer.subject,
271+
sequence: 2,
272+
};
273+
const signature = await other.sign(canonicalTunnelAuthPayload(unsigned));
274+
const record = { ...unsigned, signature };
275+
276+
assert.equal(await verifyTunnelAuth(record), false);
277+
});
278+
279+
test("rejects a tunnel auth record with the wrong action", () => {
280+
assert.throws(
281+
() =>
282+
validateTunnelAuth({
283+
version: 1,
284+
action: "claim",
285+
name: "tunnelnode",
286+
subject: didKeySigner(43).subject,
287+
sequence: 1,
288+
signature: "x",
289+
}),
290+
NameError
291+
);
292+
});
293+
244294
test("validates and verifies a cert request record", async () => {
245295
const signer = didKeySigner(13);
246296
const domain = fqdnForName("certnode");

src/names.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ export function fqdnForName(name: string): string {
99
return `${name}.${DOMAIN_SUFFIX}`;
1010
}
1111

12+
// The tunnel relay's namespace: <name>.tinycloud.link (the public apex zone,
13+
// not the LAN-only local.tinycloud.link zone above). A name claimed via
14+
// PUT /v1/names/:name is the same name a subject can open a tunnel for --
15+
// there is one name registry, two surfaces (LAN A/AAAA records vs. a remote
16+
// WebSocket tunnel).
17+
export const REMOTE_DOMAIN_SUFFIX = "tinycloud.link";
18+
19+
export function remoteFqdnForName(name: string): string {
20+
return `${name}.${REMOTE_DOMAIN_SUFFIX}`;
21+
}
22+
1223
const NAME_LABEL_PATTERN = /^[a-z0-9]([a-z0-9-]{1,30}[a-z0-9])?$/;
1324

1425
// Reserved so a claimed name can never collide with infrastructure, the
@@ -209,6 +220,61 @@ export async function verifyNameDelete(record: NameDeleteRecord): Promise<boolea
209220
return verifySignedPayload(record.subject, canonicalDeletePayload(record), record.signature);
210221
}
211222

223+
// --- tunnel registration (first frame sent over wss://.../v1/tunnel/:name) ---
224+
//
225+
// Same signed-payload scheme as claim/delete/cert above: the node proves it
226+
// owns `name` (already claimed via PUT /v1/names/:name) with a signature
227+
// over a canonical payload and a strictly-increasing sequence, reusing the
228+
// name record's own sequence counter for replay protection.
229+
230+
export interface TunnelAuthPayload {
231+
version: 1;
232+
action: "tunnel";
233+
name: string;
234+
subject: string;
235+
sequence: number;
236+
}
237+
238+
export interface TunnelAuthRecord extends TunnelAuthPayload {
239+
signature: string;
240+
}
241+
242+
export function canonicalTunnelAuthPayload(payload: TunnelAuthPayload): string {
243+
return JSON.stringify({
244+
version: payload.version,
245+
action: payload.action,
246+
name: payload.name,
247+
subject: payload.subject,
248+
sequence: payload.sequence,
249+
});
250+
}
251+
252+
export function validateTunnelAuth(input: unknown): TunnelAuthRecord {
253+
if (input === null || typeof input !== "object") {
254+
throw new NameError("body must be an object");
255+
}
256+
const body = input as Partial<TunnelAuthRecord>;
257+
if (body.version !== 1) {
258+
throw new NameError("version must be 1");
259+
}
260+
if (body.action !== "tunnel") {
261+
throw new NameError('action must be "tunnel"');
262+
}
263+
264+
return {
265+
version: 1,
266+
action: "tunnel",
267+
name: validateNameLabel(body.name),
268+
subject: validateSubject(body.subject),
269+
sequence: validateSequence(body.sequence),
270+
signature: validateSignature(body.signature),
271+
};
272+
}
273+
274+
export async function verifyTunnelAuth(record: TunnelAuthRecord): Promise<boolean> {
275+
return verifySignedPayload(record.subject, canonicalTunnelAuthPayload(record), record.signature);
276+
}
277+
212278
// --- cert request (POST /v1/certs/:name) ---
213279

214280
export interface CertRequestPayload {

src/server.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import {
1414
verifyNameDelete,
1515
} from "./names.js";
1616
import type { CertRateLimiter, NameStore } from "./storage.js";
17+
import { DEFAULT_API_HOSTNAME, createTunnelMiddleware } from "./tunnel/host-router.js";
18+
import type { TunnelRegistry } from "./tunnel/registry.js";
1719

1820
const DAY_MS = 24 * 60 * 60 * 1000;
1921

@@ -25,6 +27,10 @@ export interface ServerConfig {
2527
attestationDocument?: string;
2628
certRateLimitPerDay?: number;
2729
nameUpdateRateLimitPerDay?: number;
30+
/** 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). */
31+
tunnelRegistry?: TunnelRegistry;
32+
/** The control-plane API's own hostname, exempted from tunnel routing. Defaults to "api.tinycloud.link". */
33+
apiHostname?: string;
2834
}
2935

3036
// Prefixes name-update entries so they share the cert rate-limit store without
@@ -38,6 +44,15 @@ export function createServer(config: ServerConfig): Hono {
3844
const rateLimit = config.certRateLimitPerDay ?? 5;
3945
const nameUpdateRateLimit = config.nameUpdateRateLimitPerDay ?? 30;
4046

47+
if (config.tunnelRegistry) {
48+
app.use(
49+
"*",
50+
createTunnelMiddleware(config.tunnelRegistry, {
51+
apiHostname: config.apiHostname ?? DEFAULT_API_HOSTNAME,
52+
})
53+
);
54+
}
55+
4156
app.get("/health", (c) => c.json({ ok: true }));
4257

4358
app.get("/attestation", (c) => {

0 commit comments

Comments
 (0)