@volter/twin-tunnel 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,339 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { appendAction, applyTwinWrite, listActions, projectResources, TwinActionPreconditionError, type TwinActionPrecondition } from '@volter/world-core';
3
+
4
+ const SERVICE = 'tunnel';
5
+
6
+ /** Message discriminators implemented or deliberately inventoried by the owned relay protocol.
7
+ * The capability manifest checks against this runtime-owned list; a test separately pins it to
8
+ * the installed first-party tunnel-core SDK without pulling that dev-only SDK into runtime code. */
9
+ export const TUNNEL_PROTOCOL_MESSAGE_TYPES = [
10
+ 'register', 'response', 'response-start', 'response-chunk', 'response-end',
11
+ 'ws-ready', 'ws-error', 'ws-message', 'ws-close', 'registered', 'request',
12
+ 'request-abort', 'ws-upgrade', 'quota', 'error',
13
+ ] as const;
14
+
15
+ export type TunnelRequest = {
16
+ method: string;
17
+ path: string;
18
+ body?: string;
19
+ headers?: Record<string, string | undefined>;
20
+ occurredAt?: string;
21
+ root?: string;
22
+ readOnly?: boolean;
23
+ liveTunnels?: number;
24
+ };
25
+
26
+ export type TunnelResponse = { status: number; body: unknown; headers?: Record<string, string> };
27
+
28
+ function nextRev(id: string, root?: string): number {
29
+ let max = 0;
30
+ for (const action of listActions(SERVICE, root)) {
31
+ if (action.subject?.id !== id) continue;
32
+ const rev = (action.fields as Record<string, unknown> | undefined)?.rev;
33
+ if (typeof rev === 'number') max = Math.max(max, rev);
34
+ }
35
+ return max + 1;
36
+ }
37
+
38
+ async function writeTunnel(
39
+ type: string,
40
+ id: string,
41
+ fields: Record<string, unknown>,
42
+ operation: string,
43
+ req: TunnelRequest,
44
+ preconditions?: TwinActionPrecondition[],
45
+ ): Promise<void> {
46
+ const occurredAt = req.occurredAt ?? new Date().toISOString();
47
+ await applyTwinWrite(SERVICE, {
48
+ operation,
49
+ subjectType: type,
50
+ subjectId: id,
51
+ fields: { ...fields, rev: nextRev(id, req.root), writtenAt: occurredAt },
52
+ occurredAt,
53
+ actor: { kind: 'agent', id: 'tunnel-twin' },
54
+ preconditions,
55
+ }, req.root);
56
+ }
57
+
58
+ /** Cloudflare's quick-tunnel allocation control API plus the owned relay health surface.
59
+ * The quick response follows cloudflared's first-party `QuickTunnelResponse` structure. */
60
+ export async function handleTunnelTwinRequest(req: TunnelRequest): Promise<TunnelResponse> {
61
+ const url = new URL(req.path, 'http://twin.local');
62
+ if (req.method === 'POST' && url.pathname === '/tunnel') {
63
+ if (req.readOnly) return {
64
+ status: 405,
65
+ body: { success: false, errors: [{ message: 'tunnel twin is read-only' }] },
66
+ headers: { 'content-type': 'application/json' },
67
+ };
68
+ // The allocation's identity is state (R9): id, name, hostname and account tag derive from
69
+ // the world instant and the quick tunnels already allocated, in cloudflared's shapes. The
70
+ // tunnel secret is the credential the connector authenticates with, so it stays random.
71
+ const held = projectResources(SERVICE, req.root).filter((r) => r.type === 'quick_tunnel').length;
72
+ const derive = (part: string, n: number): string => createHash('sha256').update(`quick_tunnel:${part}:${req.occurredAt ?? ''}:${held}`).digest('hex').slice(0, n);
73
+ const uuidOf = (hex: string): string => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
74
+ const id = uuidOf(derive('id', 32));
75
+ const name = derive('name', 16);
76
+ const hostname = `${derive('hostname', 16)}.trycloudflare.com`;
77
+ const accountTag = uuidOf(derive('account_tag', 32));
78
+ const secret = randomBytes(32).toString('base64');
79
+ await writeTunnel('quick_tunnel', `quick:${id}`, {
80
+ vendor: 'cloudflare', id, name, hostname, account_tag: accountTag, secret,
81
+ ephemeral: true, connected: false,
82
+ }, 'quick_tunnel.create', req);
83
+ return {
84
+ status: 200,
85
+ body: {
86
+ success: true,
87
+ result: { id, name, hostname, account_tag: accountTag, secret },
88
+ errors: [],
89
+ },
90
+ headers: { 'content-type': 'application/json' },
91
+ };
92
+ }
93
+ if (req.method === 'GET' && url.pathname === '/api/status') {
94
+ // Connection count is a live-process fact. The server adapter supplies its authoritative
95
+ // socket-map size; the pure reducer has no live sockets and therefore reports zero instead of
96
+ // treating an append-only historical registration as currently connected.
97
+ const tunnels = req.liveTunnels ?? 0;
98
+ return { status: 200, body: { ok: true, relay: 'twin-local', tunnels }, headers: { 'content-type': 'application/json' } };
99
+ }
100
+ return { status: 404, body: { error: 'not found' }, headers: { 'content-type': 'application/json' } };
101
+ }
102
+
103
+ export type RegisterMessage = {
104
+ type: 'register'; tunnelId?: string; secret?: string; replace?: boolean;
105
+ authRequired?: boolean; basicAuth?: { user: string; pass: string };
106
+ };
107
+
108
+ export type RelayControlMessage = RegisterMessage | {
109
+ type: 'response'; reqId: string | number; status: number; headers: Record<string, string | string[]>; body: string;
110
+ } | { type: 'response-start'; reqId: string | number; status: number; headers: Record<string, string | string[]> }
111
+ | { type: 'response-chunk'; reqId: string | number; data: string }
112
+ | { type: 'response-end'; reqId: string | number }
113
+ | { type: 'ws-ready' | 'ws-error' | 'ws-message' | 'ws-close'; [key: string]: unknown };
114
+
115
+ export type RelaySession = {
116
+ tunnelId?: string;
117
+ authRequired?: boolean;
118
+ basicAuth?: { user: string; pass: string };
119
+ registered?: boolean;
120
+ superseded?: boolean;
121
+ };
122
+
123
+ export type RelayReduction = {
124
+ session: RelaySession;
125
+ outbound?: Record<string, unknown>;
126
+ registration?: { tunnelId: string; authRequired: boolean };
127
+ };
128
+
129
+ export type PreparedRelayRegistration = RelayReduction & {
130
+ registration: { tunnelId: string; authRequired: boolean };
131
+ persistence: { tunnelId: string; fields: Record<string, unknown> };
132
+ };
133
+
134
+ /** Validate and normalize a registration without changing persisted or live ownership. The
135
+ * socket adapter uses this seam before it makes an existing owner inert, so a malformed takeover
136
+ * can never evict a healthy tunnel. */
137
+ export function prepareTunnelRelayRegistration(input: {
138
+ message: unknown;
139
+ session: RelaySession;
140
+ authoritativeId?: string;
141
+ publicBaseUrl: string;
142
+ readOnly?: boolean;
143
+ }): RelayReduction | PreparedRelayRegistration {
144
+ const message = input.message as Partial<RelayControlMessage> & Record<string, unknown>;
145
+ if (message.type !== 'register') return { session: input.session };
146
+ if (input.readOnly) return { session: input.session, outbound: { type: 'error', fatal: true, message: 'tunnel twin is read-only' } };
147
+ const requested = input.authoritativeId || (typeof message.tunnelId === 'string' ? message.tunnelId : '') || randomUUID().slice(0, 8);
148
+ if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(requested)) {
149
+ return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Invalid tunnel ID' } };
150
+ }
151
+ if (typeof message.secret !== 'string' || message.secret.length === 0) {
152
+ return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Tunnel rejected: credential is invalid or revoked.' } };
153
+ }
154
+ if (message.basicAuth !== undefined && (
155
+ !message.basicAuth || typeof message.basicAuth !== 'object'
156
+ || typeof (message.basicAuth as Record<string, unknown>).user !== 'string'
157
+ || typeof (message.basicAuth as Record<string, unknown>).pass !== 'string'
158
+ )) {
159
+ return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Invalid basic-auth configuration' } };
160
+ }
161
+ const basicAuthInput = message.basicAuth as { user: string; pass: string } | undefined;
162
+ // The owned relay configures its Basic gate only when BOTH values are non-empty. The SDK/CLI can
163
+ // emit `{ user: 'name', pass: '' }`; upstream accepts that registration but leaves Basic auth
164
+ // unconfigured, so the twin must not invent a gate for the empty-value edge.
165
+ const basicAuth = basicAuthInput?.user && basicAuthInput.pass ? basicAuthInput : undefined;
166
+ const authRequired = message.authRequired !== false;
167
+ const basicAuthHash = basicAuth
168
+ ? createHash('sha256').update(`${basicAuth.user}:${basicAuth.pass}`).digest('hex')
169
+ : null;
170
+ const session = { tunnelId: requested, authRequired, registered: true, ...(basicAuth ? { basicAuth } : {}) };
171
+ return {
172
+ session,
173
+ registration: { tunnelId: requested, authRequired },
174
+ persistence: {
175
+ tunnelId: requested,
176
+ fields: {
177
+ vendor: 'volter-tunnel', tunnel_id: requested, connected: true, auth_required: authRequired,
178
+ basic_auth_configured: basicAuth !== undefined,
179
+ basic_auth_sha256: basicAuthHash,
180
+ },
181
+ },
182
+ outbound: { type: 'registered', tunnelId: requested, url: `${input.publicBaseUrl}/__twin/tunnels/${encodeURIComponent(requested)}` },
183
+ };
184
+ }
185
+
186
+ export async function persistTunnelRelayRegistration(
187
+ prepared: PreparedRelayRegistration,
188
+ options: { root?: string; occurredAt?: string; registrationId?: string } = {},
189
+ ): Promise<void> {
190
+ await writeTunnel('relay_tunnel', `relay:${prepared.persistence.tunnelId}`, {
191
+ ...prepared.persistence.fields,
192
+ ...(options.registrationId ? { registration_id: options.registrationId } : {}),
193
+ },
194
+ 'relay.register', { method: 'WS', path: '/ws', root: options.root, occurredAt: options.occurredAt });
195
+ }
196
+
197
+ /** Undo exactly the registration action identified by the durable registration id. Persistence can
198
+ * append successfully and still reject afterward (for example while projecting the result). A
199
+ * kernel revert preserves that attempted write in the audit log while suppressing its projection,
200
+ * restoring either the prior live owner or the prior absence byte-for-byte. */
201
+ export function revertTunnelRelayRegistration(
202
+ tunnelId: string,
203
+ registrationId: string,
204
+ options: { root?: string; occurredAt?: string } = {},
205
+ ): boolean {
206
+ const actions = listActions(SERVICE, options.root);
207
+ const registration = [...actions].reverse().find((action) => action.operation === 'relay.register'
208
+ && action.subject.type === 'relay_tunnel' && action.subject.id === `relay:${tunnelId}`
209
+ && (action.fields as Record<string, unknown> | undefined)?.registration_id === registrationId);
210
+ if (!registration) return false;
211
+ if (actions.some((action) => action.op === 'revert' && action.revertsActionId === registration.id)) return true;
212
+ appendAction({
213
+ id: `twin:tunnel:relay.register.rollback:${registrationId}`,
214
+ service: SERVICE,
215
+ op: 'revert',
216
+ operation: 'relay.register.rollback',
217
+ subject: registration.subject,
218
+ occurredAt: options.occurredAt ?? new Date().toISOString(),
219
+ actor: { kind: 'system', id: 'tunnel-twin' },
220
+ revertsActionId: registration.id,
221
+ }, options.root);
222
+ return true;
223
+ }
224
+
225
+ /** Reducer behind the WebSocket adapter. It is deliberately in a separate module so the
226
+ * mutation gate can replace it while the real socket/data-path adapter remains intact. */
227
+ export async function handleTunnelRelayMessage(input: {
228
+ message: unknown;
229
+ session: RelaySession;
230
+ authoritativeId?: string;
231
+ publicBaseUrl: string;
232
+ root?: string;
233
+ occurredAt?: string;
234
+ readOnly?: boolean;
235
+ }): Promise<RelayReduction> {
236
+ const prepared = prepareTunnelRelayRegistration(input);
237
+ if (!('persistence' in prepared)) return prepared;
238
+ await persistTunnelRelayRegistration(prepared, { root: input.root, occurredAt: input.occurredAt });
239
+ const { persistence: _persistence, ...reduction } = prepared;
240
+ return reduction;
241
+ }
242
+
243
+ /**
244
+ * What the relay should DO with one connId-correlated WebSocket frame.
245
+ *
246
+ * `ws-ready` / `ws-message` / `ws-error` / `ws-close` are the four frames that address a bridged
247
+ * browser socket rather than an HTTP request, and the decision they lead to is PURE: it depends
248
+ * only on the frame and on who owns that connId. Keeping it here rather than inline in the socket
249
+ * adapter is the same split `handleTunnelRelayMessage` already uses, and for the same reason —
250
+ * the mutation gate can replace this seam while the real socket/data path stays intact, so the
251
+ * bridge's verifies are killed at the FRAME. §9 found they were only ever killed at REGISTRATION
252
+ * (every one of them traverses `prepareTunnelRelayRegistration` through `openControl`), which
253
+ * meant the bridge itself was reported as covered while nothing sabotaged it.
254
+ */
255
+ export type TunnelWsOwnership = 'self' | 'other' | 'unknown';
256
+ export type TunnelWsDecision =
257
+ /** Not a connId frame, or one whose bridge is already gone. Do nothing. */
258
+ | { kind: 'ignore' }
259
+ /** Answer the control socket with a protocol error. */
260
+ | { kind: 'error'; message: string }
261
+ /** The bridge is up: flush anything the browser said while it was coming up. */
262
+ | { kind: 'ready'; connId: string }
263
+ /** Send payload to the browser. */
264
+ | { kind: 'deliver'; connId: string; data: Uint8Array; binary: boolean }
265
+ /** End the browser socket with this code and reason. */
266
+ | { kind: 'close'; connId: string; code: number; reason: string };
267
+
268
+ /** Close codes 1005 ("no status") and 1006 ("abnormal") may never appear ON THE WIRE, and
269
+ * anything outside 1000-4999 is not a close code at all — the vendor's client clamps the
270
+ * identical way on the frames it receives. */
271
+ export function clampTunnelCloseCode(raw: unknown): number {
272
+ const code = typeof raw === 'number' ? raw : 1000;
273
+ return code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 ? code : 1000;
274
+ }
275
+
276
+ /** A close `reason` may carry at most 123 bytes. */
277
+ export function clampTunnelCloseReason(raw: unknown): string {
278
+ return typeof raw === 'string' ? raw.slice(0, 123) : '';
279
+ }
280
+
281
+ export function reduceTunnelWsFrame(input: { message: unknown; ownership: TunnelWsOwnership }): TunnelWsDecision {
282
+ const message = (input.message ?? {}) as Record<string, unknown>;
283
+ const type = message.type;
284
+ if (type !== 'ws-ready' && type !== 'ws-error' && type !== 'ws-message' && type !== 'ws-close') return { kind: 'ignore' };
285
+ const connId = typeof message.connId === 'string' ? message.connId : '';
286
+ // AN UNKNOWN connId IS NOT A VIOLATION — it is the normal tail of every bridged close, in both
287
+ // directions: the vendor's client closes its local socket on a relayed `ws-close`, and that
288
+ // socket's own close listener echoes a `ws-close` back after the relay has already torn the
289
+ // bridge down. Answering "not owned by this control socket" there accuses a CORRECT client of
290
+ // a protocol violation on the happy path (§9).
291
+ if (!connId || input.ownership === 'unknown') return { kind: 'ignore' };
292
+ // A control socket may only speak for the bridges IT was handed. Without this, any registered
293
+ // client could drive another tunnel's LIVE browser sockets by guessing a connId.
294
+ if (input.ownership === 'other') return { kind: 'error', message: 'websocket correlation is not owned by this control socket' };
295
+ if (type === 'ws-ready') return { kind: 'ready', connId };
296
+ if (type === 'ws-message') {
297
+ const raw = typeof message.data === 'string' ? message.data : '';
298
+ const data = Buffer.from(raw, 'base64');
299
+ // `Buffer.from(x, 'base64')` is LENIENT — it drops what it cannot parse — so the round trip
300
+ // is what rejects a payload that is not really base64, the same strictness the response
301
+ // frames' own decoder applies.
302
+ if (data.toString('base64').replace(/=+$/, '') !== raw.replace(/=+$/, '')) return { kind: 'error', message: 'malformed ws-message frame' };
303
+ return { kind: 'deliver', connId, data: new Uint8Array(data), binary: message.binary === true };
304
+ }
305
+ if (type === 'ws-error') {
306
+ // 1011 "internal error" with the CLIENT's own reason — the browser learns why, and the relay
307
+ // does not invent one of its own.
308
+ const reason = typeof message.error === 'string' ? message.error : 'tunnel websocket error';
309
+ return { kind: 'close', connId, code: 1011, reason: clampTunnelCloseReason(reason) };
310
+ }
311
+ return { kind: 'close', connId, code: clampTunnelCloseCode(message.code), reason: clampTunnelCloseReason(message.reason) };
312
+ }
313
+
314
+ /** Persist a disconnect only for the registration generation that actually lost its socket.
315
+ * Replacement appends its new generation before transferring the live controls map, so a close
316
+ * from the prior socket can arrive in that interval. The server serializes this conditional write
317
+ * behind the registration turn; the identity check then prevents the stale close from overwriting
318
+ * the successor's durable connected state. */
319
+ export async function markTunnelDisconnected(
320
+ tunnelId: string,
321
+ root?: string,
322
+ occurredAt?: string,
323
+ expectedRegistrationId?: string,
324
+ ): Promise<boolean> {
325
+ try {
326
+ await writeTunnel('relay_tunnel', `relay:${tunnelId}`, {
327
+ vendor: 'volter-tunnel', tunnel_id: tunnelId, connected: false,
328
+ }, 'relay.disconnect', { method: 'WS', path: '/ws', root, occurredAt }, expectedRegistrationId ? [{
329
+ subject: { type: 'relay_tunnel', id: `relay:${tunnelId}` },
330
+ field: 'registration_id',
331
+ op: 'eq',
332
+ value: expectedRegistrationId,
333
+ }] : undefined);
334
+ } catch (error) {
335
+ if (expectedRegistrationId && error instanceof TwinActionPreconditionError) return false;
336
+ throw error;
337
+ }
338
+ return true;
339
+ }