@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.
- package/README.md +123 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +59 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.js +42 -0
- package/dist/src/tunnel-budget.d.ts +21 -0
- package/dist/src/tunnel-budget.js +26 -0
- package/dist/src/tunnel-capabilities.d.ts +4 -0
- package/dist/src/tunnel-capabilities.js +2091 -0
- package/dist/src/tunnel-conformance.d.ts +10 -0
- package/dist/src/tunnel-conformance.js +63 -0
- package/dist/src/tunnel-connector.d.ts +21 -0
- package/dist/src/tunnel-connector.js +68 -0
- package/dist/src/tunnel-server.d.ts +109 -0
- package/dist/src/tunnel-server.js +1197 -0
- package/dist/src/tunnel-twin.d.ts +175 -0
- package/dist/src/tunnel-twin.js +236 -0
- package/package.json +51 -0
- package/src/cli.ts +48 -0
- package/src/index.ts +53 -0
- package/src/tunnel-budget.ts +34 -0
- package/src/tunnel-capabilities.ts +1770 -0
- package/src/tunnel-conformance.ts +62 -0
- package/src/tunnel-connector.ts +64 -0
- package/src/tunnel-server.ts +1194 -0
- package/src/tunnel-twin.ts +339 -0
|
@@ -0,0 +1,1770 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { hostname, tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { listActions, projectResources, worldPaths } from '@volter/world-core';
|
|
6
|
+
import { checkCapabilities, type CapabilityReport, type CapabilitySpec, verifyBoundary } from '@volter/world-tooling';
|
|
7
|
+
import { createTunnelTwinJwt, createTunnelTwinServer, tunnelControlPathIsReserved } from './tunnel-server.ts';
|
|
8
|
+
import { handleTunnelRelayMessage, handleTunnelTwinRequest, markTunnelDisconnected, persistTunnelRelayRegistration, prepareTunnelRelayRegistration, TUNNEL_PROTOCOL_MESSAGE_TYPES, type RelaySession } from './tunnel-twin.ts';
|
|
9
|
+
import { checkTunnelConformance } from './tunnel-conformance.ts';
|
|
10
|
+
import { liveTunnelExecute, mapTunnelRelay, syncTunnelFromReal } from './tunnel-connector.ts';
|
|
11
|
+
import { TunnelBudget, TunnelBudgetError, TUNNEL_BUDGET_CEILING, TUNNEL_CALL_WEIGHTS } from './tunnel-budget.ts';
|
|
12
|
+
|
|
13
|
+
const done = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier'], verify: CapabilitySpec['verify']): CapabilitySpec =>
|
|
14
|
+
({ id, area, title, dimension, tier, expected: 'done', verify });
|
|
15
|
+
const todo = (id: string, area: string, title: string, dimension: CapabilitySpec['dimension'], tier: CapabilitySpec['tier']): CapabilitySpec =>
|
|
16
|
+
({ id, area, title, dimension, tier, expected: 'todo' });
|
|
17
|
+
|
|
18
|
+
const AT = '2026-01-01T00:00:00.000Z';
|
|
19
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
20
|
+
async function withRoot(fn: (root: string) => Promise<boolean>): Promise<boolean> {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), 'tunnel-cap-'));
|
|
22
|
+
try { return await verifyBoundary('tunnel.withRoot', () => fn(root)); }
|
|
23
|
+
finally { rmSync(root, { recursive: true, force: true }); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function quick(root: string, over: Partial<{ method: string; path: string; readOnly: boolean }> = {}) {
|
|
27
|
+
return handleTunnelTwinRequest({ method: over.method ?? 'POST', path: over.path ?? '/tunnel', readOnly: over.readOnly, root, occurredAt: AT });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function nextSocketMessage(socket: WebSocket, type: string, timeoutMs = 3_000): Promise<Record<string, any>> {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error(`timed out waiting for ${type}`)); }, timeoutMs);
|
|
33
|
+
const onMessage = (event: MessageEvent) => {
|
|
34
|
+
const message = JSON.parse(String(event.data)) as Record<string, any>;
|
|
35
|
+
if (message.type !== type) return;
|
|
36
|
+
cleanup(); resolve(message);
|
|
37
|
+
};
|
|
38
|
+
const cleanup = () => { clearTimeout(timer); socket.removeEventListener('message', onMessage); };
|
|
39
|
+
socket.addEventListener('message', onMessage);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function nextSocketOutcome(socket: WebSocket, types: string[], timeoutMs = 3_000): Promise<Record<string, any>> {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error(`timed out waiting for ${types.join('/')}`)); }, timeoutMs);
|
|
46
|
+
const onMessage = (event: MessageEvent) => {
|
|
47
|
+
const message = JSON.parse(String(event.data)) as Record<string, any>;
|
|
48
|
+
if (!types.includes(String(message.type))) return;
|
|
49
|
+
cleanup(); resolve(message);
|
|
50
|
+
};
|
|
51
|
+
const cleanup = () => { clearTimeout(timer); socket.removeEventListener('message', onMessage); };
|
|
52
|
+
socket.addEventListener('message', onMessage);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function relayFrameOutcome(
|
|
57
|
+
control: Awaited<ReturnType<typeof openControl>>,
|
|
58
|
+
path: string,
|
|
59
|
+
frames: Array<Record<string, unknown>>,
|
|
60
|
+
): Promise<{ kind: 'response'; status: number; body: string } | { kind: 'error'; error: string } | { kind: 'timeout' }> {
|
|
61
|
+
const aborter = new AbortController();
|
|
62
|
+
const requestFrame = nextSocketMessage(control.socket, 'request');
|
|
63
|
+
const observed = fetch(`${control.url}${path}`, { signal: aborter.signal }).then(async (response) => {
|
|
64
|
+
try { return { kind: 'response' as const, status: response.status, body: await response.text() }; }
|
|
65
|
+
catch (error) { return { kind: 'error' as const, error: String(error) }; }
|
|
66
|
+
}, (error) => ({ kind: 'error' as const, error: String(error) }));
|
|
67
|
+
const request = await requestFrame;
|
|
68
|
+
for (const frame of frames) control.socket.send(JSON.stringify({ ...frame, reqId: request.reqId }));
|
|
69
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
70
|
+
const outcome = await Promise.race([
|
|
71
|
+
observed,
|
|
72
|
+
new Promise<{ kind: 'timeout' }>((resolve) => { timeout = setTimeout(() => resolve({ kind: 'timeout' }), 500); }),
|
|
73
|
+
]);
|
|
74
|
+
if (timeout) clearTimeout(timeout);
|
|
75
|
+
if (outcome.kind === 'timeout') aborter.abort();
|
|
76
|
+
return outcome;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function openControl(relayUrl: string, tunnelId: string, options: { replace?: boolean; authRequired?: boolean } = {}) {
|
|
80
|
+
const socket = new WebSocket(`${relayUrl.replace(/^http/, 'ws')}/ws`);
|
|
81
|
+
const registered = nextSocketMessage(socket, 'registered');
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
83
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
84
|
+
socket.addEventListener('error', () => reject(new Error('control socket failed')), { once: true });
|
|
85
|
+
});
|
|
86
|
+
socket.send(JSON.stringify({
|
|
87
|
+
type: 'register', tunnelId, secret: 'vt_fake_local', ...(options.replace ? { replace: true } : {}),
|
|
88
|
+
...(options.authRequired !== undefined ? { authRequired: options.authRequired } : {}),
|
|
89
|
+
}));
|
|
90
|
+
const message = await registered;
|
|
91
|
+
return { socket, url: String(message.url), tunnelId: String(message.tunnelId) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A relay, a registered control socket, and a REAL local WebSocket server — with the control
|
|
96
|
+
* socket bridging between them exactly as the owned client does.
|
|
97
|
+
*
|
|
98
|
+
* A HARNESS SHAPED AFTER `handleWsUpgrade`, not a reproduction of it — §9 was right to refuse
|
|
99
|
+
* the stronger word. What it does share with the vendor's client is everything the relay is
|
|
100
|
+
* judged on: dial the local server on `ws-upgrade` carrying the offered subprotocols, answer
|
|
101
|
+
* `ws-ready` when it opens and `ws-error` when it FAILS (a real listener, not a scripted one),
|
|
102
|
+
* strip the hop-by-hop handshake headers the client strips, forward local frames back as
|
|
103
|
+
* `ws-message` with the `binary` flag, and relay `ws-close`. What it does NOT reproduce, and
|
|
104
|
+
* does not need to because no assertion depends on it: the client's `x-forwarded-*`/`origin`
|
|
105
|
+
* re-stamping, its 15-second dial timeout, and `safeClose`'s FIN-not-RST teardown. Driving the
|
|
106
|
+
* twin over a genuine loopback WebSocket against a real local server is what makes these
|
|
107
|
+
* verifies transport proofs rather than assertions about the relay talking to itself.
|
|
108
|
+
*
|
|
109
|
+
* `mode` selects what the pretend client does with an upgrade: bridge it for real, hold `ws-ready`
|
|
110
|
+
* back (so the queueing behaviour is observable), or refuse it with `ws-error`.
|
|
111
|
+
*/
|
|
112
|
+
async function openWsBridge(root: string, mode: 'bridge' | 'hold' | 'refuse' = 'bridge') {
|
|
113
|
+
const app = Bun.serve<{ path: string }>({
|
|
114
|
+
hostname: '127.0.0.1',
|
|
115
|
+
port: 0,
|
|
116
|
+
fetch(request, server) {
|
|
117
|
+
const url = new URL(request.url);
|
|
118
|
+
// A real local server negotiates the subprotocol it was offered; without that, the
|
|
119
|
+
// relay's own echo could look correct while nothing downstream honoured it.
|
|
120
|
+
const offered = (request.headers.get('sec-websocket-protocol') ?? '').split(',').map((p) => p.trim()).filter(Boolean);
|
|
121
|
+
return server.upgrade(request, {
|
|
122
|
+
...(offered[0] ? { headers: { 'sec-websocket-protocol': offered[0] } } : {}),
|
|
123
|
+
data: { path: url.pathname + url.search },
|
|
124
|
+
}) ? undefined : new Response('expected websocket', { status: 426 });
|
|
125
|
+
},
|
|
126
|
+
websocket: {
|
|
127
|
+
message(ws, raw) {
|
|
128
|
+
// Text echoes with a marker; binary comes back REVERSED, so a verify can tell a genuine
|
|
129
|
+
// binary round trip from a text one that happened to survive.
|
|
130
|
+
if (typeof raw === 'string') ws.send(`echo:${ws.data.path}:${raw}`);
|
|
131
|
+
else ws.send(new Uint8Array([...Buffer.from(raw)].reverse()));
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
const relay = createTunnelTwinServer({ root });
|
|
136
|
+
const control = await openControl(relay.url, 'ws-bridge', { authRequired: false });
|
|
137
|
+
const locals = new Map<string, WebSocket>();
|
|
138
|
+
const frames: Array<Record<string, any>> = [];
|
|
139
|
+
const held: Array<Record<string, any>> = [];
|
|
140
|
+
control.socket.addEventListener('message', (event) => {
|
|
141
|
+
const message = JSON.parse(String(event.data)) as Record<string, any>;
|
|
142
|
+
frames.push(message);
|
|
143
|
+
if (message.type === 'ws-upgrade') {
|
|
144
|
+
if (mode === 'refuse') {
|
|
145
|
+
control.socket.send(JSON.stringify({ type: 'ws-error', connId: message.connId, error: 'ECONNREFUSED dialling local server' }));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
// The vendor's `handleWsUpgrade` reads `sec-websocket-protocol` off the frame's headers
|
|
149
|
+
// and passes it to the local dial as `protocols`; reproducing that is what makes the
|
|
150
|
+
// subprotocol assertion below a claim about the WHOLE path, not just the relay's 101.
|
|
151
|
+
const offered = String(message.headers?.['sec-websocket-protocol'] ?? '').split(',').map((p: string) => p.trim()).filter(Boolean);
|
|
152
|
+
const local = offered.length > 0
|
|
153
|
+
? new WebSocket(`ws://127.0.0.1:${app.port}${message.path}`, offered)
|
|
154
|
+
: new WebSocket(`ws://127.0.0.1:${app.port}${message.path}`);
|
|
155
|
+
local.binaryType = 'arraybuffer';
|
|
156
|
+
// The client's REAL failure path: a local socket that errors reports `ws-error` rather
|
|
157
|
+
// than leaving the browser hanging. `refuse` mode scripts that answer without dialling;
|
|
158
|
+
// this is the same frame arriving from an actual failed dial.
|
|
159
|
+
local.addEventListener('error', () => {
|
|
160
|
+
if (locals.get(String(message.connId)) !== local) return;
|
|
161
|
+
locals.delete(String(message.connId));
|
|
162
|
+
control.socket.send(JSON.stringify({ type: 'ws-error', connId: message.connId, error: 'local websocket failed' }));
|
|
163
|
+
});
|
|
164
|
+
locals.set(String(message.connId), local);
|
|
165
|
+
local.addEventListener('open', () => {
|
|
166
|
+
if (mode === 'hold') { held.push(message); return; }
|
|
167
|
+
control.socket.send(JSON.stringify({ type: 'ws-ready', connId: message.connId }));
|
|
168
|
+
});
|
|
169
|
+
local.addEventListener('message', (localEvent) => {
|
|
170
|
+
const binary = typeof localEvent.data !== 'string';
|
|
171
|
+
const bytes = binary ? Buffer.from(localEvent.data as ArrayBuffer) : Buffer.from(String(localEvent.data), 'utf8');
|
|
172
|
+
control.socket.send(JSON.stringify({ type: 'ws-message', connId: message.connId, data: bytes.toString('base64'), binary }));
|
|
173
|
+
});
|
|
174
|
+
local.addEventListener('close', (closeEvent) => {
|
|
175
|
+
control.socket.send(JSON.stringify({ type: 'ws-close', connId: message.connId, code: closeEvent.code, reason: closeEvent.reason }));
|
|
176
|
+
locals.delete(String(message.connId));
|
|
177
|
+
});
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (message.type === 'ws-message') {
|
|
181
|
+
const local = locals.get(String(message.connId));
|
|
182
|
+
const bytes = Buffer.from(String(message.data), 'base64');
|
|
183
|
+
if (local?.readyState === WebSocket.OPEN) local.send(message.binary === true ? new Uint8Array(bytes) : bytes.toString('utf8'));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (message.type === 'ws-close') {
|
|
187
|
+
const local = locals.get(String(message.connId));
|
|
188
|
+
if (local) { local.close(); locals.delete(String(message.connId)); }
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
relay, control, app, frames, held, locals,
|
|
193
|
+
/** Release a held `ws-ready` — the "the local socket finally came up" moment. */
|
|
194
|
+
release() { for (const message of held.splice(0)) control.socket.send(JSON.stringify({ type: 'ws-ready', connId: message.connId })); },
|
|
195
|
+
/**
|
|
196
|
+
* Open a BROWSER socket through the tunnel and return it with the `connId` the relay
|
|
197
|
+
* announced for it.
|
|
198
|
+
*
|
|
199
|
+
* It waits for the `ws-upgrade` FRAME, not just for the browser handshake: the browser's
|
|
200
|
+
* `open` fires before the relay's own socket-open handler has sent the frame, so reading
|
|
201
|
+
* `frames` straight after the handshake is a race that reports "no upgrade" on a bridge that
|
|
202
|
+
* is about to work.
|
|
203
|
+
*/
|
|
204
|
+
async visitor(path: string, protocols: string[] = []): Promise<{ socket: WebSocket; upgrade: Record<string, any> }> {
|
|
205
|
+
const before = frames.filter((frame) => frame.type === 'ws-upgrade').length;
|
|
206
|
+
const socket = protocols.length > 0
|
|
207
|
+
? new WebSocket(`${control.url.replace(/^http/, 'ws')}${path}`, protocols)
|
|
208
|
+
: new WebSocket(`${control.url.replace(/^http/, 'ws')}${path}`);
|
|
209
|
+
socket.binaryType = 'arraybuffer';
|
|
210
|
+
await new Promise<void>((resolve, reject) => {
|
|
211
|
+
const timer = setTimeout(() => reject(new Error('visitor websocket did not open')), 3_000);
|
|
212
|
+
socket.addEventListener('open', () => { clearTimeout(timer); resolve(); }, { once: true });
|
|
213
|
+
socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('visitor websocket failed')); }, { once: true });
|
|
214
|
+
});
|
|
215
|
+
const deadline = Date.now() + 3_000;
|
|
216
|
+
let upgrades = frames.filter((frame) => frame.type === 'ws-upgrade');
|
|
217
|
+
while (upgrades.length <= before) {
|
|
218
|
+
if (Date.now() > deadline) throw new Error('the relay never sent ws-upgrade for the visitor socket');
|
|
219
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
220
|
+
upgrades = frames.filter((frame) => frame.type === 'ws-upgrade');
|
|
221
|
+
}
|
|
222
|
+
return { socket, upgrade: upgrades[upgrades.length - 1]! };
|
|
223
|
+
},
|
|
224
|
+
stop() {
|
|
225
|
+
for (const local of locals.values()) local.close();
|
|
226
|
+
control.socket.close();
|
|
227
|
+
relay.stop();
|
|
228
|
+
app.stop(true);
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* READ THE 101 ITSELF — the raw bytes of a WebSocket handshake response.
|
|
235
|
+
*
|
|
236
|
+
* §9 caught the subprotocol assertion being a fixture artifact: Bun's WebSocket CLIENT does not
|
|
237
|
+
* enforce RFC 6455 §4.1 for a single-element offer, so `socket.protocol` reports what the client
|
|
238
|
+
* REQUESTED and stays green whether or not the relay echoed anything. Response headers are
|
|
239
|
+
* likewise invisible to that client, so `Set-Cookie` on an upgrade cannot be seen through it at
|
|
240
|
+
* all. A raw TCP handshake reads what the relay actually WROTE, which is the only thing either
|
|
241
|
+
* claim is about.
|
|
242
|
+
*/
|
|
243
|
+
async function readUpgradeHandshake(url: string, headers: Record<string, string> = {}): Promise<{ status: number; headers: Record<string, string> }> {
|
|
244
|
+
const target = new URL(url.replace(/^ws/, 'http'));
|
|
245
|
+
const lines = [
|
|
246
|
+
`GET ${target.pathname}${target.search} HTTP/1.1`,
|
|
247
|
+
`Host: ${target.host}`,
|
|
248
|
+
'Upgrade: websocket',
|
|
249
|
+
'Connection: Upgrade',
|
|
250
|
+
// A fixed key: `Sec-WebSocket-Accept` is not what is under test, and a fixed one keeps the
|
|
251
|
+
// handshake a pure function of the request.
|
|
252
|
+
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
|
|
253
|
+
'Sec-WebSocket-Version: 13',
|
|
254
|
+
...Object.entries(headers).map(([key, value]) => `${key}: ${value}`),
|
|
255
|
+
];
|
|
256
|
+
return new Promise((resolve, reject) => {
|
|
257
|
+
let buffer = '';
|
|
258
|
+
let settled = false;
|
|
259
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('handshake timed out')); } }, 3_000);
|
|
260
|
+
const finish = (fn: () => void) => { if (settled) return; settled = true; clearTimeout(timer); fn(); };
|
|
261
|
+
void Bun.connect({
|
|
262
|
+
hostname: target.hostname,
|
|
263
|
+
port: Number(target.port),
|
|
264
|
+
socket: {
|
|
265
|
+
open(socket) { socket.write(`${lines.join('\r\n')}\r\n\r\n`); },
|
|
266
|
+
data(socket, chunk) {
|
|
267
|
+
buffer += Buffer.from(chunk).toString('latin1');
|
|
268
|
+
const end = buffer.indexOf('\r\n\r\n');
|
|
269
|
+
if (end === -1) return;
|
|
270
|
+
finish(() => {
|
|
271
|
+
const [statusLine, ...rest] = buffer.slice(0, end).split('\r\n');
|
|
272
|
+
const parsed: Record<string, string> = {};
|
|
273
|
+
for (const line of rest) {
|
|
274
|
+
const colon = line.indexOf(':');
|
|
275
|
+
if (colon > 0) parsed[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
276
|
+
}
|
|
277
|
+
socket.end();
|
|
278
|
+
resolve({ status: Number(statusLine?.split(' ')[1] ?? 0), headers: parsed });
|
|
279
|
+
});
|
|
280
|
+
},
|
|
281
|
+
error(_socket, error) { finish(() => reject(error)); },
|
|
282
|
+
close() { finish(() => reject(new Error('handshake socket closed before a response head'))); },
|
|
283
|
+
},
|
|
284
|
+
}).catch((error: unknown) => finish(() => reject(error as Error)));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The next frame a visitor socket receives, or `undefined` if it closes first. */
|
|
289
|
+
function nextVisitorFrame(socket: WebSocket, timeoutMs = 3_000): Promise<{ data: string | ArrayBuffer } | { closed: { code: number; reason: string } }> {
|
|
290
|
+
return new Promise((resolve, reject) => {
|
|
291
|
+
const timer = setTimeout(() => { cleanup(); reject(new Error('timed out waiting for a visitor frame')); }, timeoutMs);
|
|
292
|
+
const onMessage = (event: MessageEvent) => { cleanup(); resolve({ data: event.data as string | ArrayBuffer }); };
|
|
293
|
+
const onClose = (event: CloseEvent) => { cleanup(); resolve({ closed: { code: event.code, reason: event.reason } }); };
|
|
294
|
+
const cleanup = () => { clearTimeout(timer); socket.removeEventListener('message', onMessage); socket.removeEventListener('close', onClose); };
|
|
295
|
+
socket.addEventListener('message', onMessage);
|
|
296
|
+
socket.addEventListener('close', onClose);
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async function protocolRoundtrip(root: string, options: { basicAuth?: { user: string; pass: string }; sendBasicAuth?: boolean; request?: RequestInit; path?: string } = {}) {
|
|
301
|
+
const app = Bun.serve({ hostname: '127.0.0.1', port: 0, async fetch(req) {
|
|
302
|
+
const body = await req.text();
|
|
303
|
+
return new Response(JSON.stringify({ method: req.method, path: new URL(req.url).pathname + new URL(req.url).search, body, marker: 'genuine-origin' }), {
|
|
304
|
+
status: 201, headers: { 'content-type': 'application/json', 'x-origin': 'loopback' },
|
|
305
|
+
});
|
|
306
|
+
} });
|
|
307
|
+
const relay = createTunnelTwinServer({ root });
|
|
308
|
+
const socket = new WebSocket(relay.url.replace(/^http/, 'ws') + '/ws');
|
|
309
|
+
try {
|
|
310
|
+
const handle = await new Promise<{ tunnelId: string; url: string }>((resolve, reject) => {
|
|
311
|
+
const timer = setTimeout(() => reject(new Error('relay registration timed out')), 5_000);
|
|
312
|
+
socket.addEventListener('open', () => socket.send(JSON.stringify({
|
|
313
|
+
type: 'register', tunnelId: 'protocol-proof', secret: 'vt_fake_local', authRequired: false,
|
|
314
|
+
...(options.basicAuth ? { basicAuth: options.basicAuth } : {}),
|
|
315
|
+
})));
|
|
316
|
+
socket.addEventListener('message', (event) => {
|
|
317
|
+
const message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
318
|
+
if (message.type === 'registered') { clearTimeout(timer); resolve({ tunnelId: String(message.tunnelId), url: String(message.url) }); }
|
|
319
|
+
if (message.type === 'error') { clearTimeout(timer); reject(new Error(String(message.message))); }
|
|
320
|
+
});
|
|
321
|
+
socket.addEventListener('error', () => { clearTimeout(timer); reject(new Error('relay socket failed')); });
|
|
322
|
+
});
|
|
323
|
+
socket.addEventListener('message', async (event) => {
|
|
324
|
+
const message = JSON.parse(String(event.data)) as Record<string, any>;
|
|
325
|
+
if (message.type !== 'request') return;
|
|
326
|
+
const originResponse = await fetch(`http://127.0.0.1:${app.port}${message.path}`, {
|
|
327
|
+
method: message.method,
|
|
328
|
+
headers: message.headers,
|
|
329
|
+
...(message.body ? { body: Buffer.from(message.body, 'base64') } : {}),
|
|
330
|
+
});
|
|
331
|
+
const responseHeaders: Record<string, string> = {};
|
|
332
|
+
originResponse.headers.forEach((value, key) => { responseHeaders[key] = value; });
|
|
333
|
+
socket.send(JSON.stringify({
|
|
334
|
+
type: 'response', reqId: message.reqId, status: originResponse.status,
|
|
335
|
+
headers: responseHeaders, body: Buffer.from(await originResponse.arrayBuffer()).toString('base64'),
|
|
336
|
+
}));
|
|
337
|
+
});
|
|
338
|
+
const headers = new Headers(options.request?.headers);
|
|
339
|
+
if (options.basicAuth && options.sendBasicAuth !== false) headers.set('authorization', `Basic ${Buffer.from(`${options.basicAuth.user}:${options.basicAuth.pass}`).toString('base64')}`);
|
|
340
|
+
const response = await fetch(`${handle.url}${options.path ?? '/hello?x=1'}`, { ...options.request, headers });
|
|
341
|
+
const responseBody = await response.text();
|
|
342
|
+
let body: Record<string, unknown> = {};
|
|
343
|
+
try { body = JSON.parse(responseBody) as Record<string, unknown>; } catch { body = { text: responseBody }; }
|
|
344
|
+
return { response, body, handle };
|
|
345
|
+
} finally {
|
|
346
|
+
socket.close(); relay.stop(); app.stop(true);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function jwtCookieProof(root: string): Promise<boolean> {
|
|
351
|
+
const relay = createTunnelTwinServer({ root });
|
|
352
|
+
const control = await openControl(relay.url, 'cookie-proof');
|
|
353
|
+
try {
|
|
354
|
+
const token = relay.mintToken('cookie-proof');
|
|
355
|
+
const wrongBootstrap = await fetch(relay.authUrl('cookie-proof', relay.mintToken('different-tunnel')));
|
|
356
|
+
if (wrongBootstrap.status !== 401 || wrongBootstrap.headers.get('set-cookie') !== null) return false;
|
|
357
|
+
const bootstrap = await fetch(relay.authUrl('cookie-proof', token));
|
|
358
|
+
const cookie = bootstrap.headers.get('set-cookie')?.split(';')[0];
|
|
359
|
+
if (bootstrap.status !== 200 || !cookie) return false;
|
|
360
|
+
const requestFrame = nextSocketMessage(control.socket, 'request');
|
|
361
|
+
const responsePromise = fetch(`${control.url}/cookie`, { headers: { cookie } });
|
|
362
|
+
const request = await requestFrame;
|
|
363
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('cookie-ok') }));
|
|
364
|
+
const response = await responsePromise;
|
|
365
|
+
const wrongToken = relay.mintToken('different-tunnel');
|
|
366
|
+
const wrong = await fetch(`${control.url}/cookie`, { headers: { authorization: `Bearer ${wrongToken}` } });
|
|
367
|
+
return response.status === 200 && await response.text() === 'cookie-ok'
|
|
368
|
+
&& !String(request.headers.cookie ?? '').includes('__volter_auth') && wrong.status === 401;
|
|
369
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export const TUNNEL_CAPABILITIES: CapabilitySpec[] = [
|
|
373
|
+
// Cloudflare Quick Tunnels — control plane from the official cloudflared source + docs.
|
|
374
|
+
done('tunnel.cloudflare.quick.create', 'cloudflare_quick', 'POST /tunnel allocates a successful quick tunnel', 'api', 'core', () => withRoot(async (root) => {
|
|
375
|
+
const r = await quick(root); const b = r.body as any;
|
|
376
|
+
return r.status === 200 && b.success === true && b.errors.length === 0 && UUID_PATTERN.test(b.result.id);
|
|
377
|
+
})),
|
|
378
|
+
done('tunnel.cloudflare.quick.response_shape', 'cloudflare_quick', 'Allocation returns id/name/hostname/account_tag/secret in cloudflared\'s first-party response shape', 'api', 'core', () => withRoot(async (root) => {
|
|
379
|
+
const b = (await quick(root)).body as any;
|
|
380
|
+
return JSON.stringify(Object.keys(b).sort()) === JSON.stringify(['errors', 'result', 'success'])
|
|
381
|
+
&& JSON.stringify(Object.keys(b.result).sort()) === JSON.stringify(['account_tag', 'hostname', 'id', 'name', 'secret'])
|
|
382
|
+
&& UUID_PATTERN.test(b.result.id)
|
|
383
|
+
&& typeof b.result.name === 'string' && b.result.name.length > 0
|
|
384
|
+
&& typeof b.result.hostname === 'string' && /^[^.]+\.trycloudflare\.com$/.test(b.result.hostname)
|
|
385
|
+
&& typeof b.result.account_tag === 'string' && b.result.account_tag.length > 0
|
|
386
|
+
&& typeof b.result.secret === 'string' && b.result.secret.length > 0
|
|
387
|
+
&& Buffer.from(b.result.secret, 'base64').toString('base64') === b.result.secret;
|
|
388
|
+
})),
|
|
389
|
+
done('tunnel.cloudflare.quick.no_account', 'cloudflare_quick', 'Quick allocation requires no Cloudflare account token', 'api', 'core', () => withRoot(async (root) => {
|
|
390
|
+
const r = await handleTunnelTwinRequest({ method: 'POST', path: '/tunnel', headers: {}, root, occurredAt: AT });
|
|
391
|
+
return r.status === 200 && (r.body as any).success === true;
|
|
392
|
+
})),
|
|
393
|
+
done('tunnel.cloudflare.quick.unique_allocations', 'cloudflare_quick', 'Two allocations receive different ids, hostnames, and secrets', 'api', 'common', () => withRoot(async (root) => {
|
|
394
|
+
const a = (await quick(root)).body as any; const b = (await quick(root)).body as any;
|
|
395
|
+
return a.result.id !== b.result.id && a.result.hostname !== b.result.hostname && a.result.secret !== b.result.secret;
|
|
396
|
+
})),
|
|
397
|
+
done('tunnel.cloudflare.quick.persisted', 'cloudflare_quick', 'Allocation is folded into the kernel as an ephemeral quick_tunnel resource', 'api', 'common', () => withRoot(async (root) => {
|
|
398
|
+
const r = await quick(root); const id = (r.body as any).result.id;
|
|
399
|
+
return projectResources('tunnel', root).some((x) => x.id === `quick:${id}` && x.type === 'quick_tunnel' && x.ephemeral === true);
|
|
400
|
+
})),
|
|
401
|
+
done('tunnel.cloudflare.quick.readonly_refusal', 'cloudflare_quick', 'Twin-only read-only mode refuses allocation without claiming a Cloudflare error code', 'api', 'niche', () => withRoot(async (root) => {
|
|
402
|
+
const relay = createTunnelTwinServer({ root, readOnly: true });
|
|
403
|
+
try {
|
|
404
|
+
const response = await fetch(`${relay.url}/tunnel`, { method: 'POST' });
|
|
405
|
+
const body = await response.json() as any;
|
|
406
|
+
return response.status === 405 && response.headers.get('content-type')?.startsWith('application/json') === true
|
|
407
|
+
&& body.success === false && body.errors.length === 1
|
|
408
|
+
&& JSON.stringify(Object.keys(body.errors[0]).sort()) === JSON.stringify(['message'])
|
|
409
|
+
&& body.errors[0].message === 'tunnel twin is read-only' && body.errors[0].code === undefined
|
|
410
|
+
&& listActions('tunnel', root).length === 0;
|
|
411
|
+
} finally { relay.stop(); }
|
|
412
|
+
})),
|
|
413
|
+
done('tunnel.cloudflare.quick.unknown_route', 'cloudflare_quick', 'An unmodeled Cloudflare route fails locally instead of fabricating success', 'api', 'common', () => withRoot(async (root) => (await quick(root, { method: 'GET', path: '/accounts' })).status === 404)),
|
|
414
|
+
done('tunnel.cloudflare.quick.adapter_route_boundary', 'cloudflare_quick', 'Reserved management paths still refuse after origin registration while ordinary app paths forward', 'api', 'core', () => withRoot(async (root) => {
|
|
415
|
+
const hits: string[] = [];
|
|
416
|
+
const origin = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: (request) => {
|
|
417
|
+
hits.push(new URL(request.url).pathname);
|
|
418
|
+
return new Response('app-route');
|
|
419
|
+
} });
|
|
420
|
+
const relay = createTunnelTwinServer({ root });
|
|
421
|
+
try {
|
|
422
|
+
const allocated = await fetch(`${relay.url}/tunnel`, { method: 'POST' }).then((r) => r.json()) as any;
|
|
423
|
+
relay.registerQuickOrigin(allocated.result.id, `http://127.0.0.1:${origin.port}`);
|
|
424
|
+
const reservedPaths = [
|
|
425
|
+
'/', '/docs', '/__volter_inspect', '/__volter_replay',
|
|
426
|
+
'/accounts', '/client/v4/accounts', '/me', '/admin/accounts', '/signup/github', '/report', '/waitlist',
|
|
427
|
+
'/api/status/unmodeled', '/%2Faccounts', '/%252Fadmin/accounts', '//signup/github',
|
|
428
|
+
'/%25252525252525252Faccounts',
|
|
429
|
+
];
|
|
430
|
+
if (!tunnelControlPathIsReserved(`/${'x'.repeat(8_193)}`)
|
|
431
|
+
|| !tunnelControlPathIsReserved('/%ZZ/accounts')
|
|
432
|
+
|| tunnelControlPathIsReserved('/product/%252Ffloor')) return false;
|
|
433
|
+
const reserved = await Promise.all(reservedPaths.map(async (path) => {
|
|
434
|
+
const response = await fetch(`${relay.url}${path}`);
|
|
435
|
+
return response.status === 404 && (await response.json() as any).error === 'not found';
|
|
436
|
+
}));
|
|
437
|
+
const app = await fetch(`${relay.url}/product/floor`);
|
|
438
|
+
const appApi = await fetch(`${relay.url}/api/application`);
|
|
439
|
+
const encodedApp = await fetch(`${relay.url}/product/%252Ffloor`);
|
|
440
|
+
return reserved.every(Boolean) && app.status === 200 && await app.text() === 'app-route'
|
|
441
|
+
&& appApi.status === 200 && await appApi.text() === 'app-route'
|
|
442
|
+
&& encodedApp.status === 200 && await encodedApp.text() === 'app-route'
|
|
443
|
+
&& JSON.stringify(hits) === JSON.stringify(['/product/floor', '/api/application', '/product/%252Ffloor']);
|
|
444
|
+
} finally { relay.stop(); origin.stop(true); }
|
|
445
|
+
})),
|
|
446
|
+
done('tunnel.cloudflare.quick.local_http_path', 'cloudflare_quick', 'Local quick adapter forwards bytes over a genuine loopback HTTP path', 'api', 'core', () => withRoot(async (root) => {
|
|
447
|
+
const origin = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('real-loopback-body', { headers: { 'x-origin': 'yes' } }) });
|
|
448
|
+
const relay = createTunnelTwinServer({ root });
|
|
449
|
+
try {
|
|
450
|
+
const allocated = await fetch(`${relay.url}/tunnel`, { method: 'POST' }).then((r) => r.json()) as any;
|
|
451
|
+
relay.registerQuickOrigin(allocated.result.id, `http://127.0.0.1:${origin.port}`);
|
|
452
|
+
const response = await fetch(`${relay.quickUrl(allocated.result.id)}/health`);
|
|
453
|
+
return response.headers.get('x-origin') === 'yes' && await response.text() === 'real-loopback-body';
|
|
454
|
+
} finally { relay.stop(); origin.stop(true); }
|
|
455
|
+
})),
|
|
456
|
+
done('tunnel.cloudflare.quick.origin_boundary', 'cloudflare_quick', 'Quick-origin registration accepts allocated credential-free IPv4/IPv6 loopback http(s) URLs and forwarded paths cannot replace that origin', 'api', 'core', () => withRoot(async (root) => {
|
|
457
|
+
const relay = createTunnelTwinServer({ root });
|
|
458
|
+
const registeredHits: Array<{ path: string; search: string }> = [];
|
|
459
|
+
const decoyHits: Array<{ path: string; search: string }> = [];
|
|
460
|
+
const registeredOrigin = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: (request) => {
|
|
461
|
+
const url = new URL(request.url);
|
|
462
|
+
registeredHits.push({ path: url.pathname, search: url.search });
|
|
463
|
+
return new Response('registered-origin');
|
|
464
|
+
} });
|
|
465
|
+
const decoyOrigin = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: (request) => {
|
|
466
|
+
const url = new URL(request.url);
|
|
467
|
+
decoyHits.push({ path: url.pathname, search: url.search });
|
|
468
|
+
return new Response('decoy-origin');
|
|
469
|
+
} });
|
|
470
|
+
try {
|
|
471
|
+
const allocated = await fetch(`${relay.url}/tunnel`, { method: 'POST' }).then((r) => r.json()) as any;
|
|
472
|
+
let remote = false; let scheme = false; let unknown = false; let credentialed = false;
|
|
473
|
+
let decorated = false; let backslash = false; let strictRaw = true;
|
|
474
|
+
try { relay.registerQuickOrigin(allocated.result.id, 'https://example.com'); } catch { remote = true; }
|
|
475
|
+
try { relay.registerQuickOrigin(allocated.result.id, 'file:///tmp/secret'); } catch { scheme = true; }
|
|
476
|
+
try { relay.registerQuickOrigin('not-allocated', 'http://127.0.0.1:3000'); } catch { unknown = true; }
|
|
477
|
+
try { relay.registerQuickOrigin(allocated.result.id, 'http://user:pass@127.0.0.1:3000'); } catch { credentialed = true; }
|
|
478
|
+
try { relay.registerQuickOrigin(allocated.result.id, 'http://127.0.0.1:3000/base?secret=1#fragment'); } catch { decorated = true; }
|
|
479
|
+
try { relay.registerQuickOrigin(allocated.result.id, 'http:\\\\127.0.0.1:3000'); } catch { backslash = true; }
|
|
480
|
+
for (const invalid of [
|
|
481
|
+
'http://127.0.0.1:3000/%2e%2e',
|
|
482
|
+
'http://127.1:3000',
|
|
483
|
+
'http://2130706433:3000',
|
|
484
|
+
'http://0x7f000001:3000',
|
|
485
|
+
'http://0177.0.0.1:3000',
|
|
486
|
+
'http://127.000.000.001:3000',
|
|
487
|
+
'http://%31%32%37.0.0.1:3000',
|
|
488
|
+
'http://127%2e0%2e0%2e1:3000',
|
|
489
|
+
'http://127.0.0.1.:3000',
|
|
490
|
+
'http://127.0.0.1:03000',
|
|
491
|
+
'http://[::ffff:127.0.0.1]:3000',
|
|
492
|
+
]) {
|
|
493
|
+
try { relay.registerQuickOrigin(allocated.result.id, invalid); strictRaw = false; } catch { /* expected */ }
|
|
494
|
+
}
|
|
495
|
+
relay.registerQuickOrigin(allocated.result.id, 'http://localhost:3000');
|
|
496
|
+
relay.registerQuickOrigin(allocated.result.id, 'https://localhost:3000');
|
|
497
|
+
relay.registerQuickOrigin(allocated.result.id, 'http://127.255.255.254:3000');
|
|
498
|
+
relay.registerQuickOrigin(allocated.result.id, 'http://[::1]:3000');
|
|
499
|
+
relay.registerQuickOrigin(allocated.result.id, 'http://[0:0:0:0:0:0:0:1]:3000');
|
|
500
|
+
relay.registerQuickOrigin(allocated.result.id, `http://127.0.0.1:${registeredOrigin.port}`);
|
|
501
|
+
const explicit = await fetch(`${relay.quickUrl(allocated.result.id)}//127.0.0.1:${decoyOrigin.port}/explicit?next=https%3A%2F%2Fdecoy.example`);
|
|
502
|
+
const explicitEncoded = await fetch(`${relay.quickUrl(allocated.result.id)}/%2F%2F127.0.0.1:${decoyOrigin.port}/encoded?q=%2F%2Fevil.example`);
|
|
503
|
+
const fallback = await fetch(`${relay.url}//127.0.0.1:${decoyOrigin.port}/fallback?next=https%3A%2F%2Fdecoy.example`);
|
|
504
|
+
const fallbackEncoded = await fetch(`${relay.url}/%2F%2F127.0.0.1:${decoyOrigin.port}/encoded?q=%2F%2Fevil.example`);
|
|
505
|
+
return remote && scheme && unknown && credentialed && decorated && backslash && strictRaw
|
|
506
|
+
&& (await Promise.all([explicit, explicitEncoded, fallback, fallbackEncoded].map(async (response) => response.status === 200 && await response.text() === 'registered-origin'))).every(Boolean)
|
|
507
|
+
&& JSON.stringify(registeredHits) === JSON.stringify([
|
|
508
|
+
{ path: `/127.0.0.1:${decoyOrigin.port}/explicit`, search: '?next=https%3A%2F%2Fdecoy.example' },
|
|
509
|
+
{ path: `/%2F%2F127.0.0.1:${decoyOrigin.port}/encoded`, search: '?q=%2F%2Fevil.example' },
|
|
510
|
+
{ path: `/127.0.0.1:${decoyOrigin.port}/fallback`, search: '?next=https%3A%2F%2Fdecoy.example' },
|
|
511
|
+
{ path: `/%2F%2F127.0.0.1:${decoyOrigin.port}/encoded`, search: '?q=%2F%2Fevil.example' },
|
|
512
|
+
])
|
|
513
|
+
&& decoyHits.length === 0;
|
|
514
|
+
} finally { relay.stop(); registeredOrigin.stop(true); decoyOrigin.stop(true); }
|
|
515
|
+
})),
|
|
516
|
+
done('tunnel.conformance.control_probe', 'volter_management', 'Offline conformance drives each claimed control route and relay registration through live seams', 'api', 'core', async () => {
|
|
517
|
+
const report = await checkTunnelConformance();
|
|
518
|
+
return report.ok && report.probes === 4 && report.violations.length === 0;
|
|
519
|
+
}),
|
|
520
|
+
done('tunnel.errors.json_containment', 'volter_management', 'HTTP, storage, and origin failures stay in stable JSON envelopes without runtime stack pages', 'api', 'core', () => withRoot(async (root) => {
|
|
521
|
+
const relay = createTunnelTwinServer({ root: '/dev/null/tunnel-impossible' });
|
|
522
|
+
try {
|
|
523
|
+
const storage = await fetch(`${relay.url}/tunnel`, { method: 'POST' });
|
|
524
|
+
const disconnected = await fetch(`${relay.url}/__twin/tunnels/missing/path`);
|
|
525
|
+
const storageBody = await storage.json() as Record<string, unknown>;
|
|
526
|
+
const disconnectedBody = await disconnected.json() as Record<string, unknown>;
|
|
527
|
+
if (storage.status !== 500 || storageBody.error !== 'Tunnel twin request failed'
|
|
528
|
+
|| disconnected.status !== 502 || disconnectedBody.error !== 'Tunnel not connected') return false;
|
|
529
|
+
} finally { relay.stop(); }
|
|
530
|
+
const stoppedOrigin = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => new Response('never') });
|
|
531
|
+
const stoppedPort = stoppedOrigin.port;
|
|
532
|
+
stoppedOrigin.stop(true);
|
|
533
|
+
const originRelay = createTunnelTwinServer({ root });
|
|
534
|
+
try {
|
|
535
|
+
const allocated = await fetch(`${originRelay.url}/tunnel`, { method: 'POST' }).then((response) => response.json()) as any;
|
|
536
|
+
originRelay.registerQuickOrigin(allocated.result.id, `http://127.0.0.1:${stoppedPort}`);
|
|
537
|
+
const explicitUnavailable = await fetch(`${originRelay.quickUrl(allocated.result.id)}/health`);
|
|
538
|
+
const fallbackUnavailable = await fetch(`${originRelay.url}/health`);
|
|
539
|
+
return explicitUnavailable.status === 502 && (await explicitUnavailable.json() as any).error === 'Quick tunnel origin unavailable'
|
|
540
|
+
&& fallbackUnavailable.status === 502 && (await fallbackUnavailable.json() as any).error === 'Quick tunnel origin unavailable';
|
|
541
|
+
} finally { originRelay.stop(); }
|
|
542
|
+
})),
|
|
543
|
+
todo('tunnel.cloudflare.quick.concurrent_200_limit', 'cloudflare_quick', 'Enforce Cloudflare Quick Tunnels\' documented 200 concurrent in-flight request cap with 429', 'api', 'common'),
|
|
544
|
+
todo('tunnel.cloudflare.quick.no_sse', 'cloudflare_quick', 'Reject Server-Sent Events like the documented Quick Tunnel limitation', 'api', 'niche'),
|
|
545
|
+
todo('tunnel.cloudflare.managed.named_tunnel', 'cloudflare_managed', 'Named tunnel creation and credentials', 'api', 'common'),
|
|
546
|
+
todo('tunnel.cloudflare.managed.ingress_rules', 'cloudflare_managed', 'Managed ingress configuration and hostname routing', 'api', 'common'),
|
|
547
|
+
todo('tunnel.cloudflare.managed.account_auth', 'cloudflare_managed', 'Account-scoped API token authentication', 'api', 'common'),
|
|
548
|
+
todo('tunnel.cloudflare.managed.replicas', 'cloudflare_managed', 'Multiple cloudflared replicas for one named tunnel', 'api', 'niche'),
|
|
549
|
+
|
|
550
|
+
// Owned Volter relay protocol.
|
|
551
|
+
done('tunnel.volter.control.register', 'volter_control', 'Relay register receives registered with the requested id and a reachable URL', 'api', 'core', () => withRoot(async (root) => {
|
|
552
|
+
const x = await protocolRoundtrip(root); return x.handle.tunnelId === 'protocol-proof' && x.handle.url.includes('/__twin/tunnels/protocol-proof');
|
|
553
|
+
})),
|
|
554
|
+
done('tunnel.volter.http.forward_get', 'volter_http', 'Relay frames forward GET path/query to a real local HTTP server', 'api', 'core', () => withRoot(async (root) => {
|
|
555
|
+
const x = await protocolRoundtrip(root); return x.response.status === 201 && x.body.path === '/hello?x=1' && x.body.marker === 'genuine-origin';
|
|
556
|
+
})),
|
|
557
|
+
done('tunnel.volter.http.forward_post_body', 'volter_http', 'POST method and body survive relay framing', 'api', 'core', () => withRoot(async (root) => {
|
|
558
|
+
const x = await protocolRoundtrip(root, { path: '/submit', request: { method: 'POST', body: 'payload-123' } });
|
|
559
|
+
return x.body.method === 'POST' && x.body.path === '/submit' && x.body.body === 'payload-123';
|
|
560
|
+
})),
|
|
561
|
+
done('tunnel.volter.http.response_status_headers', 'volter_http', 'Origin response status and headers survive relay framing', 'api', 'core', () => withRoot(async (root) => {
|
|
562
|
+
const x = await protocolRoundtrip(root); return x.response.status === 201 && x.response.headers.get('x-origin') === 'loopback';
|
|
563
|
+
})),
|
|
564
|
+
done('tunnel.volter.http.owned_header_rules', 'volter_http', 'Owned relay header rules strip framing headers, replace downstream CORS, and default dynamic traffic to no-store', 'api', 'common', () => withRoot(async (root) => {
|
|
565
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'headers-proof', { authRequired: false });
|
|
566
|
+
try {
|
|
567
|
+
const roundtrip = async (path: string, headers: Record<string, string>) => {
|
|
568
|
+
const frame = nextSocketMessage(control.socket, 'request');
|
|
569
|
+
const pending = fetch(`${control.url}${path}`, { headers: { origin: 'https://caller.example' } });
|
|
570
|
+
const req = await frame;
|
|
571
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: req.reqId, status: 203, headers, body: '' }));
|
|
572
|
+
return pending;
|
|
573
|
+
};
|
|
574
|
+
const dynamic = await roundtrip('/headers', {
|
|
575
|
+
'x-frame-options': 'DENY',
|
|
576
|
+
'content-security-policy': "default-src 'self'; frame-ancestors 'none'",
|
|
577
|
+
'access-control-allow-origin': 'https://wrong.example',
|
|
578
|
+
});
|
|
579
|
+
if (dynamic.status !== 203 || dynamic.headers.get('x-frame-options') !== null
|
|
580
|
+
|| String(dynamic.headers.get('content-security-policy')).includes('frame-ancestors')
|
|
581
|
+
|| dynamic.headers.get('access-control-allow-origin') !== 'https://caller.example') return false;
|
|
582
|
+
|
|
583
|
+
const privateCases: Response[] = [];
|
|
584
|
+
for (const [path, headers] of [
|
|
585
|
+
['/asset-conflict', { 'cache-control': 'public, private', vary: 'Accept-Encoding' }],
|
|
586
|
+
['/asset-no-store', { 'cache-control': 'public, no-store' }],
|
|
587
|
+
['/asset-no-cache', { 'cache-control': 'public, no-cache' }],
|
|
588
|
+
// The owned relay never caches API paths even when downstream says public.
|
|
589
|
+
['/api/private', { 'cache-control': 'public, max-age=3600' }],
|
|
590
|
+
] as Array<[string, Record<string, string>]>) privateCases.push(await roundtrip(path, headers));
|
|
591
|
+
for (const response of [dynamic, ...privateCases]) {
|
|
592
|
+
const vary = (response.headers.get('vary') ?? '').toLowerCase().split(',').map((value) => value.trim());
|
|
593
|
+
if (response.headers.get('cache-control') !== 'private, no-store, max-age=0'
|
|
594
|
+
|| response.headers.get('cloudflare-cdn-cache-control') !== 'no-store'
|
|
595
|
+
|| response.headers.get('cdn-cache-control') !== 'no-store'
|
|
596
|
+
|| !vary.includes('cookie') || !vary.includes('authorization')) return false;
|
|
597
|
+
}
|
|
598
|
+
if (!(privateCases[0]!.headers.get('vary') ?? '').toLowerCase().includes('accept-encoding')) return false;
|
|
599
|
+
|
|
600
|
+
const publicAsset = await roundtrip('/asset-public', { 'cache-control': 'public, max-age=3600' });
|
|
601
|
+
return publicAsset.headers.get('cache-control') === 'public, max-age=3600'
|
|
602
|
+
&& publicAsset.headers.get('cloudflare-cdn-cache-control') === null
|
|
603
|
+
&& publicAsset.headers.get('cdn-cache-control') === null;
|
|
604
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
605
|
+
})),
|
|
606
|
+
done('tunnel.volter.http.basic_auth', 'volter_auth', 'Non-empty Basic auth gates ingress while owned empty-value configs remain unconfigured', 'api', 'common', () => withRoot(async (root) => {
|
|
607
|
+
const basicAuth = { user: 'human', pass: 'remember-me' };
|
|
608
|
+
const denied = await protocolRoundtrip(root, { basicAuth, sendBasicAuth: false });
|
|
609
|
+
const allowed = await protocolRoundtrip(root, { basicAuth });
|
|
610
|
+
if (denied.response.status !== 401 || denied.response.headers.get('www-authenticate')?.includes('Basic') !== true
|
|
611
|
+
|| allowed.response.status !== 201 || allowed.body.marker !== 'genuine-origin') return false;
|
|
612
|
+
for (const empty of [{ user: 'human', pass: '' }, { user: '', pass: 'secret' }, { user: '', pass: '' }]) {
|
|
613
|
+
const prepared = prepareTunnelRelayRegistration({
|
|
614
|
+
message: { type: 'register', tunnelId: 'empty-basic', secret: 'x', authRequired: false, basicAuth: empty },
|
|
615
|
+
session: {}, publicBaseUrl: 'http://127.0.0.1:1',
|
|
616
|
+
});
|
|
617
|
+
if (!('persistence' in prepared) || prepared.session.basicAuth !== undefined
|
|
618
|
+
|| prepared.persistence.fields.basic_auth_configured !== false
|
|
619
|
+
|| prepared.persistence.fields.basic_auth_sha256 !== null) return false;
|
|
620
|
+
const ungated = await protocolRoundtrip(root, { basicAuth: empty, sendBasicAuth: false, path: '/empty-basic' });
|
|
621
|
+
if (ungated.response.status !== 201 || ungated.body.marker !== 'genuine-origin') return false;
|
|
622
|
+
}
|
|
623
|
+
return true;
|
|
624
|
+
})),
|
|
625
|
+
done('tunnel.volter.control.missing_secret', 'volter_auth', 'Missing credentials and malformed Basic-auth registration frames are rejected fatally without changing session state', 'api', 'core', async () => {
|
|
626
|
+
const session = (): RelaySession => ({
|
|
627
|
+
tunnelId: 'healthy-owner', authRequired: true,
|
|
628
|
+
basicAuth: { user: 'sentinel', pass: 'untouched' }, registered: true,
|
|
629
|
+
});
|
|
630
|
+
const original = session();
|
|
631
|
+
const originalJson = JSON.stringify(original);
|
|
632
|
+
const missing = await handleTunnelRelayMessage({ message: { type: 'register', tunnelId: 'x', authRequired: false }, session: original, publicBaseUrl: 'http://127.0.0.1:1' });
|
|
633
|
+
if (missing.outbound?.type !== 'error' || missing.outbound.fatal !== true || !String(missing.outbound.message).includes('credential')
|
|
634
|
+
|| missing.session !== original || JSON.stringify(original) !== originalJson) return false;
|
|
635
|
+
for (const basicAuth of [null, 'user:pass', {}, { user: 'only' }, { pass: 'only' }, { user: 7, pass: 'x' }]) {
|
|
636
|
+
const existing = session();
|
|
637
|
+
const before = JSON.stringify(existing);
|
|
638
|
+
const invalid = await handleTunnelRelayMessage({ message: { type: 'register', tunnelId: 'x', secret: 'credential', basicAuth }, session: existing, publicBaseUrl: 'http://127.0.0.1:1' });
|
|
639
|
+
if (invalid.outbound?.type !== 'error' || invalid.outbound.fatal !== true || !String(invalid.outbound.message).includes('basic-auth')
|
|
640
|
+
|| invalid.session !== existing || JSON.stringify(existing) !== before) return false;
|
|
641
|
+
}
|
|
642
|
+
return true;
|
|
643
|
+
}),
|
|
644
|
+
done('tunnel.volter.control.readonly_refusal', 'volter_control', 'Read-only mode refuses relay registration without persisting or creating a live owner', 'api', 'core', () => withRoot(async (root) => {
|
|
645
|
+
const relay = createTunnelTwinServer({ root, readOnly: true });
|
|
646
|
+
const socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
647
|
+
try {
|
|
648
|
+
await new Promise<void>((resolve, reject) => {
|
|
649
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
650
|
+
socket.addEventListener('error', () => reject(new Error('read-only socket failed')), { once: true });
|
|
651
|
+
});
|
|
652
|
+
const outcome = nextSocketOutcome(socket, ['registered', 'error']);
|
|
653
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId: 'read-only', secret: 'x', authRequired: false }));
|
|
654
|
+
const result = await outcome;
|
|
655
|
+
const status = await fetch(`${relay.url}/api/status`).then((response) => response.json()) as any;
|
|
656
|
+
return result.type === 'error' && result.fatal === true && String(result.message).includes('read-only')
|
|
657
|
+
&& status.tunnels === 0 && listActions('tunnel', root).length === 0
|
|
658
|
+
&& !projectResources('tunnel', root).some((resource) => resource.id === 'relay:read-only');
|
|
659
|
+
} finally { socket.close(); relay.stop(); }
|
|
660
|
+
})),
|
|
661
|
+
done('tunnel.volter.control.invalid_id', 'volter_control', 'Invalid tunnel ids are rejected instead of routed ambiguously', 'api', 'common', async () => {
|
|
662
|
+
const r = await handleTunnelRelayMessage({ message: { type: 'register', tunnelId: '../bad', secret: 'x', authRequired: false }, session: {}, publicBaseUrl: 'http://127.0.0.1:1' });
|
|
663
|
+
return r.outbound?.type === 'error' && r.outbound.fatal === true && String(r.outbound.message).includes('Invalid');
|
|
664
|
+
}),
|
|
665
|
+
done('tunnel.volter.control.persisted_registration', 'volter_control', 'Registration folds a relay_tunnel resource into the kernel', 'api', 'common', () => withRoot(async (root) => {
|
|
666
|
+
await handleTunnelRelayMessage({ message: { type: 'register', tunnelId: 'persisted', secret: 'x', authRequired: false }, session: {}, publicBaseUrl: 'http://127.0.0.1:1', root, occurredAt: AT });
|
|
667
|
+
return projectResources('tunnel', root).some((x) => x.id === 'relay:persisted' && x.type === 'relay_tunnel' && x.connected === true);
|
|
668
|
+
})),
|
|
669
|
+
done('tunnel.volter.control.basic_auth_log_secrecy', 'volter_auth', 'Basic-auth plaintext stays session-only; the append-only log stores only a hash and configured bit', 'api', 'core', () => withRoot(async (root) => {
|
|
670
|
+
await handleTunnelRelayMessage({
|
|
671
|
+
message: { type: 'register', tunnelId: 'secret-log', secret: 'x', basicAuth: { user: 'operator', pass: 'never-persist-this' } },
|
|
672
|
+
session: {}, publicBaseUrl: 'http://127.0.0.1:1', root, occurredAt: AT,
|
|
673
|
+
});
|
|
674
|
+
const serialized = JSON.stringify(listActions('tunnel', root));
|
|
675
|
+
const resource = projectResources('tunnel', root).find((x) => x.id === 'relay:secret-log');
|
|
676
|
+
const basicAuthKeys = Object.keys(resource ?? {}).filter((key) => key.startsWith('basic_auth_')).sort();
|
|
677
|
+
const expectedHash = createHash('sha256').update('operator:never-persist-this').digest('hex');
|
|
678
|
+
return !serialized.includes('never-persist-this') && !serialized.includes('operator')
|
|
679
|
+
&& JSON.stringify(basicAuthKeys) === JSON.stringify(['basic_auth_configured', 'basic_auth_sha256'])
|
|
680
|
+
&& resource?.basic_auth_configured === true && resource.basic_auth_sha256 === expectedHash;
|
|
681
|
+
})),
|
|
682
|
+
done('tunnel.volter.status.health', 'volter_management', 'GET /api/status reports the twin-local live socket count independently from persisted historical connection state', 'api', 'core', () => withRoot(async (root) => {
|
|
683
|
+
await handleTunnelRelayMessage({ message: { type: 'register', tunnelId: 'historical-only', secret: 'x', authRequired: false }, session: {}, publicBaseUrl: 'http://127.0.0.1:1', root, occurredAt: AT });
|
|
684
|
+
const persisted = projectResources('tunnel', root).find((resource) => resource.id === 'relay:historical-only');
|
|
685
|
+
const relay = createTunnelTwinServer({ root });
|
|
686
|
+
try {
|
|
687
|
+
const r = await fetch(`${relay.url}/api/status`);
|
|
688
|
+
const body = await r.json() as any;
|
|
689
|
+
return persisted?.connected === true && r.status === 200 && body.ok === true && body.relay === 'twin-local' && body.tunnels === 0;
|
|
690
|
+
} finally { relay.stop(); }
|
|
691
|
+
})),
|
|
692
|
+
done('tunnel.volter.control.response_ownership', 'volter_control', 'Unguessable request correlations are socket-owned and an unregistered socket cannot spoof a response', 'api', 'core', () => withRoot(async (root) => {
|
|
693
|
+
const relay = createTunnelTwinServer({ root });
|
|
694
|
+
let owner: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
695
|
+
let rogue: WebSocket | undefined;
|
|
696
|
+
try {
|
|
697
|
+
owner = await openControl(relay.url, 'owned-response', { authRequired: false });
|
|
698
|
+
const rogueSocket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
699
|
+
rogue = rogueSocket;
|
|
700
|
+
await new Promise<void>((resolve) => rogueSocket.addEventListener('open', () => resolve(), { once: true }));
|
|
701
|
+
const requestFrame = nextSocketMessage(owner.socket, 'request');
|
|
702
|
+
const responsePromise = fetch(`${owner.url}/ownership`);
|
|
703
|
+
const request = await requestFrame;
|
|
704
|
+
const rogueError = nextSocketMessage(rogueSocket, 'error');
|
|
705
|
+
rogueSocket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 299, headers: {}, body: btoa('rogue') }));
|
|
706
|
+
if (!(await rogueError).message.includes('not the registered')) return false;
|
|
707
|
+
const rogueRegistered = nextSocketMessage(rogueSocket, 'registered');
|
|
708
|
+
rogueSocket.send(JSON.stringify({ type: 'register', tunnelId: 'rogue-response', secret: 'vt_fake_local', authRequired: false }));
|
|
709
|
+
await rogueRegistered;
|
|
710
|
+
const correlationError = nextSocketMessage(rogueSocket, 'error');
|
|
711
|
+
rogueSocket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 299, headers: {}, body: btoa('rogue') }));
|
|
712
|
+
if (!(await correlationError).message.includes('not owned')) return false;
|
|
713
|
+
owner.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('owner') }));
|
|
714
|
+
const response = await responsePromise;
|
|
715
|
+
return /^[0-9a-f-]{36}$/.test(String(request.reqId)) && response.status === 200 && await response.text() === 'owner';
|
|
716
|
+
} finally { owner?.socket.close(); rogue?.close(); relay.stop(); }
|
|
717
|
+
})),
|
|
718
|
+
done('tunnel.volter.control.replace', 'volter_control', 'replace:true makes the prior socket inert and fails its in-flight requests before transfer', 'api', 'core', () => withRoot(async (root) => {
|
|
719
|
+
const relay = createTunnelTwinServer({ root });
|
|
720
|
+
let old: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
721
|
+
let replacement: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
722
|
+
try {
|
|
723
|
+
old = await openControl(relay.url, 'replace-proof', { authRequired: false });
|
|
724
|
+
const requestFrame = nextSocketMessage(old.socket, 'request');
|
|
725
|
+
const responsePromise = fetch(`${old.url}/slow`);
|
|
726
|
+
const request = await requestFrame;
|
|
727
|
+
replacement = await openControl(relay.url, 'replace-proof', { replace: true, authRequired: false });
|
|
728
|
+
const response = await responsePromise;
|
|
729
|
+
const body = await response.json() as Record<string, unknown>;
|
|
730
|
+
old.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('late') }));
|
|
731
|
+
const successorFrame = nextSocketMessage(replacement.socket, 'request');
|
|
732
|
+
const successorPromise = fetch(`${replacement.url}/successor`);
|
|
733
|
+
const successorRequest = await successorFrame;
|
|
734
|
+
try { old.socket.send(JSON.stringify({ type: 'response', reqId: successorRequest.reqId, status: 299, headers: {}, body: btoa('superseded') })); } catch { /* closed as required */ }
|
|
735
|
+
replacement.socket.send(JSON.stringify({ type: 'response', reqId: successorRequest.reqId, status: 200, headers: {}, body: btoa('successor') }));
|
|
736
|
+
const successor = await successorPromise;
|
|
737
|
+
return response.status === 502 && body.error === 'Tunnel replaced'
|
|
738
|
+
&& successor.status === 200 && await successor.text() === 'successor'
|
|
739
|
+
&& (await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any).tunnels === 1;
|
|
740
|
+
} finally { old?.socket.close(); replacement?.socket.close(); relay.stop(); }
|
|
741
|
+
})),
|
|
742
|
+
done('tunnel.volter.control.replace_auth_before_transfer', 'volter_control', 'An invalid replace:true candidate cannot evict or disarm the valid owner', 'api', 'core', () => withRoot(async (root) => {
|
|
743
|
+
const relay = createTunnelTwinServer({ root }); const owner = await openControl(relay.url, 'auth-before-replace', { authRequired: false });
|
|
744
|
+
const attacker = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
745
|
+
const malformed = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
746
|
+
try {
|
|
747
|
+
await Promise.all([attacker, malformed].map((socket) => new Promise<void>((resolve, reject) => {
|
|
748
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
749
|
+
socket.addEventListener('error', () => reject(new Error('replacement candidate failed')), { once: true });
|
|
750
|
+
})));
|
|
751
|
+
const actionsBefore = listActions('tunnel', root).length;
|
|
752
|
+
const rejected = nextSocketMessage(attacker, 'error');
|
|
753
|
+
attacker.send(JSON.stringify({ type: 'register', tunnelId: 'auth-before-replace', replace: true }));
|
|
754
|
+
if (!(await rejected).fatal) return false;
|
|
755
|
+
const malformedRejected = nextSocketMessage(malformed, 'error');
|
|
756
|
+
malformed.send(JSON.stringify({
|
|
757
|
+
type: 'register', tunnelId: 'auth-before-replace', secret: 'x', replace: true,
|
|
758
|
+
basicAuth: { user: 'missing-pass' },
|
|
759
|
+
}));
|
|
760
|
+
const malformedError = await malformedRejected;
|
|
761
|
+
if (!malformedError.fatal || !String(malformedError.message).includes('basic-auth')
|
|
762
|
+
|| listActions('tunnel', root).length !== actionsBefore) return false;
|
|
763
|
+
const frame = nextSocketMessage(owner.socket, 'request');
|
|
764
|
+
const responsePromise = fetch(`${owner.url}/still-owned`);
|
|
765
|
+
const request = await frame;
|
|
766
|
+
owner.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('owner-survived') }));
|
|
767
|
+
const response = await responsePromise;
|
|
768
|
+
return response.status === 200 && await response.text() === 'owner-survived'
|
|
769
|
+
&& (await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any).tunnels === 1;
|
|
770
|
+
} finally { attacker.close(); malformed.close(); owner.socket.close(); relay.stop(); }
|
|
771
|
+
})),
|
|
772
|
+
done('tunnel.volter.control.replace_persist_before_transfer', 'volter_control', 'A replacement persistence failure leaves the healthy owner and ingress live', 'api', 'core', () => withRoot(async (root) => {
|
|
773
|
+
const relay = createTunnelTwinServer({ root });
|
|
774
|
+
const owner = await openControl(relay.url, 'persist-before-replace', { authRequired: false });
|
|
775
|
+
const replacement = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
776
|
+
const savedRoot = `${root}-healthy`;
|
|
777
|
+
let displacedRoot = false;
|
|
778
|
+
try {
|
|
779
|
+
await new Promise<void>((resolve, reject) => {
|
|
780
|
+
replacement.addEventListener('open', () => resolve(), { once: true });
|
|
781
|
+
replacement.addEventListener('error', () => reject(new Error('replacement socket failed')), { once: true });
|
|
782
|
+
});
|
|
783
|
+
renameSync(root, savedRoot);
|
|
784
|
+
displacedRoot = true;
|
|
785
|
+
writeFileSync(root, 'not a state directory');
|
|
786
|
+
const rejected = nextSocketMessage(replacement, 'error');
|
|
787
|
+
replacement.send(JSON.stringify({
|
|
788
|
+
type: 'register', tunnelId: 'persist-before-replace', secret: 'x', replace: true, authRequired: false,
|
|
789
|
+
}));
|
|
790
|
+
if (!(await rejected).fatal) return false;
|
|
791
|
+
const requestFrame = nextSocketMessage(owner.socket, 'request');
|
|
792
|
+
const responsePromise = fetch(`${owner.url}/still-live`);
|
|
793
|
+
const request = await requestFrame;
|
|
794
|
+
owner.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('healthy-owner') }));
|
|
795
|
+
const response = await responsePromise;
|
|
796
|
+
const status = await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any;
|
|
797
|
+
return response.status === 200 && await response.text() === 'healthy-owner' && status.tunnels === 1;
|
|
798
|
+
} finally {
|
|
799
|
+
if (displacedRoot) {
|
|
800
|
+
rmSync(root, { force: true });
|
|
801
|
+
renameSync(savedRoot, root);
|
|
802
|
+
}
|
|
803
|
+
replacement.close(); owner.socket.close(); relay.stop();
|
|
804
|
+
}
|
|
805
|
+
})),
|
|
806
|
+
done('tunnel.volter.control.replace_disconnect_durable_order', 'volter_control', 'A prior owner closing during replacement persistence cannot overwrite the sole successor\'s durable connected generation', 'api', 'core', () => withRoot(async (root) => {
|
|
807
|
+
let persistenceCalls = 0;
|
|
808
|
+
let enteredReplacement!: () => void;
|
|
809
|
+
let releaseReplacement!: () => void;
|
|
810
|
+
const entered = new Promise<void>((resolve) => { enteredReplacement = resolve; });
|
|
811
|
+
const held = new Promise<void>((resolve) => { releaseReplacement = resolve; });
|
|
812
|
+
const relay = createTunnelTwinServer({
|
|
813
|
+
root,
|
|
814
|
+
persistRegistration: async (candidate, options) => {
|
|
815
|
+
await persistTunnelRelayRegistration(candidate, options);
|
|
816
|
+
persistenceCalls += 1;
|
|
817
|
+
if (persistenceCalls === 2) {
|
|
818
|
+
enteredReplacement();
|
|
819
|
+
await held;
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
const owner = await openControl(relay.url, 'disconnect-order', { authRequired: false });
|
|
824
|
+
const first = projectResources('tunnel', root).find((resource) => resource.id === 'relay:disconnect-order');
|
|
825
|
+
const replacement = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
826
|
+
try {
|
|
827
|
+
await new Promise<void>((resolve, reject) => {
|
|
828
|
+
replacement.addEventListener('open', () => resolve(), { once: true });
|
|
829
|
+
replacement.addEventListener('error', () => reject(new Error('replacement ordering socket failed')), { once: true });
|
|
830
|
+
});
|
|
831
|
+
const registered = nextSocketOutcome(replacement, ['registered', 'error']);
|
|
832
|
+
replacement.send(JSON.stringify({
|
|
833
|
+
type: 'register', tunnelId: 'disconnect-order', secret: 'x', replace: true, authRequired: false,
|
|
834
|
+
}));
|
|
835
|
+
const persistenceEntered = await Promise.race([
|
|
836
|
+
entered.then(() => true),
|
|
837
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), 1_000)),
|
|
838
|
+
]);
|
|
839
|
+
if (!persistenceEntered) return false;
|
|
840
|
+
owner.socket.close();
|
|
841
|
+
for (let i = 0; i < 40; i++) {
|
|
842
|
+
if ((await fetch(`${relay.url}/api/status`).then((response) => response.json()) as any).tunnels === 0) break;
|
|
843
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
844
|
+
}
|
|
845
|
+
releaseReplacement();
|
|
846
|
+
const outcome = await registered;
|
|
847
|
+
if (outcome.type !== 'registered') return false;
|
|
848
|
+
|
|
849
|
+
// Let the old close's queued generation-conditional disconnect run after the replacement
|
|
850
|
+
// turn. It must observe the successor registration id and become a no-op.
|
|
851
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
852
|
+
const current = projectResources('tunnel', root).find((resource) => resource.id === 'relay:disconnect-order');
|
|
853
|
+
const status = await fetch(`${relay.url}/api/status`).then((response) => response.json()) as any;
|
|
854
|
+
const frame = nextSocketMessage(replacement, 'request');
|
|
855
|
+
const responsePromise = fetch(`${String(outcome.url)}/successor`);
|
|
856
|
+
const request = await frame;
|
|
857
|
+
replacement.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('successor-live') }));
|
|
858
|
+
const response = await responsePromise;
|
|
859
|
+
return persistenceCalls === 2 && first?.connected === true
|
|
860
|
+
&& typeof first.registration_id === 'string'
|
|
861
|
+
&& current?.connected === true && typeof current.registration_id === 'string'
|
|
862
|
+
&& current.registration_id !== first.registration_id
|
|
863
|
+
&& status.tunnels === 1 && response.status === 200 && await response.text() === 'successor-live';
|
|
864
|
+
} finally { releaseReplacement(); replacement.close(); owner.socket.close(); relay.stop(); }
|
|
865
|
+
})),
|
|
866
|
+
done('tunnel.volter.control.postappend_persist_failure_reverted', 'volter_control', 'A registration action appended before persistence reports failure is reverted by exact action id, preserving disconnected durable state and truthful audit history', 'api', 'core', () => withRoot(async (root) => {
|
|
867
|
+
await markTunnelDisconnected('postappend-failure', root, AT);
|
|
868
|
+
const relay = createTunnelTwinServer({
|
|
869
|
+
root,
|
|
870
|
+
persistRegistration: async (candidate, options) => {
|
|
871
|
+
await persistTunnelRelayRegistration(candidate, options);
|
|
872
|
+
throw new Error('injected failure after durable append');
|
|
873
|
+
},
|
|
874
|
+
});
|
|
875
|
+
const socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
876
|
+
try {
|
|
877
|
+
await new Promise<void>((resolve, reject) => {
|
|
878
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
879
|
+
socket.addEventListener('error', () => reject(new Error('post-append socket failed')), { once: true });
|
|
880
|
+
});
|
|
881
|
+
const rejected = nextSocketMessage(socket, 'error');
|
|
882
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId: 'postappend-failure', secret: 'x', authRequired: false }));
|
|
883
|
+
if (!(await rejected).fatal) return false;
|
|
884
|
+
const actions = listActions('tunnel', root);
|
|
885
|
+
const registration = actions.find((action) => action.operation === 'relay.register'
|
|
886
|
+
&& action.subject.id === 'relay:postappend-failure'
|
|
887
|
+
&& (action.fields as Record<string, unknown> | undefined)?.registration_id);
|
|
888
|
+
const rollback = actions.find((action) => action.operation === 'relay.register.rollback'
|
|
889
|
+
&& action.subject.id === 'relay:postappend-failure');
|
|
890
|
+
const resource = projectResources('tunnel', root).find((candidate) => candidate.id === 'relay:postappend-failure');
|
|
891
|
+
const status = await fetch(`${relay.url}/api/status`).then((response) => response.json()) as any;
|
|
892
|
+
return registration !== undefined && rollback?.op === 'revert'
|
|
893
|
+
&& rollback.revertsActionId === registration.id
|
|
894
|
+
&& resource?.connected === false && resource.registration_id === undefined
|
|
895
|
+
&& status.tunnels === 0;
|
|
896
|
+
} finally { socket.close(); relay.stop(); }
|
|
897
|
+
})),
|
|
898
|
+
// ONE rule decides ownership: is the claim's own heartbeat fresh. The relay writes that
|
|
899
|
+
// heartbeat (reservation, control frames, idle keepalive); nothing here reads a process table,
|
|
900
|
+
// so the same answer holds for a claim written by another host or another shell. The four
|
|
901
|
+
// fixtures below all carry THIS host's pid — a stale local claim (its owner stopped writing,
|
|
902
|
+
// whatever became of the pid) is reclaimed exactly like a stale foreign one, and a fresh claim
|
|
903
|
+
// stays exclusive whoever wrote it.
|
|
904
|
+
done('tunnel.volter.control.stale_claim_recovery', 'volter_control', 'Relay claims live or die by bounded heartbeat freshness alone, so stale local and expired or far-future foreign reservations are reclaimed while any fresh owner stays exclusive', 'api', 'core', () => withRoot(async (root) => {
|
|
905
|
+
const claimsDir = join(worldPaths('tunnel', root).dir, 'relay-claims');
|
|
906
|
+
mkdirSync(claimsDir, { recursive: true });
|
|
907
|
+
const writeClaim = (tunnelId: string, claim: Record<string, unknown>) => writeFileSync(join(claimsDir, `${tunnelId}.json`), `${JSON.stringify({
|
|
908
|
+
tunnelId, token: `old-${tunnelId}`, serverId: 'old-server', socketId: 'old-socket', pid: process.pid,
|
|
909
|
+
hostname: hostname(), state: 'reserving', claimedAt: '2000-01-01T00:00:00.000Z', heartbeatAt: new Date().toISOString(),
|
|
910
|
+
...claim,
|
|
911
|
+
})}\n`);
|
|
912
|
+
// Stopped writing: this host, a live pid (ours), and no heartbeat since 2000 — reclaimable.
|
|
913
|
+
writeClaim('stale-local', { heartbeatAt: '2000-01-01T00:00:00.000Z' });
|
|
914
|
+
writeClaim('expired-foreign', { hostname: 'retired-host.invalid', heartbeatAt: '2000-01-01T00:00:00.000Z' });
|
|
915
|
+
writeClaim('future-foreign', { hostname: 'bad-clock-host.invalid', heartbeatAt: '2999-01-01T00:00:00.000Z' });
|
|
916
|
+
writeClaim('fresh-foreign', { hostname: 'other-live-host.invalid' });
|
|
917
|
+
writeClaim('fresh-local', {});
|
|
918
|
+
const relay = createTunnelTwinServer({ root });
|
|
919
|
+
let stale: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
920
|
+
let expired: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
921
|
+
let future: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
922
|
+
const contenders: WebSocket[] = [];
|
|
923
|
+
/** Register `tunnelId` on a socket that expects to be REFUSED. */
|
|
924
|
+
const refusedRegistration = async (tunnelId: string): Promise<Record<string, any>> => {
|
|
925
|
+
const socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
926
|
+
contenders.push(socket);
|
|
927
|
+
await new Promise<void>((resolve, reject) => {
|
|
928
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
929
|
+
socket.addEventListener('error', () => reject(new Error(`contender for ${tunnelId} failed`)), { once: true });
|
|
930
|
+
});
|
|
931
|
+
const refused = nextSocketMessage(socket, 'error');
|
|
932
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId, secret: 'x', authRequired: false }));
|
|
933
|
+
return refused;
|
|
934
|
+
};
|
|
935
|
+
try {
|
|
936
|
+
stale = await openControl(relay.url, 'stale-local', { authRequired: false });
|
|
937
|
+
expired = await openControl(relay.url, 'expired-foreign', { authRequired: false });
|
|
938
|
+
future = await openControl(relay.url, 'future-foreign', { authRequired: false });
|
|
939
|
+
const foreignRefusal = await refusedRegistration('fresh-foreign');
|
|
940
|
+
const localRefusal = await refusedRegistration('fresh-local');
|
|
941
|
+
const registrations = listActions('tunnel', root).filter((action) => action.operation === 'relay.register');
|
|
942
|
+
return foreignRefusal.type === 'error' && localRefusal.type === 'error'
|
|
943
|
+
&& registrations.some((action) => action.subject.id === 'relay:stale-local')
|
|
944
|
+
&& registrations.some((action) => action.subject.id === 'relay:expired-foreign')
|
|
945
|
+
&& registrations.some((action) => action.subject.id === 'relay:future-foreign')
|
|
946
|
+
&& !registrations.some((action) => action.subject.id === 'relay:fresh-foreign')
|
|
947
|
+
&& !registrations.some((action) => action.subject.id === 'relay:fresh-local');
|
|
948
|
+
} finally { for (const socket of contenders) socket.close(); stale?.socket.close(); expired?.socket.close(); future?.socket.close(); relay.stop(); }
|
|
949
|
+
})),
|
|
950
|
+
// The other half of the rule above: WHO writes the heartbeat. The relay does, out of its own
|
|
951
|
+
// traffic — so liveness is a fact this twin holds in state rather than something the serve path
|
|
952
|
+
// asks the host operating system.
|
|
953
|
+
done('tunnel.volter.control.heartbeat_written_by_traffic', 'volter_control', 'A relay\'s own control frame stamps its ownership claim with the world instant, so liveness is state the relay writes rather than a process-table probe', 'api', 'core', () => withRoot(async (root) => {
|
|
954
|
+
const claimPath = join(worldPaths('tunnel', root).dir, 'relay-claims', 'beating.json');
|
|
955
|
+
const heartbeatAt = (): number => Date.parse(String((JSON.parse(readFileSync(claimPath, 'utf8')) as Record<string, unknown>).heartbeatAt));
|
|
956
|
+
const relay = createTunnelTwinServer({ root });
|
|
957
|
+
let control: Awaited<ReturnType<typeof openControl>> | undefined;
|
|
958
|
+
try {
|
|
959
|
+
control = await openControl(relay.url, 'beating', { authRequired: false });
|
|
960
|
+
const atRegistration = heartbeatAt();
|
|
961
|
+
if (!Number.isFinite(atRegistration)) return false;
|
|
962
|
+
// Past the millisecond the registration stamped, so a later stamp is strictly greater.
|
|
963
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
964
|
+
control.socket.send(JSON.stringify({ type: 'ws-message', data: 'a frame from a live relay' }));
|
|
965
|
+
for (let attempt = 0; attempt < 50; attempt++) {
|
|
966
|
+
if (heartbeatAt() > atRegistration) return true;
|
|
967
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
968
|
+
}
|
|
969
|
+
return false;
|
|
970
|
+
} finally { control?.socket.close(); relay.stop(); }
|
|
971
|
+
})),
|
|
972
|
+
done('tunnel.volter.control.concurrent_claim_single_owner', 'volter_control', 'Concurrent claims without replace permission leave exactly one registered traffic owner', 'api', 'core', () => withRoot(async (root) => {
|
|
973
|
+
let persisted = 0;
|
|
974
|
+
let enteredPersistence: (() => void) | undefined;
|
|
975
|
+
let releasePersistence: (() => void) | undefined;
|
|
976
|
+
const entered = new Promise<void>((resolve) => { enteredPersistence = resolve; });
|
|
977
|
+
const overlap = new Promise<void>((resolve) => { releasePersistence = resolve; });
|
|
978
|
+
const relay = createTunnelTwinServer({
|
|
979
|
+
root,
|
|
980
|
+
persistRegistration: async (candidate, options) => {
|
|
981
|
+
await persistTunnelRelayRegistration(candidate, options);
|
|
982
|
+
persisted += 1;
|
|
983
|
+
enteredPersistence?.();
|
|
984
|
+
await overlap;
|
|
985
|
+
},
|
|
986
|
+
});
|
|
987
|
+
const first = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
988
|
+
const second = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
989
|
+
try {
|
|
990
|
+
await Promise.all([first, second].map((socket) => new Promise<void>((resolve, reject) => {
|
|
991
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
992
|
+
socket.addEventListener('error', () => reject(new Error('claim socket failed')), { once: true });
|
|
993
|
+
})));
|
|
994
|
+
// Attach rejection handlers immediately: the mutation harness deliberately replaces the
|
|
995
|
+
// registration seam with a no-op response, so neither socket is guaranteed to reach the
|
|
996
|
+
// persistence rendezvous below. A pending outcome must become a named red verify, not an
|
|
997
|
+
// unhandled timer rejection that aborts the entire mutation run.
|
|
998
|
+
const firstOutcome = nextSocketOutcome(first, ['registered', 'error'])
|
|
999
|
+
.catch((error): Record<string, any> => ({ type: 'timeout', message: String(error) }));
|
|
1000
|
+
const secondOutcome = nextSocketOutcome(second, ['registered', 'error'])
|
|
1001
|
+
.catch((error): Record<string, any> => ({ type: 'timeout', message: String(error) }));
|
|
1002
|
+
const claim = JSON.stringify({ type: 'register', tunnelId: 'single-owner', secret: 'x', authRequired: false });
|
|
1003
|
+
first.send(claim);
|
|
1004
|
+
const enteredBeforeTimeout = await Promise.race([
|
|
1005
|
+
entered.then(() => true),
|
|
1006
|
+
new Promise<false>((resolve) => setTimeout(() => resolve(false), 3_100)),
|
|
1007
|
+
]);
|
|
1008
|
+
if (!enteredBeforeTimeout) return false;
|
|
1009
|
+
second.send(claim);
|
|
1010
|
+
// The second frame is now pending at the same id while the first persistence seam is held.
|
|
1011
|
+
// It must not enter persistence or append an action; only releasing the first winner lets the
|
|
1012
|
+
// serialized ownership decision reject it.
|
|
1013
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1014
|
+
if (persisted !== 1 || listActions('tunnel', root).filter((action) => action.operation === 'relay.register').length !== 1) return false;
|
|
1015
|
+
releasePersistence?.();
|
|
1016
|
+
const outcomes = await Promise.all([firstOutcome, secondOutcome]);
|
|
1017
|
+
const winnerIndex = outcomes.findIndex((outcome) => outcome.type === 'registered');
|
|
1018
|
+
if (winnerIndex < 0 || outcomes.filter((outcome) => outcome.type === 'registered').length !== 1
|
|
1019
|
+
|| outcomes.filter((outcome) => outcome.type === 'error').length !== 1) return false;
|
|
1020
|
+
const winner = winnerIndex === 0 ? first : second;
|
|
1021
|
+
const url = String(outcomes[winnerIndex]!.url);
|
|
1022
|
+
const requestFrame = nextSocketMessage(winner, 'request');
|
|
1023
|
+
const responsePromise = fetch(`${url}/winner`);
|
|
1024
|
+
const request = await requestFrame;
|
|
1025
|
+
winner.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('one-owner') }));
|
|
1026
|
+
const response = await responsePromise;
|
|
1027
|
+
const status = await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any;
|
|
1028
|
+
if (persisted !== 1 || response.status !== 200 || await response.text() !== 'one-owner' || status.tunnels !== 1) return false;
|
|
1029
|
+
|
|
1030
|
+
// A second OS process with a separate server/controls map but the SAME root must observe the
|
|
1031
|
+
// transient live-ownership claim and reject before append too. This is the branch an
|
|
1032
|
+
// in-process Map-only mutex cannot cover.
|
|
1033
|
+
const moduleUrl = new URL('./tunnel-server.ts', import.meta.url).href;
|
|
1034
|
+
const childScript = `
|
|
1035
|
+
const { createTunnelTwinServer } = await import(${JSON.stringify(moduleUrl)});
|
|
1036
|
+
const relay = createTunnelTwinServer({ root: ${JSON.stringify(root)} });
|
|
1037
|
+
const socket = new WebSocket(relay.url.replace(/^http/, 'ws') + '/ws');
|
|
1038
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
1039
|
+
const timer = setTimeout(() => reject(new Error('cross-process claim timeout')), 3000);
|
|
1040
|
+
socket.addEventListener('open', () => socket.send(JSON.stringify({ type: 'register', tunnelId: 'single-owner', secret: 'x', authRequired: false })));
|
|
1041
|
+
socket.addEventListener('message', (event) => {
|
|
1042
|
+
const message = JSON.parse(String(event.data));
|
|
1043
|
+
if (message.type === 'registered' || message.type === 'error') { clearTimeout(timer); resolve(message); }
|
|
1044
|
+
});
|
|
1045
|
+
socket.addEventListener('error', () => reject(new Error('cross-process claim socket failed')));
|
|
1046
|
+
});
|
|
1047
|
+
console.log(JSON.stringify(outcome));
|
|
1048
|
+
socket.close(); relay.stop();
|
|
1049
|
+
`;
|
|
1050
|
+
const child = Bun.spawn([process.execPath, '-e', childScript], { stdout: 'pipe', stderr: 'pipe' });
|
|
1051
|
+
const [childStdout, childStderr, childExit] = await Promise.all([
|
|
1052
|
+
new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited,
|
|
1053
|
+
]);
|
|
1054
|
+
const crossProcess = JSON.parse(childStdout.trim()) as Record<string, unknown>;
|
|
1055
|
+
return childExit === 0 && childStderr === '' && crossProcess.type === 'error'
|
|
1056
|
+
&& listActions('tunnel', root).filter((action) => action.operation === 'relay.register').length === 1;
|
|
1057
|
+
} finally { releasePersistence?.(); first.close(); second.close(); relay.stop(); }
|
|
1058
|
+
})),
|
|
1059
|
+
done('tunnel.volter.control.socket_single_identity', 'volter_control', 'One socket can own only one current ID; valid alias movement retires and disconnects the old mapping', 'api', 'core', () => withRoot(async (root) => {
|
|
1060
|
+
const relay = createTunnelTwinServer({ root }); const socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
1061
|
+
try {
|
|
1062
|
+
await new Promise<void>((resolve) => socket.addEventListener('open', () => resolve(), { once: true }));
|
|
1063
|
+
let registered = nextSocketMessage(socket, 'registered');
|
|
1064
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId: 'alias-a', secret: 'x', authRequired: false }));
|
|
1065
|
+
const first = await registered;
|
|
1066
|
+
registered = nextSocketMessage(socket, 'registered');
|
|
1067
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId: 'alias-b', secret: 'x', authRequired: false }));
|
|
1068
|
+
const second = await registered;
|
|
1069
|
+
const old = await fetch(String(first.url));
|
|
1070
|
+
const frame = nextSocketMessage(socket, 'request');
|
|
1071
|
+
const responsePromise = fetch(`${String(second.url)}/active`);
|
|
1072
|
+
const request = await frame;
|
|
1073
|
+
socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('alias-b') }));
|
|
1074
|
+
const current = await responsePromise;
|
|
1075
|
+
const oldResource = projectResources('tunnel', root).find((x) => x.id === 'relay:alias-a');
|
|
1076
|
+
return old.status === 502 && current.status === 200 && await current.text() === 'alias-b'
|
|
1077
|
+
&& oldResource?.connected === false
|
|
1078
|
+
&& (await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any).tunnels === 1;
|
|
1079
|
+
} finally { socket.close(); relay.stop(); }
|
|
1080
|
+
})),
|
|
1081
|
+
done('tunnel.volter.status.live_disconnect', 'volter_management', 'Live status and persisted connected state fall to zero/false after the owning socket closes', 'api', 'core', () => withRoot(async (root) => {
|
|
1082
|
+
const relay = createTunnelTwinServer({ root });
|
|
1083
|
+
const control = await openControl(relay.url, 'close-proof', { authRequired: false });
|
|
1084
|
+
try {
|
|
1085
|
+
if ((await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any).tunnels !== 1) return false;
|
|
1086
|
+
const requestFrame = nextSocketMessage(control.socket, 'request');
|
|
1087
|
+
const pendingResponse = fetch(`${control.url}/pending-at-close`);
|
|
1088
|
+
await requestFrame;
|
|
1089
|
+
control.socket.close();
|
|
1090
|
+
const failed = await pendingResponse;
|
|
1091
|
+
if (failed.status !== 502 || (await failed.json() as any).error !== 'Tunnel disconnected') return false;
|
|
1092
|
+
for (let i = 0; i < 40; i++) {
|
|
1093
|
+
const status = await fetch(`${relay.url}/api/status`).then((r) => r.json()) as any;
|
|
1094
|
+
const resource = projectResources('tunnel', root).find((x) => x.id === 'relay:close-proof');
|
|
1095
|
+
if (status.tunnels === 0 && resource?.connected === false) return true;
|
|
1096
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
1097
|
+
}
|
|
1098
|
+
return false;
|
|
1099
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1100
|
+
})),
|
|
1101
|
+
done('tunnel.volter.auth.jwt_default', 'volter_auth', 'SDK-default authRequired=true denies anonymous traffic and accepts valid owned shared-SSO or tunnel-bound HS256 tokens', 'api', 'core', () => withRoot(async (root) => {
|
|
1102
|
+
const relay = createTunnelTwinServer({ root });
|
|
1103
|
+
const control = await openControl(relay.url, 'jwt-default');
|
|
1104
|
+
try {
|
|
1105
|
+
const denied = await fetch(`${control.url}/private`);
|
|
1106
|
+
if (denied.status !== 401) return false;
|
|
1107
|
+
const sharedToken = createTunnelTwinJwt({ sub: 'shared-user', exp: Math.floor(Date.now() / 1000) + 60 });
|
|
1108
|
+
let requestFrame = nextSocketMessage(control.socket, 'request');
|
|
1109
|
+
const sharedPromise = fetch(`${control.url}/shared?keep=1&__volter_token=${encodeURIComponent(sharedToken)}`);
|
|
1110
|
+
let request = await requestFrame;
|
|
1111
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa(String(request.path)) }));
|
|
1112
|
+
const shared = await sharedPromise;
|
|
1113
|
+
if (shared.status !== 200 || await shared.text() !== '/shared?keep=1') return false;
|
|
1114
|
+
requestFrame = nextSocketMessage(control.socket, 'request');
|
|
1115
|
+
const allowedPromise = fetch(`${control.url}/private?keep=1&__volter_token=${encodeURIComponent(relay.mintToken('jwt-default'))}`);
|
|
1116
|
+
request = await requestFrame;
|
|
1117
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa(String(request.path)) }));
|
|
1118
|
+
const allowed = await allowedPromise;
|
|
1119
|
+
return allowed.status === 200 && await allowed.text() === '/private?keep=1' && allowed.headers.get('set-cookie')?.includes('__volter_auth=') === true;
|
|
1120
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1121
|
+
})),
|
|
1122
|
+
done('tunnel.volter.auth.jwt_claim_types', 'volter_auth', 'JWT exp/nbf and present tid claims are validated, with missing tid rejected only in explicit requireTid mode', 'api', 'core', () => withRoot(async (root) => {
|
|
1123
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'jwt-types');
|
|
1124
|
+
try {
|
|
1125
|
+
const exp = await fetch(`${control.url}/x?__volter_token=${encodeURIComponent(relay.mintToken('jwt-types', { exp: 'never' }))}`);
|
|
1126
|
+
const nbf = await fetch(`${control.url}/x?__volter_token=${encodeURIComponent(relay.mintToken('jwt-types', { nbf: 'later' }))}`);
|
|
1127
|
+
const mismatch = await fetch(`${control.url}/x?__volter_token=${encodeURIComponent(relay.mintToken('another-tunnel'))}`);
|
|
1128
|
+
if (exp.status !== 401 || nbf.status !== 401 || mismatch.status !== 401) return false;
|
|
1129
|
+
|
|
1130
|
+
const strictRelay = createTunnelTwinServer({ root, requireTid: true, requestTimeoutMs: 200 });
|
|
1131
|
+
const strict = await openControl(strictRelay.url, 'jwt-strict');
|
|
1132
|
+
try {
|
|
1133
|
+
const missingTid = createTunnelTwinJwt({ sub: 'twin-local', exp: Math.floor(Date.now() / 1000) + 60 });
|
|
1134
|
+
const nonStringTid = createTunnelTwinJwt({ sub: 'twin-local', tid: 7 as unknown as string, exp: Math.floor(Date.now() / 1000) + 60 });
|
|
1135
|
+
const missing = await fetch(`${strict.url}/x?__volter_token=${encodeURIComponent(missingTid)}`);
|
|
1136
|
+
const typed = await fetch(`${strict.url}/x?__volter_token=${encodeURIComponent(nonStringTid)}`);
|
|
1137
|
+
const requestFrame = nextSocketMessage(strict.socket, 'request');
|
|
1138
|
+
const exactPromise = fetch(`${strict.url}/x?__volter_token=${encodeURIComponent(strictRelay.mintToken('jwt-strict'))}`);
|
|
1139
|
+
const request = await requestFrame;
|
|
1140
|
+
strict.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('bound') }));
|
|
1141
|
+
const exact = await exactPromise;
|
|
1142
|
+
return missing.status === 401 && typed.status === 401 && exact.status === 200 && await exact.text() === 'bound';
|
|
1143
|
+
} finally { strict.socket.close(); strictRelay.stop(); }
|
|
1144
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1145
|
+
})),
|
|
1146
|
+
done('tunnel.volter.http.malformed_response_contained', 'volter_http', 'Malformed complete response status/headers/body fail the owned visitor with stable JSON 502 instead of stranding it', 'api', 'core', () => withRoot(async (root) => {
|
|
1147
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'malformed-response', { authRequired: false });
|
|
1148
|
+
try {
|
|
1149
|
+
for (const malformed of [
|
|
1150
|
+
{ status: 999, headers: {}, body: '' },
|
|
1151
|
+
{ status: 200, headers: { 'bad\nname': 'x' }, body: '' },
|
|
1152
|
+
{ status: 200, headers: {}, body: '***not-base64***' },
|
|
1153
|
+
]) {
|
|
1154
|
+
const frame = nextSocketMessage(control.socket, 'request');
|
|
1155
|
+
const responsePromise = fetch(`${control.url}/malformed`);
|
|
1156
|
+
const request = await frame;
|
|
1157
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, ...malformed }));
|
|
1158
|
+
const response = await responsePromise;
|
|
1159
|
+
if (response.status !== 502 || (await response.json() as any).error !== 'Malformed tunnel response frame') return false;
|
|
1160
|
+
}
|
|
1161
|
+
return true;
|
|
1162
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1163
|
+
})),
|
|
1164
|
+
done('tunnel.volter.http.malformed_stream_sequence_contained', 'volter_http', 'Malformed or mixed stream response sequences terminate with a bounded error instead of stranding the visitor', 'api', 'core', () => withRoot(async (root) => {
|
|
1165
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'malformed-stream', { authRequired: false });
|
|
1166
|
+
try {
|
|
1167
|
+
const beforeStart = [
|
|
1168
|
+
await relayFrameOutcome(control, '/chunk-before-start', [{ type: 'response-chunk', data: btoa('x') }]),
|
|
1169
|
+
await relayFrameOutcome(control, '/end-before-start', [{ type: 'response-end' }]),
|
|
1170
|
+
await relayFrameOutcome(control, '/bad-start', [{ type: 'response-start', status: 999, headers: {} }]),
|
|
1171
|
+
];
|
|
1172
|
+
if (!beforeStart.every((outcome) => outcome.kind === 'response' && outcome.status === 502)) return false;
|
|
1173
|
+
const afterStart = [
|
|
1174
|
+
await relayFrameOutcome(control, '/duplicate-start', [
|
|
1175
|
+
{ type: 'response-start', status: 200, headers: {} },
|
|
1176
|
+
{ type: 'response-chunk', data: btoa('first') },
|
|
1177
|
+
{ type: 'response-start', status: 201, headers: {} },
|
|
1178
|
+
]),
|
|
1179
|
+
await relayFrameOutcome(control, '/complete-after-start', [
|
|
1180
|
+
{ type: 'response-start', status: 200, headers: {} },
|
|
1181
|
+
{ type: 'response-chunk', data: btoa('first') },
|
|
1182
|
+
{ type: 'response', status: 200, headers: {}, body: btoa('wrong-shape') },
|
|
1183
|
+
]),
|
|
1184
|
+
await relayFrameOutcome(control, '/bad-chunk-after-start', [
|
|
1185
|
+
{ type: 'response-start', status: 200, headers: {} },
|
|
1186
|
+
{ type: 'response-chunk', data: btoa('first') },
|
|
1187
|
+
{ type: 'response-chunk', data: '***not-base64***' },
|
|
1188
|
+
]),
|
|
1189
|
+
];
|
|
1190
|
+
// The 502 is only reachable because the head is NOT committed at `response-start`
|
|
1191
|
+
// (tunnel-server.ts says why, and tunnel-stream-boundary.test.ts pins the runtime fact
|
|
1192
|
+
// behind it). Each sequence now carries a VALID chunk before the malformed frame, so a
|
|
1193
|
+
// relay that had already begun delivering would answer these with a truncated 200 —
|
|
1194
|
+
// which is what this half would then be asserting away.
|
|
1195
|
+
return afterStart.every((outcome) => outcome.kind === 'response' && outcome.status === 502);
|
|
1196
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1197
|
+
})),
|
|
1198
|
+
// ── THE THREE STREAMING GAPS, AND THE MEASURED REASON THEY STAY TODO ────────────────
|
|
1199
|
+
//
|
|
1200
|
+
// All three need the relay to COMMIT a response head at `response-start` and deliver chunks
|
|
1201
|
+
// as they land. The blocker is not effort: through `Bun.serve`'s Response surface — the HTTP
|
|
1202
|
+
// host this relay uses, because it is also where `server.upgrade` and therefore the whole
|
|
1203
|
+
// WebSocket bridge come from — a body that has begun cannot be failed. Erroring the
|
|
1204
|
+
// `ReadableStream` behind a Response ends the chunked body cleanly, so a visitor reads a
|
|
1205
|
+
// truncated payload as a successful 200 and cannot tell it from a complete one — a fake
|
|
1206
|
+
// success — and a `content-length` that would have made the truncation detectable is dropped
|
|
1207
|
+
// the moment the body is a stream.
|
|
1208
|
+
//
|
|
1209
|
+
// §9 refuted the BROADER form of that sentence, correctly: `node:http` can signal a mid-body
|
|
1210
|
+
// failure on this same runtime, in this same process. So this is a TRADE with a named
|
|
1211
|
+
// alternative (re-host the HTTP plane, lose `server.upgrade`), not an impossibility, and
|
|
1212
|
+
// `tunnel-stream-boundary.test.ts` measures all three facts — the two limitations AND the
|
|
1213
|
+
// alternative — rather than only the half that supports the decision. Until the first two
|
|
1214
|
+
// flip, this relay buffers to `response-end`, which is what keeps
|
|
1215
|
+
// `malformed_stream_sequence_contained`'s 502 reachable at all.
|
|
1216
|
+
//
|
|
1217
|
+
// `stream_backpressure` has a second, independent blocker worth stating: the owned protocol's
|
|
1218
|
+
// fifteen discriminators contain no pause frame, so a relay cannot ask a client to slow down.
|
|
1219
|
+
// The only expressible bound is `request-abort`, which STOPS the request rather than pacing it.
|
|
1220
|
+
todo('tunnel.volter.http.streaming', 'volter_http', 'Reliable incremental visitor delivery for response-start/chunk/end (blocked: a committed body cannot be failed on this runtime — see tunnel-stream-boundary.test.ts)', 'api', 'core'),
|
|
1221
|
+
todo('tunnel.volter.http.stream_backpressure', 'volter_http', 'Backpressure from a slow visitor pauses or bounds client response-chunk production (blocked twice: no committed body, and the protocol has no pause frame)', 'api', 'core'),
|
|
1222
|
+
todo('tunnel.volter.http.stream_cancel_abort', 'volter_http', 'Canceling a streamed visitor response reliably sends request-abort (blocked: there is no committed visitor body to cancel while the relay buffers)', 'api', 'core'),
|
|
1223
|
+
// A STRUCTURAL gap in the protocol, filed rather than papered over (§9). `server.upgrade`
|
|
1224
|
+
// answers the browser's FIRST offered subprotocol, and the 101 must go out before `ws-ready`
|
|
1225
|
+
// exists — so the relay commits to one before the client has dialled the local server, which
|
|
1226
|
+
// may pick a DIFFERENT member of the full list the `ws-upgrade` frame forwards. None of the
|
|
1227
|
+
// vendor's fifteen discriminators can carry that choice back: `ws-ready` carries only `connId`.
|
|
1228
|
+
// A single-offer browser (the overwhelmingly common case, and what `websocket.upgrade` proves
|
|
1229
|
+
// end to end) cannot hit it; a multi-offer one can, silently.
|
|
1230
|
+
todo('tunnel.volter.websocket.subprotocol_negotiation', 'volter_websocket', 'A multi-offer subprotocol is negotiated with the LOCAL server rather than committed on the 101 (blocked: no discriminator carries the local choice back — ws-ready is connId only)', 'api', 'common'),
|
|
1231
|
+
todo('tunnel.volter.control.reconnect_backoff', 'volter_control', 'Client reconnect and stable-link backoff reset behavior', 'api', 'common'),
|
|
1232
|
+
todo('tunnel.volter.control.quota_frame', 'volter_control', 'Quota level frames with daily/monthly windows', 'api', 'common'),
|
|
1233
|
+
done('tunnel.volter.http.visitor_disconnect_abort', 'volter_http', 'A visitor TCP disconnect before response-start reliably sends request-abort through Bun\'s server boundary', 'api', 'core', () => withRoot(async (root) => {
|
|
1234
|
+
// The visitor walks away while the local server is still working. Unlike the streamed-response
|
|
1235
|
+
// reliability gaps, this one IS observable on this runtime: `Request.signal` fires on a
|
|
1236
|
+
// visitor hang-up, and what the relay must do with it is stop the client.
|
|
1237
|
+
const relay = createTunnelTwinServer({ root, requestTimeoutMs: 30_000 });
|
|
1238
|
+
const control = await openControl(relay.url, 'visitor-hangup', { authRequired: false });
|
|
1239
|
+
try {
|
|
1240
|
+
const aborter = new AbortController();
|
|
1241
|
+
const requestFrame = nextSocketMessage(control.socket, 'request');
|
|
1242
|
+
const visitorFailure = fetch(`${control.url}/slow`, { signal: aborter.signal }).then(() => 'completed' as const, () => 'aborted' as const);
|
|
1243
|
+
const request = await requestFrame;
|
|
1244
|
+
// No response frame at all: the client is still reading its local server when the browser
|
|
1245
|
+
// hangs up. The relay's request timeout is 30s, so a `request-abort` arriving here can only
|
|
1246
|
+
// have come from the disconnect.
|
|
1247
|
+
aborter.abort();
|
|
1248
|
+
const abort = await nextSocketMessage(control.socket, 'request-abort');
|
|
1249
|
+
const visitor = await visitorFailure;
|
|
1250
|
+
|
|
1251
|
+
// …and the request is genuinely FORGOTTEN: a late response frame for it is refused rather
|
|
1252
|
+
// than resolving a visitor that is already gone.
|
|
1253
|
+
const late = nextSocketMessage(control.socket, 'error');
|
|
1254
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: request.reqId, status: 200, headers: {}, body: btoa('too late') }));
|
|
1255
|
+
const refusal = await late;
|
|
1256
|
+
return (
|
|
1257
|
+
abort.reqId === request.reqId &&
|
|
1258
|
+
visitor === 'aborted' &&
|
|
1259
|
+
String(refusal.message).includes('not owned by this control socket')
|
|
1260
|
+
);
|
|
1261
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1262
|
+
})),
|
|
1263
|
+
done('tunnel.volter.http.cors_preflight', 'volter_http', 'CORS preflight succeeds before visitor auth and is not forwarded to the origin', 'api', 'common', () => withRoot(async (root) => {
|
|
1264
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'cors-proof');
|
|
1265
|
+
try {
|
|
1266
|
+
let forwarded = 0;
|
|
1267
|
+
const observe = (event: MessageEvent) => {
|
|
1268
|
+
const message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
|
1269
|
+
if (message.type === 'request') forwarded += 1;
|
|
1270
|
+
};
|
|
1271
|
+
control.socket.addEventListener('message', observe);
|
|
1272
|
+
const response = await fetch(`${control.url}/anything`, {
|
|
1273
|
+
method: 'OPTIONS', headers: { origin: 'https://caller.example', 'access-control-request-headers': 'x-custom' },
|
|
1274
|
+
});
|
|
1275
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
1276
|
+
control.socket.removeEventListener('message', observe);
|
|
1277
|
+
return response.status === 204 && response.headers.get('access-control-allow-origin') === 'https://caller.example'
|
|
1278
|
+
&& response.headers.get('access-control-allow-headers') === 'x-custom' && forwarded === 0;
|
|
1279
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1280
|
+
})),
|
|
1281
|
+
todo('tunnel.volter.http.forwarded_headers', 'volter_http', 'x-forwarded-for/host/proto are normalized like the owned relay', 'api', 'common'),
|
|
1282
|
+
todo('tunnel.volter.http.response_header_rules', 'volter_http', 'Operator response-header remove/set/append rules', 'api', 'niche'),
|
|
1283
|
+
todo('tunnel.volter.http.rate_burst', 'volter_http', 'Per-tunnel burst limiter returns 429 with Retry-After', 'api', 'common'),
|
|
1284
|
+
done('tunnel.volter.websocket.upgrade', 'volter_websocket', 'Browser WebSocket upgrade bridges ws-upgrade/ws-ready', 'api', 'core', () => withRoot(async (root) => {
|
|
1285
|
+
const bridge = await openWsBridge(root);
|
|
1286
|
+
try {
|
|
1287
|
+
// A SUBPROTOCOL is offered, because a browser that asks for one and gets a 101 without
|
|
1288
|
+
// `Sec-WebSocket-Protocol` back fails the connection outright — the vendor's own client
|
|
1289
|
+
// names Vite's `vite-hmr` as the case that breaks.
|
|
1290
|
+
//
|
|
1291
|
+
// TWO are offered, and the 101 is read as RAW BYTES. §9 caught the single-offer version of
|
|
1292
|
+
// this assertion being a fixture artifact: Bun's WebSocket client does not enforce RFC 6455
|
|
1293
|
+
// §4.1 for a one-element offer, so `socket.protocol` echoed the client's own REQUEST.
|
|
1294
|
+
// Reading the response head is what makes this a claim about what the relay wrote — and it
|
|
1295
|
+
// is the pin on a contract the RUNTIME currently satisfies (`server.upgrade` answers with
|
|
1296
|
+
// the first offer by itself), which is exactly why it needs one: without it, nothing would
|
|
1297
|
+
// notice the day that stopped being true.
|
|
1298
|
+
const handshake = await readUpgradeHandshake(`${bridge.control.url.replace(/^http/, 'ws')}/socket?room=1`, {
|
|
1299
|
+
'Sec-WebSocket-Protocol': 'vite-hmr, graphql-ws',
|
|
1300
|
+
});
|
|
1301
|
+
const { socket: visitor, upgrade } = await bridge.visitor('/socket?room=1', ['vite-hmr', 'graphql-ws']);
|
|
1302
|
+
// The bridge is LIVE, not merely announced: a frame sent by the browser reaches the real
|
|
1303
|
+
// local server and its answer comes back. That is what an upgrade with no bridge behind it
|
|
1304
|
+
// could not do.
|
|
1305
|
+
visitor.send('ping');
|
|
1306
|
+
const echoed = await nextVisitorFrame(visitor);
|
|
1307
|
+
visitor.close();
|
|
1308
|
+
return (
|
|
1309
|
+
upgrade !== undefined &&
|
|
1310
|
+
UUID_PATTERN.test(String(upgrade.connId)) &&
|
|
1311
|
+
// the tunnelled path and query reach the client, which is what it dials locally
|
|
1312
|
+
upgrade.path === '/socket?room=1' &&
|
|
1313
|
+
typeof upgrade.headers?.['sec-websocket-key'] === 'string' &&
|
|
1314
|
+
// negotiated end to end: the relay's own 101 names it (read off the wire, not off the
|
|
1315
|
+
// client's request), the browser's socket agrees, and the frame forwarded the full offer
|
|
1316
|
+
// so the client can dial the local server with it
|
|
1317
|
+
handshake.status === 101 &&
|
|
1318
|
+
handshake.headers['sec-websocket-protocol'] === 'vite-hmr' &&
|
|
1319
|
+
visitor.protocol === 'vite-hmr' &&
|
|
1320
|
+
upgrade.headers['sec-websocket-protocol'] === 'vite-hmr, graphql-ws' &&
|
|
1321
|
+
'data' in echoed &&
|
|
1322
|
+
echoed.data === 'echo:/socket?room=1:ping'
|
|
1323
|
+
);
|
|
1324
|
+
} finally { bridge.stop(); }
|
|
1325
|
+
})),
|
|
1326
|
+
done('tunnel.volter.websocket.messages', 'volter_websocket', 'Text and binary WebSocket frames bridge bidirectionally', 'api', 'core', () => withRoot(async (root) => {
|
|
1327
|
+
const bridge = await openWsBridge(root);
|
|
1328
|
+
try {
|
|
1329
|
+
const { socket: visitor } = await bridge.visitor('/echo');
|
|
1330
|
+
visitor.send('hello');
|
|
1331
|
+
const text = await nextVisitorFrame(visitor);
|
|
1332
|
+
// The local server REVERSES binary payloads, so an accidental text round trip cannot
|
|
1333
|
+
// satisfy this half — and the `binary` flag has to survive both directions for the local
|
|
1334
|
+
// server to have received a binary frame at all.
|
|
1335
|
+
visitor.send(new Uint8Array([1, 2, 3, 4]));
|
|
1336
|
+
const binary = await nextVisitorFrame(visitor);
|
|
1337
|
+
visitor.close();
|
|
1338
|
+
const relayedBinary = bridge.frames.filter((frame) => frame.type === 'ws-message' && frame.binary === true);
|
|
1339
|
+
const relayedText = bridge.frames.filter((frame) => frame.type === 'ws-message' && frame.binary === false);
|
|
1340
|
+
return (
|
|
1341
|
+
'data' in text && text.data === 'echo:/echo:hello' &&
|
|
1342
|
+
'data' in binary && binary.data instanceof ArrayBuffer &&
|
|
1343
|
+
[...new Uint8Array(binary.data)].join(',') === '4,3,2,1' &&
|
|
1344
|
+
relayedText.length >= 1 && relayedBinary.length >= 1
|
|
1345
|
+
);
|
|
1346
|
+
} finally { bridge.stop(); }
|
|
1347
|
+
})),
|
|
1348
|
+
done('tunnel.volter.websocket.close', 'volter_websocket', 'Close code/reason bridges in both directions and the reason is clamped to 123 bytes', 'api', 'common', () => withRoot(async (root) => {
|
|
1349
|
+
const bridge = await openWsBridge(root);
|
|
1350
|
+
try {
|
|
1351
|
+
// ── browser → client: the visitor hangs up, the client is told ──
|
|
1352
|
+
const { socket: visitor } = await bridge.visitor('/bye');
|
|
1353
|
+
visitor.send('ping');
|
|
1354
|
+
await nextVisitorFrame(visitor);
|
|
1355
|
+
visitor.close(4123, 'visitor done');
|
|
1356
|
+
const relayed = await nextSocketMessage(bridge.control.socket, 'ws-close');
|
|
1357
|
+
|
|
1358
|
+
// ── client → browser: a ws-close frame closes the visitor, with the code and a reason
|
|
1359
|
+
// clamped to the 123 bytes a close frame can carry ──
|
|
1360
|
+
const second = await bridge.visitor('/bye2');
|
|
1361
|
+
const closing = nextVisitorFrame(second.socket);
|
|
1362
|
+
bridge.control.socket.send(JSON.stringify({ type: 'ws-close', connId: second.upgrade.connId, code: 4004, reason: 'x'.repeat(200) }));
|
|
1363
|
+
const closed = await closing;
|
|
1364
|
+
// …and an impossible code is normalised rather than sent: 1006 may never appear on the wire.
|
|
1365
|
+
const third = await bridge.visitor('/bye3');
|
|
1366
|
+
const thirdClosing = nextVisitorFrame(third.socket);
|
|
1367
|
+
bridge.control.socket.send(JSON.stringify({ type: 'ws-close', connId: third.upgrade.connId, code: 1006 }));
|
|
1368
|
+
const thirdClosed = await thirdClosing;
|
|
1369
|
+
return (
|
|
1370
|
+
// The CODE is what the relay carries outward; the reason a browser sends is not
|
|
1371
|
+
// delivered to the server handler on this runtime, so asserting it here would be
|
|
1372
|
+
// asserting something the relay never sees. The reason IS asserted in the other
|
|
1373
|
+
// direction, below, where the relay is the one producing it.
|
|
1374
|
+
relayed.code === 4123 && typeof relayed.reason === 'string' &&
|
|
1375
|
+
'closed' in closed && closed.closed.code === 4004 && closed.closed.reason === 'x'.repeat(123) &&
|
|
1376
|
+
'closed' in thirdClosed && thirdClosed.closed.code === 1000
|
|
1377
|
+
);
|
|
1378
|
+
} finally { bridge.stop(); }
|
|
1379
|
+
})),
|
|
1380
|
+
done('tunnel.volter.auth.jwt_cookie', 'volter_auth', 'JWT cookie authentication strips the relay cookie before forwarding and enforces tunnel-id binding', 'api', 'core', () => withRoot(jwtCookieProof)),
|
|
1381
|
+
done('tunnel.volter.auth.cookie_bootstrap', 'volter_auth', 'GET /__volter_auth validates a token and mints a path-scoped local relay cookie', 'api', 'common', () => withRoot(jwtCookieProof)),
|
|
1382
|
+
todo('tunnel.volter.auth.constant_time_secret', 'volter_auth', 'Shared secret verification is constant-time', 'api', 'common'),
|
|
1383
|
+
todo('tunnel.volter.reservations.claim', 'volter_reservations', 'Persistent tunnel-id reservation claim/refresh/reclaim', 'api', 'common'),
|
|
1384
|
+
todo('tunnel.volter.reservations.release_self', 'volter_reservations', 'DELETE /me/reservations/:tunnelId releases the caller-owned reservation', 'api', 'common'),
|
|
1385
|
+
todo('tunnel.volter.reservations.release_admin', 'volter_reservations', 'DELETE /admin/reservations/:tunnelId revokes a reservation as root', 'api', 'niche'),
|
|
1386
|
+
todo('tunnel.volter.accounts.whoami', 'volter_accounts', 'GET /me returns account and usage snapshot', 'api', 'common'),
|
|
1387
|
+
todo('tunnel.volter.accounts.device_tokens_list', 'volter_accounts', 'GET /me/tokens lists the caller account device credentials', 'api', 'niche'),
|
|
1388
|
+
todo('tunnel.volter.accounts.device_tokens_restore', 'volter_accounts', 'POST /me/tokens/:tokenId/restore restores a revoked device credential', 'api', 'niche'),
|
|
1389
|
+
todo('tunnel.volter.accounts.device_tokens_revoke', 'volter_accounts', 'DELETE /me/tokens/:tokenId revokes a device credential', 'api', 'niche'),
|
|
1390
|
+
todo('tunnel.volter.accounts.admin_list', 'volter_accounts', 'GET /admin/accounts lists accounts', 'api', 'niche'),
|
|
1391
|
+
todo('tunnel.volter.accounts.admin_create', 'volter_accounts', 'POST /admin/accounts creates an account', 'api', 'niche'),
|
|
1392
|
+
todo('tunnel.volter.accounts.admin_tokens_list', 'volter_accounts', 'GET /admin/accounts/:slug/tokens lists service tokens', 'api', 'niche'),
|
|
1393
|
+
todo('tunnel.volter.accounts.admin_tokens_create', 'volter_accounts', 'POST /admin/accounts/:slug/tokens creates a service token', 'api', 'niche'),
|
|
1394
|
+
todo('tunnel.volter.accounts.admin_tokens_revoke', 'volter_accounts', 'DELETE /admin/accounts/:slug/tokens/:tokenId revokes a service token', 'api', 'niche'),
|
|
1395
|
+
todo('tunnel.volter.accounts.admin_limits_patch', 'volter_accounts', 'PATCH /admin/accounts/:slug/limits updates account limits', 'api', 'niche'),
|
|
1396
|
+
todo('tunnel.volter.accounts.admin_suspend', 'volter_accounts', 'POST /admin/accounts/:slug/suspend suspends an account', 'api', 'niche'),
|
|
1397
|
+
todo('tunnel.volter.accounts.admin_resume', 'volter_accounts', 'POST /admin/accounts/:slug/resume resumes an account', 'api', 'niche'),
|
|
1398
|
+
todo('tunnel.volter.accounts.admin_usage', 'volter_accounts', 'GET /admin/accounts/:slug/usage returns one account usage snapshot', 'api', 'niche'),
|
|
1399
|
+
todo('tunnel.volter.usage.metering', 'volter_usage', 'Request/WebSocket/byte/second credit metering', 'api', 'common'),
|
|
1400
|
+
todo('tunnel.volter.usage.admin_summary', 'volter_usage', 'GET /admin/usage returns the fleet usage summary', 'api', 'niche'),
|
|
1401
|
+
todo('tunnel.volter.management.report_create', 'volter_management', 'POST /report submits a public abuse report', 'api', 'niche'),
|
|
1402
|
+
todo('tunnel.volter.management.reports_admin_list', 'volter_management', 'GET /admin/reports lists abuse reports as root', 'api', 'niche'),
|
|
1403
|
+
todo('tunnel.volter.signup.github_token', 'volter_signup', 'POST /signup/github signs up using a GitHub access token', 'api', 'niche'),
|
|
1404
|
+
todo('tunnel.volter.signup.github_gist_start', 'volter_signup', 'POST /signup/github/gist/start starts gist-based identity verification', 'api', 'niche'),
|
|
1405
|
+
todo('tunnel.volter.signup.github_gist_verify', 'volter_signup', 'POST /signup/github/gist/verify completes gist-based identity verification', 'api', 'niche'),
|
|
1406
|
+
todo('tunnel.volter.signup.waitlist_create', 'volter_signup', 'POST /waitlist submits a public waitlist request', 'api', 'niche'),
|
|
1407
|
+
todo('tunnel.volter.signup.waitlist_admin_list', 'volter_signup', 'GET /admin/waitlist lists waitlist requests as root', 'api', 'niche'),
|
|
1408
|
+
todo('tunnel.volter.signup.waitlist_admin_remove', 'volter_signup', 'DELETE /admin/waitlist/:githubUser removes a waitlist entry', 'api', 'niche'),
|
|
1409
|
+
todo('tunnel.volter.inspector.list', 'volter_inspector', 'GET /__volter_inspect returns the owner-authenticated live request ring', 'api', 'niche'),
|
|
1410
|
+
todo('tunnel.volter.inspector.replay', 'volter_inspector', 'POST /__volter_replay reissues a captured request', 'api', 'niche'),
|
|
1411
|
+
todo('tunnel.volter.management.landing', 'volter_management', 'GET / serves the owned relay public landing surface', 'api', 'niche'),
|
|
1412
|
+
todo('tunnel.volter.management.docs', 'volter_management', 'GET /docs serves the owned relay documentation surface', 'api', 'niche'),
|
|
1413
|
+
|
|
1414
|
+
// The owned protocol's authoritative 15 message discriminators. Implemented frames have
|
|
1415
|
+
// transport proofs above; every remaining frame stays visible here instead of disappearing
|
|
1416
|
+
// from the denominator behind broader feature labels.
|
|
1417
|
+
done('tunnel.volter.protocol.register', 'volter_control', 'Protocol frame: register', 'api', 'core', () => withRoot(async (root) => {
|
|
1418
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'frame-register', { authRequired: false });
|
|
1419
|
+
try {
|
|
1420
|
+
const resource = projectResources('tunnel', root).find((candidate) => candidate.id === 'relay:frame-register');
|
|
1421
|
+
return control.tunnelId === 'frame-register' && resource?.connected === true
|
|
1422
|
+
&& listActions('tunnel', root).some((action) => action.operation === 'relay.register' && action.subject.id === 'relay:frame-register');
|
|
1423
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1424
|
+
})),
|
|
1425
|
+
done('tunnel.volter.protocol.registered', 'volter_control', 'Protocol frame: registered', 'api', 'core', () => withRoot(async (root) => {
|
|
1426
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'frame-registered', { authRequired: false });
|
|
1427
|
+
try { return control.tunnelId === 'frame-registered' && control.url === `${relay.url}/__twin/tunnels/frame-registered`; }
|
|
1428
|
+
finally { control.socket.close(); relay.stop(); }
|
|
1429
|
+
})),
|
|
1430
|
+
done('tunnel.volter.protocol.request', 'volter_http', 'Protocol frame: request', 'api', 'core', () => withRoot(async (root) => (await protocolRoundtrip(root)).body.marker === 'genuine-origin')),
|
|
1431
|
+
done('tunnel.volter.protocol.response', 'volter_http', 'Protocol frame: response', 'api', 'core', () => withRoot(async (root) => (await protocolRoundtrip(root)).response.status === 201)),
|
|
1432
|
+
done('tunnel.volter.protocol.response_start', 'volter_http', 'Protocol frame: response-start', 'api', 'core', () => withRoot(async (root) => {
|
|
1433
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'frame-start', { authRequired: false });
|
|
1434
|
+
try {
|
|
1435
|
+
const frame = nextSocketMessage(control.socket, 'request'); const p = fetch(`${control.url}/x`); const req = await frame;
|
|
1436
|
+
control.socket.send(JSON.stringify({ type: 'response-start', reqId: req.reqId, status: 206, headers: {} }));
|
|
1437
|
+
control.socket.send(JSON.stringify({ type: 'response-chunk', reqId: req.reqId, data: btoa('x') }));
|
|
1438
|
+
control.socket.send(JSON.stringify({ type: 'response-end', reqId: req.reqId }));
|
|
1439
|
+
const res = await p; await res.arrayBuffer();
|
|
1440
|
+
// The frame is ACCEPTED — and the three reliability capabilities that depend on COMMITTING
|
|
1441
|
+
// the head at this point are still honestly `todo`. That list shrank by one when
|
|
1442
|
+
// `visitor_disconnect_abort` was built (it never needed a committed head: a visitor hang-up
|
|
1443
|
+
// before `response-start` is observable on this runtime), which is exactly the kind of
|
|
1444
|
+
// silent staleness this cross-check exists to force.
|
|
1445
|
+
const reliabilityTodos = ['tunnel.volter.http.streaming', 'tunnel.volter.http.stream_backpressure', 'tunnel.volter.http.stream_cancel_abort'];
|
|
1446
|
+
return res.status === 206 && reliabilityTodos.every((id) => {
|
|
1447
|
+
const capability = TUNNEL_CAPABILITIES.find((candidate) => candidate.id === id);
|
|
1448
|
+
return capability?.expected === 'todo' && capability.verify === undefined && capability.tier === 'core';
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
finally { control.socket.close(); relay.stop(); }
|
|
1452
|
+
})),
|
|
1453
|
+
done('tunnel.volter.protocol.response_chunk', 'volter_http', 'Protocol frame: response-chunk', 'api', 'core', () => withRoot(async (root) => {
|
|
1454
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'frame-chunk', { authRequired: false });
|
|
1455
|
+
try { const frame = nextSocketMessage(control.socket, 'request'); const p = fetch(`${control.url}/x`); const req = await frame; control.socket.send(JSON.stringify({ type: 'response-start', reqId: req.reqId, status: 200, headers: {} })); control.socket.send(JSON.stringify({ type: 'response-chunk', reqId: req.reqId, data: btoa('chunk') })); control.socket.send(JSON.stringify({ type: 'response-end', reqId: req.reqId })); const res = await p; return await res.text() === 'chunk'; }
|
|
1456
|
+
finally { control.socket.close(); relay.stop(); }
|
|
1457
|
+
})),
|
|
1458
|
+
done('tunnel.volter.protocol.response_end', 'volter_http', 'Protocol frame: response-end', 'api', 'core', () => withRoot(async (root) => {
|
|
1459
|
+
const relay = createTunnelTwinServer({ root }); const control = await openControl(relay.url, 'frame-end', { authRequired: false });
|
|
1460
|
+
try { const frame = nextSocketMessage(control.socket, 'request'); const p = fetch(`${control.url}/x`); const req = await frame; control.socket.send(JSON.stringify({ type: 'response-start', reqId: req.reqId, status: 200, headers: {} })); control.socket.send(JSON.stringify({ type: 'response-end', reqId: req.reqId })); const res = await p; return (await res.arrayBuffer()).byteLength === 0; }
|
|
1461
|
+
finally { control.socket.close(); relay.stop(); }
|
|
1462
|
+
})),
|
|
1463
|
+
done('tunnel.volter.protocol.request_abort', 'volter_http', 'Protocol frame: request-abort', 'api', 'core', () => withRoot(async (root) => {
|
|
1464
|
+
// A request that ends badly must STOP the client, not just answer the visitor. The client's
|
|
1465
|
+
// own handler destroys the local request on this frame, so a relay that forgot to send it
|
|
1466
|
+
// would leave the local server producing a response nobody will ever read.
|
|
1467
|
+
// 400ms, not 120: §9 ranked the old budget the flakiest thing in this file — the second
|
|
1468
|
+
// half needs a whole fetch/frame/response round trip inside it while the catalog's other T0s
|
|
1469
|
+
// run alongside. It fails RED when missed (504 ≠ 200), so this only buys headroom.
|
|
1470
|
+
const relay = createTunnelTwinServer({ root, requestTimeoutMs: 400 });
|
|
1471
|
+
const control = await openControl(relay.url, 'frame-abort', { authRequired: false });
|
|
1472
|
+
try {
|
|
1473
|
+
// (1) The TIMEOUT path: the client never answers, the relay gives up.
|
|
1474
|
+
const timedOutFrame = nextSocketMessage(control.socket, 'request');
|
|
1475
|
+
const timedOut = fetch(`${control.url}/never-answered`);
|
|
1476
|
+
const first = await timedOutFrame;
|
|
1477
|
+
const abortOnTimeout = await nextSocketMessage(control.socket, 'request-abort');
|
|
1478
|
+
const timedOutResponse = await timedOut;
|
|
1479
|
+
const timedOutBody = await timedOutResponse.json() as { error?: string };
|
|
1480
|
+
|
|
1481
|
+
// (2) The COMPLETED path: a request the client answers must NOT be aborted. Without this
|
|
1482
|
+
// half, a relay that sent `request-abort` after every request would pass the first half.
|
|
1483
|
+
const okFrame = nextSocketMessage(control.socket, 'request');
|
|
1484
|
+
const okResponse = fetch(`${control.url}/answered`);
|
|
1485
|
+
const second = await okFrame;
|
|
1486
|
+
control.socket.send(JSON.stringify({ type: 'response', reqId: second.reqId, status: 200, headers: {}, body: btoa('done') }));
|
|
1487
|
+
const settled = await okResponse;
|
|
1488
|
+
const body = await settled.text();
|
|
1489
|
+
const strayAbort = await Promise.race([
|
|
1490
|
+
nextSocketMessage(control.socket, 'request-abort', 250).then(() => true).catch(() => false),
|
|
1491
|
+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 300)),
|
|
1492
|
+
]);
|
|
1493
|
+
return (
|
|
1494
|
+
abortOnTimeout.reqId === first.reqId &&
|
|
1495
|
+
UUID_PATTERN.test(String(first.reqId)) &&
|
|
1496
|
+
timedOutResponse.status === 504 &&
|
|
1497
|
+
timedOutBody.error === 'Tunnel request timed out' &&
|
|
1498
|
+
settled.status === 200 && body === 'done' &&
|
|
1499
|
+
strayAbort === false
|
|
1500
|
+
);
|
|
1501
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1502
|
+
})),
|
|
1503
|
+
done('tunnel.volter.protocol.error', 'volter_control', 'Protocol frame: error', 'api', 'core', () => withRoot(async (root) => {
|
|
1504
|
+
const relay = createTunnelTwinServer({ root }); const socket = new WebSocket(`${relay.url.replace(/^http/, 'ws')}/ws`);
|
|
1505
|
+
try {
|
|
1506
|
+
await new Promise<void>((resolve, reject) => {
|
|
1507
|
+
socket.addEventListener('open', () => resolve(), { once: true });
|
|
1508
|
+
socket.addEventListener('error', () => reject(new Error('error-frame socket failed')), { once: true });
|
|
1509
|
+
});
|
|
1510
|
+
const rejected = nextSocketMessage(socket, 'error');
|
|
1511
|
+
socket.send(JSON.stringify({ type: 'register', tunnelId: 'frame-error' }));
|
|
1512
|
+
const error = await rejected;
|
|
1513
|
+
return error.type === 'error' && error.fatal === true && listActions('tunnel', root).length === 0;
|
|
1514
|
+
} finally { socket.close(); relay.stop(); }
|
|
1515
|
+
})),
|
|
1516
|
+
done('tunnel.volter.protocol.ws_ready', 'volter_websocket', 'Protocol frame: ws-ready', 'api', 'core', () => withRoot(async (root) => {
|
|
1517
|
+
// A browser usually speaks FIRST (a client library sends its hello immediately), and that
|
|
1518
|
+
// happens while the client is still dialling its local server. `ws-ready` is the frame that
|
|
1519
|
+
// says the bridge exists — so what it must prove is that frames sent BEFORE it are delivered
|
|
1520
|
+
// after it, in order, rather than dropped into a bridge that did not exist yet.
|
|
1521
|
+
const bridge = await openWsBridge(root, 'hold');
|
|
1522
|
+
try {
|
|
1523
|
+
const { socket: visitor } = await bridge.visitor('/ready');
|
|
1524
|
+
visitor.send('early-one');
|
|
1525
|
+
visitor.send('early-two');
|
|
1526
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1527
|
+
const beforeReady = bridge.frames.filter((frame) => frame.type === 'ws-message');
|
|
1528
|
+
bridge.release();
|
|
1529
|
+
const first = await nextVisitorFrame(visitor);
|
|
1530
|
+
const second = await nextVisitorFrame(visitor);
|
|
1531
|
+
visitor.close();
|
|
1532
|
+
return (
|
|
1533
|
+
// nothing was relayed while the bridge was still coming up…
|
|
1534
|
+
beforeReady.length === 0 &&
|
|
1535
|
+
// …and then BOTH queued frames arrive, in the order the browser sent them
|
|
1536
|
+
'data' in first && first.data === 'echo:/ready:early-one' &&
|
|
1537
|
+
'data' in second && second.data === 'echo:/ready:early-two'
|
|
1538
|
+
);
|
|
1539
|
+
} finally { bridge.stop(); }
|
|
1540
|
+
})),
|
|
1541
|
+
done('tunnel.volter.protocol.ws_error', 'volter_websocket', 'Protocol frame: ws-error', 'api', 'core', () => withRoot(async (root) => {
|
|
1542
|
+
// The client could not reach the local server. The browser must be TOLD — a bridge that
|
|
1543
|
+
// silently stayed open would leave a page waiting forever on a socket that goes nowhere.
|
|
1544
|
+
const bridge = await openWsBridge(root, 'refuse');
|
|
1545
|
+
try {
|
|
1546
|
+
// Opened by hand rather than through `visitor()`: the refusal can land before the browser's
|
|
1547
|
+
// own `open` fires, so the outcome to wait for is "opened OR closed", not "opened".
|
|
1548
|
+
const visitor = new WebSocket(`${bridge.control.url.replace(/^http/, 'ws')}/refused`);
|
|
1549
|
+
const closed = await new Promise<{ code: number; reason: string } | 'still-open'>((resolve, reject) => {
|
|
1550
|
+
const timer = setTimeout(() => reject(new Error('the refused visitor socket was never closed')), 3_000);
|
|
1551
|
+
visitor.addEventListener('close', (event) => { clearTimeout(timer); resolve({ code: event.code, reason: event.reason }); }, { once: true });
|
|
1552
|
+
});
|
|
1553
|
+
const upgrade = bridge.frames.find((frame) => frame.type === 'ws-upgrade');
|
|
1554
|
+
return (
|
|
1555
|
+
upgrade !== undefined &&
|
|
1556
|
+
closed !== 'still-open' &&
|
|
1557
|
+
// 1011 "internal error" with the client's OWN message — the browser learns why, and the
|
|
1558
|
+
// relay does not invent a reason of its own.
|
|
1559
|
+
closed.code === 1011 &&
|
|
1560
|
+
closed.reason === 'ECONNREFUSED dialling local server'
|
|
1561
|
+
);
|
|
1562
|
+
} finally { bridge.stop(); }
|
|
1563
|
+
})),
|
|
1564
|
+
done('tunnel.volter.protocol.ws_message', 'volter_websocket', 'Protocol frame: ws-message', 'api', 'core', () => withRoot(async (root) => {
|
|
1565
|
+
const bridge = await openWsBridge(root);
|
|
1566
|
+
try {
|
|
1567
|
+
const { socket: visitor, upgrade } = await bridge.visitor('/frames');
|
|
1568
|
+
visitor.send(new Uint8Array([9, 8, 7]));
|
|
1569
|
+
const reversed = await nextVisitorFrame(visitor);
|
|
1570
|
+
const relayed = bridge.frames.filter((frame) => frame.type === 'ws-message');
|
|
1571
|
+
visitor.close();
|
|
1572
|
+
// The frame's own fields: correlated by connId, payload base64, and the `binary` flag the
|
|
1573
|
+
// client uses to decide the local frame type. A relay that dropped `binary` would turn
|
|
1574
|
+
// every binary frame into text without any error to notice.
|
|
1575
|
+
return (
|
|
1576
|
+
relayed.length === 1 &&
|
|
1577
|
+
relayed[0]!.connId === upgrade.connId &&
|
|
1578
|
+
relayed[0]!.binary === true &&
|
|
1579
|
+
Buffer.from(String(relayed[0]!.data), 'base64').toString('hex') === '090807' &&
|
|
1580
|
+
'closed' in reversed === false &&
|
|
1581
|
+
'data' in reversed && reversed.data instanceof ArrayBuffer &&
|
|
1582
|
+
[...new Uint8Array(reversed.data)].join(',') === '7,8,9'
|
|
1583
|
+
);
|
|
1584
|
+
} finally { bridge.stop(); }
|
|
1585
|
+
})),
|
|
1586
|
+
done('tunnel.volter.protocol.ws_close', 'volter_websocket', 'Protocol frame: ws-close', 'api', 'common', () => withRoot(async (root) => {
|
|
1587
|
+
const bridge = await openWsBridge(root);
|
|
1588
|
+
try {
|
|
1589
|
+
const { socket: visitor, upgrade } = await bridge.visitor('/closing');
|
|
1590
|
+
visitor.close(4200, 'done');
|
|
1591
|
+
const relayed = await nextSocketMessage(bridge.control.socket, 'ws-close');
|
|
1592
|
+
// …and a SECOND close is not sent when the close came from the client's own side: the
|
|
1593
|
+
// relay would otherwise echo a close back at the party that sent it.
|
|
1594
|
+
const second = await bridge.visitor('/closing2');
|
|
1595
|
+
const before = bridge.frames.filter((frame) => frame.type === 'ws-close').length;
|
|
1596
|
+
const closing = nextVisitorFrame(second.socket);
|
|
1597
|
+
bridge.control.socket.send(JSON.stringify({ type: 'ws-close', connId: second.upgrade.connId, code: 1000 }));
|
|
1598
|
+
await closing;
|
|
1599
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1600
|
+
return (
|
|
1601
|
+
relayed.connId === upgrade.connId && relayed.code === 4200 &&
|
|
1602
|
+
bridge.frames.filter((frame) => frame.type === 'ws-close').length === before
|
|
1603
|
+
);
|
|
1604
|
+
} finally { bridge.stop(); }
|
|
1605
|
+
})),
|
|
1606
|
+
done('tunnel.volter.protocol.ws_upgrade', 'volter_websocket', 'Protocol frame: ws-upgrade', 'api', 'core', () => withRoot(async (root) => {
|
|
1607
|
+
const bridge = await openWsBridge(root);
|
|
1608
|
+
try {
|
|
1609
|
+
const { socket: visitor, upgrade } = await bridge.visitor('/upgrade/path?x=1');
|
|
1610
|
+
visitor.close();
|
|
1611
|
+
// The frame the client dials from: a fresh connId, the TUNNELLED path (query included),
|
|
1612
|
+
// and the browser's own handshake headers — which is how the client forwards a
|
|
1613
|
+
// subprotocol and an auth cookie to the local server.
|
|
1614
|
+
return (
|
|
1615
|
+
UUID_PATTERN.test(String(upgrade.connId)) &&
|
|
1616
|
+
upgrade.path === '/upgrade/path?x=1' &&
|
|
1617
|
+
typeof upgrade.headers === 'object' &&
|
|
1618
|
+
typeof upgrade.headers['sec-websocket-key'] === 'string' &&
|
|
1619
|
+
upgrade.headers['x-forwarded-for'] === '0.0.0.0' &&
|
|
1620
|
+
// one connId per socket — two browsers on one tunnel must not share a bridge
|
|
1621
|
+
(await (async () => {
|
|
1622
|
+
const other = await bridge.visitor('/upgrade/path?x=1');
|
|
1623
|
+
other.socket.close();
|
|
1624
|
+
return other.upgrade.connId !== upgrade.connId;
|
|
1625
|
+
})())
|
|
1626
|
+
);
|
|
1627
|
+
} finally { bridge.stop(); }
|
|
1628
|
+
})),
|
|
1629
|
+
done('tunnel.volter.websocket.auth_gate', 'volter_auth', 'A browser WebSocket upgrade passes the SAME auth gates the HTTP path does — an unauthenticated upgrade never reaches a bridge', 'api', 'common', () => withRoot(async (root) => {
|
|
1630
|
+
// The gate has to bite BEFORE `server.upgrade`, or the relay would have an unauthenticated
|
|
1631
|
+
// hole sitting beside an authenticated door. `refused === 'closed'` is what proves the
|
|
1632
|
+
// ORDER, and it needs no timing to do it: a relay that upgraded first would fire `open` on
|
|
1633
|
+
// that socket before any close, and the promise below would resolve `'opened'`. The frame
|
|
1634
|
+
// counts are the second, independent tooth — `afterAuth === 1` is an exact equality, so a
|
|
1635
|
+
// stray `ws-upgrade` from the refused attempt reddens it. (§9 was right that the original
|
|
1636
|
+
// comment credited the frame count for what the close proves, and that the negative half
|
|
1637
|
+
// deserved the settle the positive one had.)
|
|
1638
|
+
const relay = createTunnelTwinServer({ root });
|
|
1639
|
+
// `authRequired` defaults to the owned relay's own default (true) — deliberately not passed.
|
|
1640
|
+
const control = await openControl(relay.url, 'ws-auth');
|
|
1641
|
+
try {
|
|
1642
|
+
const frames: Array<Record<string, any>> = [];
|
|
1643
|
+
control.socket.addEventListener('message', (event) => frames.push(JSON.parse(String(event.data)) as Record<string, any>));
|
|
1644
|
+
const anonymous = new WebSocket(`${control.url.replace(/^http/, 'ws')}/private`);
|
|
1645
|
+
const refused = await new Promise<string>((resolve) => {
|
|
1646
|
+
const timer = setTimeout(() => resolve('still-open'), 2_000);
|
|
1647
|
+
anonymous.addEventListener('open', () => { clearTimeout(timer); resolve('opened'); }, { once: true });
|
|
1648
|
+
anonymous.addEventListener('close', () => { clearTimeout(timer); resolve('closed'); }, { once: true });
|
|
1649
|
+
anonymous.addEventListener('error', () => { clearTimeout(timer); resolve('closed'); }, { once: true });
|
|
1650
|
+
});
|
|
1651
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1652
|
+
const afterRefusal = frames.filter((frame) => frame.type === 'ws-upgrade').length;
|
|
1653
|
+
// …and the SAME upgrade with the relay's own minted, tunnel-bound token is accepted.
|
|
1654
|
+
const token = relay.mintToken(control.tunnelId);
|
|
1655
|
+
// THE 101's OWN BYTES: a query-token upgrade must hand back the same `__volter_auth`
|
|
1656
|
+
// cookie the identical HTTP request does, so the page's later requests are authenticated
|
|
1657
|
+
// without re-carrying the token. Bun's WebSocket client cannot see a response header at
|
|
1658
|
+
// all, so this is read off the wire — §9 flagged the cookie as shipped-with-zero-coverage.
|
|
1659
|
+
const handshake = await readUpgradeHandshake(`${control.url.replace(/^http/, 'ws')}/private?__volter_token=${encodeURIComponent(token)}`);
|
|
1660
|
+
const authorised = new WebSocket(`${control.url.replace(/^http/, 'ws')}/private?__volter_token=${encodeURIComponent(token)}`);
|
|
1661
|
+
const accepted = await new Promise<string>((resolve) => {
|
|
1662
|
+
const timer = setTimeout(() => resolve('still-open'), 3_000);
|
|
1663
|
+
authorised.addEventListener('open', () => { clearTimeout(timer); resolve('opened'); }, { once: true });
|
|
1664
|
+
authorised.addEventListener('close', () => { clearTimeout(timer); resolve('closed'); }, { once: true });
|
|
1665
|
+
authorised.addEventListener('error', () => { clearTimeout(timer); resolve('closed'); }, { once: true });
|
|
1666
|
+
});
|
|
1667
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
1668
|
+
const afterAuth = frames.filter((frame) => frame.type === 'ws-upgrade').length;
|
|
1669
|
+
authorised.close();
|
|
1670
|
+
return (
|
|
1671
|
+
refused === 'closed' &&
|
|
1672
|
+
afterRefusal === 0 &&
|
|
1673
|
+
accepted === 'opened' &&
|
|
1674
|
+
afterAuth === 2 &&
|
|
1675
|
+
handshake.status === 101 &&
|
|
1676
|
+
handshake.headers['set-cookie']?.startsWith('__volter_auth=') === true &&
|
|
1677
|
+
handshake.headers['set-cookie']?.includes('HttpOnly') === true
|
|
1678
|
+
);
|
|
1679
|
+
} finally { control.socket.close(); relay.stop(); }
|
|
1680
|
+
})),
|
|
1681
|
+
done('tunnel.volter.websocket.owner_scoping', 'volter_websocket', 'A control socket may only drive the bridges it owns; another tunnel\'s connId is refused', 'api', 'common', () => withRoot(async (root) => {
|
|
1682
|
+
const bridge = await openWsBridge(root);
|
|
1683
|
+
try {
|
|
1684
|
+
const { socket: visitor, upgrade } = await bridge.visitor('/scoped');
|
|
1685
|
+
// A SECOND registered client on the same relay, guessing the first tunnel's connId. If the
|
|
1686
|
+
// relay correlated on connId alone, this would drive somebody else's browser socket.
|
|
1687
|
+
const intruder = await openControl(bridge.relay.url, 'intruder', { authRequired: false });
|
|
1688
|
+
try {
|
|
1689
|
+
const refused = nextSocketMessage(intruder.socket, 'error');
|
|
1690
|
+
intruder.socket.send(JSON.stringify({ type: 'ws-message', connId: upgrade.connId, data: btoa('stolen'), binary: false }));
|
|
1691
|
+
const error = await refused;
|
|
1692
|
+
// …and the victim's socket saw nothing.
|
|
1693
|
+
// ONE wait, not a race whose second leg could never win (§9: the 250ms leg was dead code
|
|
1694
|
+
// behind a 200ms one). A leak that arrived later than this would still read 'quiet', which
|
|
1695
|
+
// is why the ERROR FRAME above — which is immediate and unconditional — is what actually
|
|
1696
|
+
// proves the refusal; this is the corroborating half.
|
|
1697
|
+
const quiet = await nextVisitorFrame(visitor, 300).then(() => 'leaked' as const).catch(() => 'quiet' as const);
|
|
1698
|
+
visitor.close();
|
|
1699
|
+
return String(error.message).includes('not owned by this control socket') && quiet === 'quiet';
|
|
1700
|
+
} finally { intruder.socket.close(); }
|
|
1701
|
+
} finally { bridge.stop(); }
|
|
1702
|
+
})),
|
|
1703
|
+
todo('tunnel.volter.protocol.quota', 'volter_control', 'Protocol frame: quota', 'api', 'common'),
|
|
1704
|
+
done('tunnel.volter.control.protocol_census', 'volter_control', 'Every authoritative owned relay message discriminator has exactly one explicit protocol capability row', 'api', 'core', async () => {
|
|
1705
|
+
const declared = TUNNEL_CAPABILITIES
|
|
1706
|
+
.filter((capability) => capability.id.startsWith('tunnel.volter.protocol.'))
|
|
1707
|
+
.map((capability) => capability.id.slice('tunnel.volter.protocol.'.length).replaceAll('_', '-'))
|
|
1708
|
+
.sort();
|
|
1709
|
+
return JSON.stringify(declared) === JSON.stringify([...TUNNEL_PROTOCOL_MESSAGE_TYPES].sort());
|
|
1710
|
+
}),
|
|
1711
|
+
|
|
1712
|
+
// Connector + safety.
|
|
1713
|
+
done('tunnel.connector.pull_status', 'connector', 'Pull the exact owned relay health shape through an injected client and fold it into state', 'connector', 'core', () => withRoot(async (root) => {
|
|
1714
|
+
const result = await syncTunnelFromReal(async () => ({ status: 200, body: { ok: true, relay: 'cloudflare-do' } }), { root, occurredAt: AT });
|
|
1715
|
+
const resource = projectResources('tunnel', root).find((x) => x.id === 'relay:default');
|
|
1716
|
+
return result.observed === 1 && result.deltasAppended === 1 && resource?.relay === 'cloudflare-do' && resource?.tunnels === undefined;
|
|
1717
|
+
})),
|
|
1718
|
+
done('tunnel.connector.pull_idempotent', 'connector', 'Identical status re-pull appends no delta', 'connector', 'common', () => withRoot(async (root) => {
|
|
1719
|
+
const client = async () => ({ status: 200, body: { ok: true, relay: 'cloudflare-do' } });
|
|
1720
|
+
await syncTunnelFromReal(client, { root, occurredAt: AT });
|
|
1721
|
+
return (await syncTunnelFromReal(client, { root, occurredAt: AT })).deltasAppended === 0;
|
|
1722
|
+
})),
|
|
1723
|
+
done('tunnel.connector.refusal_not_empty', 'connector', 'A refused status read throws instead of overwriting state with an empty relay', 'connector', 'core', () => withRoot(async (root) => {
|
|
1724
|
+
await syncTunnelFromReal(async () => ({ status: 200, body: { ok: true, relay: 'cloudflare-do' } }), { root, occurredAt: AT });
|
|
1725
|
+
const beforeResources = JSON.stringify(projectResources('tunnel', root));
|
|
1726
|
+
const beforeActions = listActions('tunnel', root).length;
|
|
1727
|
+
try {
|
|
1728
|
+
await syncTunnelFromReal(async () => ({ status: 503, body: { error: 'down' } }), { root, occurredAt: '2026-01-02T00:00:00.000Z' });
|
|
1729
|
+
return false;
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
return String(error).includes('refused')
|
|
1732
|
+
&& JSON.stringify(projectResources('tunnel', root)) === beforeResources
|
|
1733
|
+
&& listActions('tunnel', root).length === beforeActions;
|
|
1734
|
+
}
|
|
1735
|
+
})),
|
|
1736
|
+
done('tunnel.connector.mapper', 'connector', 'Status mapper preserves the owned health identity without inventing a tunnel count', 'connector', 'common', async () => {
|
|
1737
|
+
const x = mapTunnelRelay({ ok: true, relay: 'cloudflare-do' }); return x.fields.ok === true && x.fields.relay === 'cloudflare-do' && x.fields.tunnels === undefined;
|
|
1738
|
+
}),
|
|
1739
|
+
done('tunnel.connector.exact_status_shape', 'connector', 'Pull refuses extra status fields that are not present in the owned public contract', 'connector', 'core', async () => {
|
|
1740
|
+
try { await syncTunnelFromReal(async () => ({ status: 200, body: { ok: true, relay: 'cloudflare-do', tunnels: 7 } })); return false; }
|
|
1741
|
+
catch (error) { return String(error).includes('no healthy relay'); }
|
|
1742
|
+
}),
|
|
1743
|
+
done('tunnel.connector.rate_budget_fail_closed', 'connector', 'Saturated client budget refuses before fetch', 'connector', 'common', () => withRoot(async (root) => {
|
|
1744
|
+
let calls = 0; const now = () => Date.parse(AT); const path = join(root, 'budget.json');
|
|
1745
|
+
const fake = (async () => { calls++; return new Response('{"ok":true,"relay":"x"}'); }) as unknown as typeof fetch;
|
|
1746
|
+
const execute = liveTunnelExecute('token', 'https://relay.test', { fetchImpl: fake, budget: new TunnelBudget({ path, now }) });
|
|
1747
|
+
const allowed = Math.floor(TUNNEL_BUDGET_CEILING / TUNNEL_CALL_WEIGHTS.other);
|
|
1748
|
+
for (let i = 0; i < allowed; i++) await execute('GET', '/api/status');
|
|
1749
|
+
try { await execute('GET', '/api/status'); return false; } catch (e) { return e instanceof TunnelBudgetError && calls === allowed; }
|
|
1750
|
+
})),
|
|
1751
|
+
done('tunnel.connector.rate_budget_persists', 'connector', 'Budget persists across client instances sharing a ledger', 'connector', 'common', () => withRoot(async (root) => {
|
|
1752
|
+
let calls = 0; const now = () => Date.parse(AT); const path = join(root, 'budget.json');
|
|
1753
|
+
const fake = (async () => { calls++; return new Response('{"ok":true,"relay":"x"}'); }) as unknown as typeof fetch;
|
|
1754
|
+
const allowed = Math.floor(TUNNEL_BUDGET_CEILING / TUNNEL_CALL_WEIGHTS.other);
|
|
1755
|
+
const first = liveTunnelExecute('token', 'https://relay.test', { fetchImpl: fake, budget: new TunnelBudget({ path, now }) });
|
|
1756
|
+
for (let i = 0; i < allowed; i++) await first('GET', '/api/status');
|
|
1757
|
+
const second = liveTunnelExecute('token', 'https://relay.test', { fetchImpl: fake, budget: new TunnelBudget({ path, now }) });
|
|
1758
|
+
try { await second('GET', '/api/status'); return false; } catch (e) { return e instanceof TunnelBudgetError && calls === allowed; }
|
|
1759
|
+
})),
|
|
1760
|
+
todo('tunnel.connector.push_public', 'connector', 'Push a local registration to a real provider under explicit operator confirmation', 'connector', 'common'),
|
|
1761
|
+
todo('tunnel.connector.pull_accounts', 'connector', 'Pull owned relay accounts/reservations/usage through admin APIs', 'connector', 'niche'),
|
|
1762
|
+
];
|
|
1763
|
+
|
|
1764
|
+
export const TUNNEL_AREAS = [
|
|
1765
|
+
'cloudflare_quick', 'cloudflare_managed', 'volter_control', 'volter_http',
|
|
1766
|
+
'volter_websocket', 'volter_auth', 'volter_reservations', 'volter_accounts', 'volter_usage',
|
|
1767
|
+
'volter_signup', 'volter_inspector', 'volter_management', 'connector',
|
|
1768
|
+
] as const;
|
|
1769
|
+
|
|
1770
|
+
export function tunnelCapabilities(): Promise<CapabilityReport> { return checkCapabilities('tunnel', TUNNEL_CAPABILITIES); }
|