@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,175 @@
|
|
|
1
|
+
/** Message discriminators implemented or deliberately inventoried by the owned relay protocol.
|
|
2
|
+
* The capability manifest checks against this runtime-owned list; a test separately pins it to
|
|
3
|
+
* the installed first-party tunnel-core SDK without pulling that dev-only SDK into runtime code. */
|
|
4
|
+
export declare const TUNNEL_PROTOCOL_MESSAGE_TYPES: readonly ["register", "response", "response-start", "response-chunk", "response-end", "ws-ready", "ws-error", "ws-message", "ws-close", "registered", "request", "request-abort", "ws-upgrade", "quota", "error"];
|
|
5
|
+
export type TunnelRequest = {
|
|
6
|
+
method: string;
|
|
7
|
+
path: string;
|
|
8
|
+
body?: string;
|
|
9
|
+
headers?: Record<string, string | undefined>;
|
|
10
|
+
occurredAt?: string;
|
|
11
|
+
root?: string;
|
|
12
|
+
readOnly?: boolean;
|
|
13
|
+
liveTunnels?: number;
|
|
14
|
+
};
|
|
15
|
+
export type TunnelResponse = {
|
|
16
|
+
status: number;
|
|
17
|
+
body: unknown;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
};
|
|
20
|
+
/** Cloudflare's quick-tunnel allocation control API plus the owned relay health surface.
|
|
21
|
+
* The quick response follows cloudflared's first-party `QuickTunnelResponse` structure. */
|
|
22
|
+
export declare function handleTunnelTwinRequest(req: TunnelRequest): Promise<TunnelResponse>;
|
|
23
|
+
export type RegisterMessage = {
|
|
24
|
+
type: 'register';
|
|
25
|
+
tunnelId?: string;
|
|
26
|
+
secret?: string;
|
|
27
|
+
replace?: boolean;
|
|
28
|
+
authRequired?: boolean;
|
|
29
|
+
basicAuth?: {
|
|
30
|
+
user: string;
|
|
31
|
+
pass: string;
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
export type RelayControlMessage = RegisterMessage | {
|
|
35
|
+
type: 'response';
|
|
36
|
+
reqId: string | number;
|
|
37
|
+
status: number;
|
|
38
|
+
headers: Record<string, string | string[]>;
|
|
39
|
+
body: string;
|
|
40
|
+
} | {
|
|
41
|
+
type: 'response-start';
|
|
42
|
+
reqId: string | number;
|
|
43
|
+
status: number;
|
|
44
|
+
headers: Record<string, string | string[]>;
|
|
45
|
+
} | {
|
|
46
|
+
type: 'response-chunk';
|
|
47
|
+
reqId: string | number;
|
|
48
|
+
data: string;
|
|
49
|
+
} | {
|
|
50
|
+
type: 'response-end';
|
|
51
|
+
reqId: string | number;
|
|
52
|
+
} | {
|
|
53
|
+
type: 'ws-ready' | 'ws-error' | 'ws-message' | 'ws-close';
|
|
54
|
+
[key: string]: unknown;
|
|
55
|
+
};
|
|
56
|
+
export type RelaySession = {
|
|
57
|
+
tunnelId?: string;
|
|
58
|
+
authRequired?: boolean;
|
|
59
|
+
basicAuth?: {
|
|
60
|
+
user: string;
|
|
61
|
+
pass: string;
|
|
62
|
+
};
|
|
63
|
+
registered?: boolean;
|
|
64
|
+
superseded?: boolean;
|
|
65
|
+
};
|
|
66
|
+
export type RelayReduction = {
|
|
67
|
+
session: RelaySession;
|
|
68
|
+
outbound?: Record<string, unknown>;
|
|
69
|
+
registration?: {
|
|
70
|
+
tunnelId: string;
|
|
71
|
+
authRequired: boolean;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
export type PreparedRelayRegistration = RelayReduction & {
|
|
75
|
+
registration: {
|
|
76
|
+
tunnelId: string;
|
|
77
|
+
authRequired: boolean;
|
|
78
|
+
};
|
|
79
|
+
persistence: {
|
|
80
|
+
tunnelId: string;
|
|
81
|
+
fields: Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
};
|
|
84
|
+
/** Validate and normalize a registration without changing persisted or live ownership. The
|
|
85
|
+
* socket adapter uses this seam before it makes an existing owner inert, so a malformed takeover
|
|
86
|
+
* can never evict a healthy tunnel. */
|
|
87
|
+
export declare function prepareTunnelRelayRegistration(input: {
|
|
88
|
+
message: unknown;
|
|
89
|
+
session: RelaySession;
|
|
90
|
+
authoritativeId?: string;
|
|
91
|
+
publicBaseUrl: string;
|
|
92
|
+
readOnly?: boolean;
|
|
93
|
+
}): RelayReduction | PreparedRelayRegistration;
|
|
94
|
+
export declare function persistTunnelRelayRegistration(prepared: PreparedRelayRegistration, options?: {
|
|
95
|
+
root?: string;
|
|
96
|
+
occurredAt?: string;
|
|
97
|
+
registrationId?: string;
|
|
98
|
+
}): Promise<void>;
|
|
99
|
+
/** Undo exactly the registration action identified by the durable registration id. Persistence can
|
|
100
|
+
* append successfully and still reject afterward (for example while projecting the result). A
|
|
101
|
+
* kernel revert preserves that attempted write in the audit log while suppressing its projection,
|
|
102
|
+
* restoring either the prior live owner or the prior absence byte-for-byte. */
|
|
103
|
+
export declare function revertTunnelRelayRegistration(tunnelId: string, registrationId: string, options?: {
|
|
104
|
+
root?: string;
|
|
105
|
+
occurredAt?: string;
|
|
106
|
+
}): boolean;
|
|
107
|
+
/** Reducer behind the WebSocket adapter. It is deliberately in a separate module so the
|
|
108
|
+
* mutation gate can replace it while the real socket/data-path adapter remains intact. */
|
|
109
|
+
export declare function handleTunnelRelayMessage(input: {
|
|
110
|
+
message: unknown;
|
|
111
|
+
session: RelaySession;
|
|
112
|
+
authoritativeId?: string;
|
|
113
|
+
publicBaseUrl: string;
|
|
114
|
+
root?: string;
|
|
115
|
+
occurredAt?: string;
|
|
116
|
+
readOnly?: boolean;
|
|
117
|
+
}): Promise<RelayReduction>;
|
|
118
|
+
/**
|
|
119
|
+
* What the relay should DO with one connId-correlated WebSocket frame.
|
|
120
|
+
*
|
|
121
|
+
* `ws-ready` / `ws-message` / `ws-error` / `ws-close` are the four frames that address a bridged
|
|
122
|
+
* browser socket rather than an HTTP request, and the decision they lead to is PURE: it depends
|
|
123
|
+
* only on the frame and on who owns that connId. Keeping it here rather than inline in the socket
|
|
124
|
+
* adapter is the same split `handleTunnelRelayMessage` already uses, and for the same reason —
|
|
125
|
+
* the mutation gate can replace this seam while the real socket/data path stays intact, so the
|
|
126
|
+
* bridge's verifies are killed at the FRAME. §9 found they were only ever killed at REGISTRATION
|
|
127
|
+
* (every one of them traverses `prepareTunnelRelayRegistration` through `openControl`), which
|
|
128
|
+
* meant the bridge itself was reported as covered while nothing sabotaged it.
|
|
129
|
+
*/
|
|
130
|
+
export type TunnelWsOwnership = 'self' | 'other' | 'unknown';
|
|
131
|
+
export type TunnelWsDecision =
|
|
132
|
+
/** Not a connId frame, or one whose bridge is already gone. Do nothing. */
|
|
133
|
+
{
|
|
134
|
+
kind: 'ignore';
|
|
135
|
+
}
|
|
136
|
+
/** Answer the control socket with a protocol error. */
|
|
137
|
+
| {
|
|
138
|
+
kind: 'error';
|
|
139
|
+
message: string;
|
|
140
|
+
}
|
|
141
|
+
/** The bridge is up: flush anything the browser said while it was coming up. */
|
|
142
|
+
| {
|
|
143
|
+
kind: 'ready';
|
|
144
|
+
connId: string;
|
|
145
|
+
}
|
|
146
|
+
/** Send payload to the browser. */
|
|
147
|
+
| {
|
|
148
|
+
kind: 'deliver';
|
|
149
|
+
connId: string;
|
|
150
|
+
data: Uint8Array;
|
|
151
|
+
binary: boolean;
|
|
152
|
+
}
|
|
153
|
+
/** End the browser socket with this code and reason. */
|
|
154
|
+
| {
|
|
155
|
+
kind: 'close';
|
|
156
|
+
connId: string;
|
|
157
|
+
code: number;
|
|
158
|
+
reason: string;
|
|
159
|
+
};
|
|
160
|
+
/** Close codes 1005 ("no status") and 1006 ("abnormal") may never appear ON THE WIRE, and
|
|
161
|
+
* anything outside 1000-4999 is not a close code at all — the vendor's client clamps the
|
|
162
|
+
* identical way on the frames it receives. */
|
|
163
|
+
export declare function clampTunnelCloseCode(raw: unknown): number;
|
|
164
|
+
/** A close `reason` may carry at most 123 bytes. */
|
|
165
|
+
export declare function clampTunnelCloseReason(raw: unknown): string;
|
|
166
|
+
export declare function reduceTunnelWsFrame(input: {
|
|
167
|
+
message: unknown;
|
|
168
|
+
ownership: TunnelWsOwnership;
|
|
169
|
+
}): TunnelWsDecision;
|
|
170
|
+
/** Persist a disconnect only for the registration generation that actually lost its socket.
|
|
171
|
+
* Replacement appends its new generation before transferring the live controls map, so a close
|
|
172
|
+
* from the prior socket can arrive in that interval. The server serializes this conditional write
|
|
173
|
+
* behind the registration turn; the identity check then prevents the stale close from overwriting
|
|
174
|
+
* the successor's durable connected state. */
|
|
175
|
+
export declare function markTunnelDisconnected(tunnelId: string, root?: string, occurredAt?: string, expectedRegistrationId?: string): Promise<boolean>;
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { appendAction, applyTwinWrite, listActions, projectResources, TwinActionPreconditionError } from '@volter/world-core';
|
|
3
|
+
const SERVICE = 'tunnel';
|
|
4
|
+
/** Message discriminators implemented or deliberately inventoried by the owned relay protocol.
|
|
5
|
+
* The capability manifest checks against this runtime-owned list; a test separately pins it to
|
|
6
|
+
* the installed first-party tunnel-core SDK without pulling that dev-only SDK into runtime code. */
|
|
7
|
+
export const TUNNEL_PROTOCOL_MESSAGE_TYPES = [
|
|
8
|
+
'register', 'response', 'response-start', 'response-chunk', 'response-end',
|
|
9
|
+
'ws-ready', 'ws-error', 'ws-message', 'ws-close', 'registered', 'request',
|
|
10
|
+
'request-abort', 'ws-upgrade', 'quota', 'error',
|
|
11
|
+
];
|
|
12
|
+
function nextRev(id, root) {
|
|
13
|
+
let max = 0;
|
|
14
|
+
for (const action of listActions(SERVICE, root)) {
|
|
15
|
+
if (action.subject?.id !== id)
|
|
16
|
+
continue;
|
|
17
|
+
const rev = action.fields?.rev;
|
|
18
|
+
if (typeof rev === 'number')
|
|
19
|
+
max = Math.max(max, rev);
|
|
20
|
+
}
|
|
21
|
+
return max + 1;
|
|
22
|
+
}
|
|
23
|
+
async function writeTunnel(type, id, fields, operation, req, preconditions) {
|
|
24
|
+
const occurredAt = req.occurredAt ?? new Date().toISOString();
|
|
25
|
+
await applyTwinWrite(SERVICE, {
|
|
26
|
+
operation,
|
|
27
|
+
subjectType: type,
|
|
28
|
+
subjectId: id,
|
|
29
|
+
fields: { ...fields, rev: nextRev(id, req.root), writtenAt: occurredAt },
|
|
30
|
+
occurredAt,
|
|
31
|
+
actor: { kind: 'agent', id: 'tunnel-twin' },
|
|
32
|
+
preconditions,
|
|
33
|
+
}, req.root);
|
|
34
|
+
}
|
|
35
|
+
/** Cloudflare's quick-tunnel allocation control API plus the owned relay health surface.
|
|
36
|
+
* The quick response follows cloudflared's first-party `QuickTunnelResponse` structure. */
|
|
37
|
+
export async function handleTunnelTwinRequest(req) {
|
|
38
|
+
const url = new URL(req.path, 'http://twin.local');
|
|
39
|
+
if (req.method === 'POST' && url.pathname === '/tunnel') {
|
|
40
|
+
if (req.readOnly)
|
|
41
|
+
return {
|
|
42
|
+
status: 405,
|
|
43
|
+
body: { success: false, errors: [{ message: 'tunnel twin is read-only' }] },
|
|
44
|
+
headers: { 'content-type': 'application/json' },
|
|
45
|
+
};
|
|
46
|
+
// The allocation's identity is state (R9): id, name, hostname and account tag derive from
|
|
47
|
+
// the world instant and the quick tunnels already allocated, in cloudflared's shapes. The
|
|
48
|
+
// tunnel secret is the credential the connector authenticates with, so it stays random.
|
|
49
|
+
const held = projectResources(SERVICE, req.root).filter((r) => r.type === 'quick_tunnel').length;
|
|
50
|
+
const derive = (part, n) => createHash('sha256').update(`quick_tunnel:${part}:${req.occurredAt ?? ''}:${held}`).digest('hex').slice(0, n);
|
|
51
|
+
const uuidOf = (hex) => `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-${((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
52
|
+
const id = uuidOf(derive('id', 32));
|
|
53
|
+
const name = derive('name', 16);
|
|
54
|
+
const hostname = `${derive('hostname', 16)}.trycloudflare.com`;
|
|
55
|
+
const accountTag = uuidOf(derive('account_tag', 32));
|
|
56
|
+
const secret = randomBytes(32).toString('base64');
|
|
57
|
+
await writeTunnel('quick_tunnel', `quick:${id}`, {
|
|
58
|
+
vendor: 'cloudflare', id, name, hostname, account_tag: accountTag, secret,
|
|
59
|
+
ephemeral: true, connected: false,
|
|
60
|
+
}, 'quick_tunnel.create', req);
|
|
61
|
+
return {
|
|
62
|
+
status: 200,
|
|
63
|
+
body: {
|
|
64
|
+
success: true,
|
|
65
|
+
result: { id, name, hostname, account_tag: accountTag, secret },
|
|
66
|
+
errors: [],
|
|
67
|
+
},
|
|
68
|
+
headers: { 'content-type': 'application/json' },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (req.method === 'GET' && url.pathname === '/api/status') {
|
|
72
|
+
// Connection count is a live-process fact. The server adapter supplies its authoritative
|
|
73
|
+
// socket-map size; the pure reducer has no live sockets and therefore reports zero instead of
|
|
74
|
+
// treating an append-only historical registration as currently connected.
|
|
75
|
+
const tunnels = req.liveTunnels ?? 0;
|
|
76
|
+
return { status: 200, body: { ok: true, relay: 'twin-local', tunnels }, headers: { 'content-type': 'application/json' } };
|
|
77
|
+
}
|
|
78
|
+
return { status: 404, body: { error: 'not found' }, headers: { 'content-type': 'application/json' } };
|
|
79
|
+
}
|
|
80
|
+
/** Validate and normalize a registration without changing persisted or live ownership. The
|
|
81
|
+
* socket adapter uses this seam before it makes an existing owner inert, so a malformed takeover
|
|
82
|
+
* can never evict a healthy tunnel. */
|
|
83
|
+
export function prepareTunnelRelayRegistration(input) {
|
|
84
|
+
const message = input.message;
|
|
85
|
+
if (message.type !== 'register')
|
|
86
|
+
return { session: input.session };
|
|
87
|
+
if (input.readOnly)
|
|
88
|
+
return { session: input.session, outbound: { type: 'error', fatal: true, message: 'tunnel twin is read-only' } };
|
|
89
|
+
const requested = input.authoritativeId || (typeof message.tunnelId === 'string' ? message.tunnelId : '') || randomUUID().slice(0, 8);
|
|
90
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(requested)) {
|
|
91
|
+
return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Invalid tunnel ID' } };
|
|
92
|
+
}
|
|
93
|
+
if (typeof message.secret !== 'string' || message.secret.length === 0) {
|
|
94
|
+
return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Tunnel rejected: credential is invalid or revoked.' } };
|
|
95
|
+
}
|
|
96
|
+
if (message.basicAuth !== undefined && (!message.basicAuth || typeof message.basicAuth !== 'object'
|
|
97
|
+
|| typeof message.basicAuth.user !== 'string'
|
|
98
|
+
|| typeof message.basicAuth.pass !== 'string')) {
|
|
99
|
+
return { session: input.session, outbound: { type: 'error', fatal: true, message: 'Invalid basic-auth configuration' } };
|
|
100
|
+
}
|
|
101
|
+
const basicAuthInput = message.basicAuth;
|
|
102
|
+
// The owned relay configures its Basic gate only when BOTH values are non-empty. The SDK/CLI can
|
|
103
|
+
// emit `{ user: 'name', pass: '' }`; upstream accepts that registration but leaves Basic auth
|
|
104
|
+
// unconfigured, so the twin must not invent a gate for the empty-value edge.
|
|
105
|
+
const basicAuth = basicAuthInput?.user && basicAuthInput.pass ? basicAuthInput : undefined;
|
|
106
|
+
const authRequired = message.authRequired !== false;
|
|
107
|
+
const basicAuthHash = basicAuth
|
|
108
|
+
? createHash('sha256').update(`${basicAuth.user}:${basicAuth.pass}`).digest('hex')
|
|
109
|
+
: null;
|
|
110
|
+
const session = { tunnelId: requested, authRequired, registered: true, ...(basicAuth ? { basicAuth } : {}) };
|
|
111
|
+
return {
|
|
112
|
+
session,
|
|
113
|
+
registration: { tunnelId: requested, authRequired },
|
|
114
|
+
persistence: {
|
|
115
|
+
tunnelId: requested,
|
|
116
|
+
fields: {
|
|
117
|
+
vendor: 'volter-tunnel', tunnel_id: requested, connected: true, auth_required: authRequired,
|
|
118
|
+
basic_auth_configured: basicAuth !== undefined,
|
|
119
|
+
basic_auth_sha256: basicAuthHash,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
outbound: { type: 'registered', tunnelId: requested, url: `${input.publicBaseUrl}/__twin/tunnels/${encodeURIComponent(requested)}` },
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
export async function persistTunnelRelayRegistration(prepared, options = {}) {
|
|
126
|
+
await writeTunnel('relay_tunnel', `relay:${prepared.persistence.tunnelId}`, {
|
|
127
|
+
...prepared.persistence.fields,
|
|
128
|
+
...(options.registrationId ? { registration_id: options.registrationId } : {}),
|
|
129
|
+
}, 'relay.register', { method: 'WS', path: '/ws', root: options.root, occurredAt: options.occurredAt });
|
|
130
|
+
}
|
|
131
|
+
/** Undo exactly the registration action identified by the durable registration id. Persistence can
|
|
132
|
+
* append successfully and still reject afterward (for example while projecting the result). A
|
|
133
|
+
* kernel revert preserves that attempted write in the audit log while suppressing its projection,
|
|
134
|
+
* restoring either the prior live owner or the prior absence byte-for-byte. */
|
|
135
|
+
export function revertTunnelRelayRegistration(tunnelId, registrationId, options = {}) {
|
|
136
|
+
const actions = listActions(SERVICE, options.root);
|
|
137
|
+
const registration = [...actions].reverse().find((action) => action.operation === 'relay.register'
|
|
138
|
+
&& action.subject.type === 'relay_tunnel' && action.subject.id === `relay:${tunnelId}`
|
|
139
|
+
&& action.fields?.registration_id === registrationId);
|
|
140
|
+
if (!registration)
|
|
141
|
+
return false;
|
|
142
|
+
if (actions.some((action) => action.op === 'revert' && action.revertsActionId === registration.id))
|
|
143
|
+
return true;
|
|
144
|
+
appendAction({
|
|
145
|
+
id: `twin:tunnel:relay.register.rollback:${registrationId}`,
|
|
146
|
+
service: SERVICE,
|
|
147
|
+
op: 'revert',
|
|
148
|
+
operation: 'relay.register.rollback',
|
|
149
|
+
subject: registration.subject,
|
|
150
|
+
occurredAt: options.occurredAt ?? new Date().toISOString(),
|
|
151
|
+
actor: { kind: 'system', id: 'tunnel-twin' },
|
|
152
|
+
revertsActionId: registration.id,
|
|
153
|
+
}, options.root);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
/** Reducer behind the WebSocket adapter. It is deliberately in a separate module so the
|
|
157
|
+
* mutation gate can replace it while the real socket/data-path adapter remains intact. */
|
|
158
|
+
export async function handleTunnelRelayMessage(input) {
|
|
159
|
+
const prepared = prepareTunnelRelayRegistration(input);
|
|
160
|
+
if (!('persistence' in prepared))
|
|
161
|
+
return prepared;
|
|
162
|
+
await persistTunnelRelayRegistration(prepared, { root: input.root, occurredAt: input.occurredAt });
|
|
163
|
+
const { persistence: _persistence, ...reduction } = prepared;
|
|
164
|
+
return reduction;
|
|
165
|
+
}
|
|
166
|
+
/** Close codes 1005 ("no status") and 1006 ("abnormal") may never appear ON THE WIRE, and
|
|
167
|
+
* anything outside 1000-4999 is not a close code at all — the vendor's client clamps the
|
|
168
|
+
* identical way on the frames it receives. */
|
|
169
|
+
export function clampTunnelCloseCode(raw) {
|
|
170
|
+
const code = typeof raw === 'number' ? raw : 1000;
|
|
171
|
+
return code >= 1000 && code <= 4999 && code !== 1005 && code !== 1006 ? code : 1000;
|
|
172
|
+
}
|
|
173
|
+
/** A close `reason` may carry at most 123 bytes. */
|
|
174
|
+
export function clampTunnelCloseReason(raw) {
|
|
175
|
+
return typeof raw === 'string' ? raw.slice(0, 123) : '';
|
|
176
|
+
}
|
|
177
|
+
export function reduceTunnelWsFrame(input) {
|
|
178
|
+
const message = (input.message ?? {});
|
|
179
|
+
const type = message.type;
|
|
180
|
+
if (type !== 'ws-ready' && type !== 'ws-error' && type !== 'ws-message' && type !== 'ws-close')
|
|
181
|
+
return { kind: 'ignore' };
|
|
182
|
+
const connId = typeof message.connId === 'string' ? message.connId : '';
|
|
183
|
+
// AN UNKNOWN connId IS NOT A VIOLATION — it is the normal tail of every bridged close, in both
|
|
184
|
+
// directions: the vendor's client closes its local socket on a relayed `ws-close`, and that
|
|
185
|
+
// socket's own close listener echoes a `ws-close` back after the relay has already torn the
|
|
186
|
+
// bridge down. Answering "not owned by this control socket" there accuses a CORRECT client of
|
|
187
|
+
// a protocol violation on the happy path (§9).
|
|
188
|
+
if (!connId || input.ownership === 'unknown')
|
|
189
|
+
return { kind: 'ignore' };
|
|
190
|
+
// A control socket may only speak for the bridges IT was handed. Without this, any registered
|
|
191
|
+
// client could drive another tunnel's LIVE browser sockets by guessing a connId.
|
|
192
|
+
if (input.ownership === 'other')
|
|
193
|
+
return { kind: 'error', message: 'websocket correlation is not owned by this control socket' };
|
|
194
|
+
if (type === 'ws-ready')
|
|
195
|
+
return { kind: 'ready', connId };
|
|
196
|
+
if (type === 'ws-message') {
|
|
197
|
+
const raw = typeof message.data === 'string' ? message.data : '';
|
|
198
|
+
const data = Buffer.from(raw, 'base64');
|
|
199
|
+
// `Buffer.from(x, 'base64')` is LENIENT — it drops what it cannot parse — so the round trip
|
|
200
|
+
// is what rejects a payload that is not really base64, the same strictness the response
|
|
201
|
+
// frames' own decoder applies.
|
|
202
|
+
if (data.toString('base64').replace(/=+$/, '') !== raw.replace(/=+$/, ''))
|
|
203
|
+
return { kind: 'error', message: 'malformed ws-message frame' };
|
|
204
|
+
return { kind: 'deliver', connId, data: new Uint8Array(data), binary: message.binary === true };
|
|
205
|
+
}
|
|
206
|
+
if (type === 'ws-error') {
|
|
207
|
+
// 1011 "internal error" with the CLIENT's own reason — the browser learns why, and the relay
|
|
208
|
+
// does not invent one of its own.
|
|
209
|
+
const reason = typeof message.error === 'string' ? message.error : 'tunnel websocket error';
|
|
210
|
+
return { kind: 'close', connId, code: 1011, reason: clampTunnelCloseReason(reason) };
|
|
211
|
+
}
|
|
212
|
+
return { kind: 'close', connId, code: clampTunnelCloseCode(message.code), reason: clampTunnelCloseReason(message.reason) };
|
|
213
|
+
}
|
|
214
|
+
/** Persist a disconnect only for the registration generation that actually lost its socket.
|
|
215
|
+
* Replacement appends its new generation before transferring the live controls map, so a close
|
|
216
|
+
* from the prior socket can arrive in that interval. The server serializes this conditional write
|
|
217
|
+
* behind the registration turn; the identity check then prevents the stale close from overwriting
|
|
218
|
+
* the successor's durable connected state. */
|
|
219
|
+
export async function markTunnelDisconnected(tunnelId, root, occurredAt, expectedRegistrationId) {
|
|
220
|
+
try {
|
|
221
|
+
await writeTunnel('relay_tunnel', `relay:${tunnelId}`, {
|
|
222
|
+
vendor: 'volter-tunnel', tunnel_id: tunnelId, connected: false,
|
|
223
|
+
}, 'relay.disconnect', { method: 'WS', path: '/ws', root, occurredAt }, expectedRegistrationId ? [{
|
|
224
|
+
subject: { type: 'relay_tunnel', id: `relay:${tunnelId}` },
|
|
225
|
+
field: 'registration_id',
|
|
226
|
+
op: 'eq',
|
|
227
|
+
value: expectedRegistrationId,
|
|
228
|
+
}] : undefined);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
if (expectedRegistrationId && error instanceof TwinActionPreconditionError)
|
|
232
|
+
return false;
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
return true;
|
|
236
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-tunnel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Cloudflare Quick Tunnel control-plane and Volter relay twin with a genuine loopback HTTP data path.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"!**/*.test.ts",
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
16
|
+
"directory": "packages/twin/tunnel"
|
|
17
|
+
},
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./dist/src/index.d.ts",
|
|
22
|
+
"default": "./dist/src/index.js"
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"world-tunnel": "dist/src/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test src/*.test.ts",
|
|
30
|
+
"typecheck": "tsc --noEmit",
|
|
31
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
32
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
33
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@volter/world-core": "2.0.0"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/bun": "^1.2.20",
|
|
40
|
+
"@types/node": "^24.0.0",
|
|
41
|
+
"@types/ws": "^8.18.1",
|
|
42
|
+
"@volter/tunnel": "^2.0.5",
|
|
43
|
+
"@volter/tunnel-core": "0.1.3",
|
|
44
|
+
"@volter/world-core": "2.0.0",
|
|
45
|
+
"@volter/world-tooling": "0.1.0",
|
|
46
|
+
"typescript": "^5.9.0"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=22.3"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { keepProcessAlive } from '@volter/world-core/lifecycle';
|
|
3
|
+
import { hasFlag, optionValue } from '@volter/world-core/args';
|
|
4
|
+
import { createTunnelTwinServer, parseTunnelLoopbackOrigin } from './tunnel-server.ts';
|
|
5
|
+
|
|
6
|
+
const [command, ...args] = process.argv.slice(2);
|
|
7
|
+
const rawPort = optionValue(args, '--port');
|
|
8
|
+
const port = rawPort === undefined ? undefined : Number(rawPort);
|
|
9
|
+
if (port !== undefined && (!Number.isInteger(port) || port < 0 || port > 65535)) {
|
|
10
|
+
process.stderr.write(`world-tunnel: --port must be an integer 0-65535 (got ${JSON.stringify(rawPort)})\n`);
|
|
11
|
+
process.exit(2);
|
|
12
|
+
}
|
|
13
|
+
const root = optionValue(args, '--root') || undefined;
|
|
14
|
+
|
|
15
|
+
if (command === 'serve') {
|
|
16
|
+
const server = createTunnelTwinServer({
|
|
17
|
+
...(port ? { port } : {}),
|
|
18
|
+
...(root ? { root } : {}),
|
|
19
|
+
readOnly: hasFlag(args, '--read-only'),
|
|
20
|
+
requireTid: hasFlag(args, '--require-tid'),
|
|
21
|
+
});
|
|
22
|
+
process.stdout.write(`tunnel twin listening on ${server.url}\n`);
|
|
23
|
+
process.stdout.write(`Volter SDK host=${server.url}; Cloudflare quick service=${server.url}\n`);
|
|
24
|
+
await keepProcessAlive();
|
|
25
|
+
} else if (command === 'quick' || command === 'tunnel') {
|
|
26
|
+
const origin = optionValue(args, '--url');
|
|
27
|
+
if (!origin) { process.stderr.write('world-tunnel quick requires --url http://127.0.0.1:<port>\n'); process.exit(2); }
|
|
28
|
+
try { parseTunnelLoopbackOrigin(origin); }
|
|
29
|
+
catch (error) { process.stderr.write(`world-tunnel quick: ${error instanceof Error ? error.message : String(error)}\n`); process.exit(2); }
|
|
30
|
+
const server = createTunnelTwinServer({ ...(port ? { port } : {}), ...(root ? { root } : {}) });
|
|
31
|
+
const allocation = await fetch(`${server.url}/tunnel`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
|
|
32
|
+
const body = await allocation.json() as { success: boolean; result?: { id: string } };
|
|
33
|
+
if (!allocation.ok || !body.success || !body.result) throw new Error('quick-tunnel allocation failed');
|
|
34
|
+
server.registerQuickOrigin(body.result.id, origin);
|
|
35
|
+
// The world runtime discovers the first URL in stdout. This is deliberately a loopback URL:
|
|
36
|
+
// the twin proves the control plane and genuine HTTP forwarding, not Cloudflare edge/DNS/TLS.
|
|
37
|
+
process.stdout.write(`VOLTER_SHARE_URL=${server.url}\n`);
|
|
38
|
+
await keepProcessAlive();
|
|
39
|
+
} else if (command === 'conformance') {
|
|
40
|
+
// Dev-only and lazy: the runtime entrypoint does not pull conformance into normal serve/share.
|
|
41
|
+
const { checkTunnelConformance } = await import('./tunnel-conformance.ts');
|
|
42
|
+
const report = await checkTunnelConformance({ ...(root ? { root } : {}) });
|
|
43
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
44
|
+
if (!report.ok) process.exitCode = 1;
|
|
45
|
+
} else {
|
|
46
|
+
process.stdout.write('Usage: world-tunnel serve|conformance [--port N] [--root DIR] [--read-only] [--require-tid]\n');
|
|
47
|
+
process.stdout.write(' world-tunnel quick --url http://127.0.0.1:<port> [--port N] [--root DIR]\n');
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export { createTunnelTwinFetch, createTunnelTwinHandler, createTunnelTwinServer } from './tunnel-server.ts';
|
|
2
|
+
export { handleTunnelRelayMessage, handleTunnelTwinRequest } from './tunnel-twin.ts';
|
|
3
|
+
export { liveTunnelExecute, mapTunnelRelay, pullTunnelRelay, syncTunnelFromReal } from './tunnel-connector.ts';
|
|
4
|
+
export {
|
|
5
|
+
TunnelBudget, TunnelBudgetError, TUNNEL_RATE_BUDGET, TUNNEL_BUDGET_CEILING,
|
|
6
|
+
TUNNEL_BUDGET_WINDOW_MS, TUNNEL_CALL_WEIGHTS,
|
|
7
|
+
} from './tunnel-budget.ts';
|
|
8
|
+
export type { RelayControlMessage, RelaySession, TunnelRequest, TunnelResponse } from './tunnel-twin.ts';
|
|
9
|
+
|
|
10
|
+
// Registry descriptor (#1): the pack self-describes so tooling can discover it.
|
|
11
|
+
// A consumer does `registerPack(pack)` after importing this package.
|
|
12
|
+
import type { TwinPack } from '@volter/world-core';
|
|
13
|
+
import { TUNNEL_RATE_BUDGET as RATE_BUDGET } from './tunnel-budget.ts';
|
|
14
|
+
export const pack: TwinPack = {
|
|
15
|
+
vendor: 'tunnel',
|
|
16
|
+
// The SAME object tunnel-budget.ts declares at module load — one source of truth, so
|
|
17
|
+
// registering the pack and importing the connector can never arm two different ceilings.
|
|
18
|
+
rateBudget: RATE_BUDGET,
|
|
19
|
+
// The CONTROL plane is HTTP/REST (POST /tunnel, GET /api/status). The relay's control link is a
|
|
20
|
+
// WebSocket and its data path is real loopback HTTP — neither is a raw line protocol, so this is
|
|
21
|
+
// not the `raw-tcp` class; the split is described in the README and spec-sources.json.
|
|
22
|
+
transport: 'rest',
|
|
23
|
+
archetype: 'proxy',
|
|
24
|
+
bin: 'world-tunnel',
|
|
25
|
+
// The subject types the twin stores: `quick_tunnel`/`relay_tunnel` written by tunnel-twin.ts,
|
|
26
|
+
// and `relay` folded by the connector's status pull (mapTunnelRelay).
|
|
27
|
+
resources: ['quick_tunnel', 'relay_tunnel', 'relay'],
|
|
28
|
+
specSource:
|
|
29
|
+
'No single first-party machine-readable spec spans this combined pack. The Cloudflare Quick Tunnel ' +
|
|
30
|
+
'half is pinned to first-party cloudflared commit 733bfb939963e150dcf5c4faddb1603f744fbc98 (POST ' +
|
|
31
|
+
'/tunnel and its {success,result,errors} envelope) plus Cloudflare\'s own Quick Tunnels docs; the ' +
|
|
32
|
+
'owned Volter relay half is pinned to volter-ai/volter-tunnel commit ' +
|
|
33
|
+
'a5abc6287384429d32cde200d8ecd042814e6619 (@volter/tunnel 2.0.5, @volter/tunnel-core\'s 15 control ' +
|
|
34
|
+
'frames). See spec-sources.json.',
|
|
35
|
+
description:
|
|
36
|
+
'Cloudflare Quick Tunnel control-plane and owned Volter relay twin — POST /tunnel allocation and the ' +
|
|
37
|
+
'@volter/tunnel WebSocket control protocol, with a genuine loopback HTTP data path (no Cloudflare ' +
|
|
38
|
+
'edge, DNS, TLS or SLA claimed).',
|
|
39
|
+
// INTERCEPTION RULING — hostsNone, the pack's own home for it: no injector entry by design:
|
|
40
|
+
// native cloudflared is an external binary, while @volter/tunnel opens a configurable
|
|
41
|
+
// WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read
|
|
42
|
+
// endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect
|
|
43
|
+
// cloudflared and would falsely imply the Cloudflare edge data plane was twinned.
|
|
44
|
+
hostsNone:
|
|
45
|
+
"no injector entry by design: native cloudflared is an external binary, while @volter/tunnel opens a configurable WebSocket control channel and the CLI reads TUNNEL_SERVER_URL. Worlds wire that app-read endpoint directly; claiming api.trycloudflare.com in the Node injector would not redirect cloudflared and would falsely imply the Cloudflare edge data plane was twinned",
|
|
46
|
+
// Adoption, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
47
|
+
// 2026-08-31).
|
|
48
|
+
adoption: {
|
|
49
|
+
// Internal Volter tunnel; the client is `@volter/tunnel` and there is no Python distribution.
|
|
50
|
+
pypi: [],
|
|
51
|
+
sdks: ['@volter/tunnel'], envStems: ['TUNNEL', 'VOLTERTUNNEL'],
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {
|
|
2
|
+
declareRateBudget, rateBudgetPath, rateBudgetWeight, RateBudget,
|
|
3
|
+
type RateBudgetDeclaration, type RateBudgetOptions,
|
|
4
|
+
} from '@volter/world-core';
|
|
5
|
+
|
|
6
|
+
const VENDOR = 'tunnel';
|
|
7
|
+
export const TUNNEL_BUDGET_WINDOW_MS = 60_000;
|
|
8
|
+
export const TUNNEL_BUDGET_CEILING = 60;
|
|
9
|
+
export const TUNNEL_CALL_WEIGHTS = { create: 3, other: 2 } as const;
|
|
10
|
+
|
|
11
|
+
/** Cloudflare documents a 200 concurrent in-flight request cap for Quick Tunnels, but no scalar
|
|
12
|
+
* create-control-API rate. Volter's relay publishes data-plane and signup rates, not a status-read
|
|
13
|
+
* rate. The connector ledger is account-wide, so it uses the kernel's conservative fallback. */
|
|
14
|
+
export const TUNNEL_RATE_BUDGET: RateBudgetDeclaration = {
|
|
15
|
+
windowMs: TUNNEL_BUDGET_WINDOW_MS,
|
|
16
|
+
ceiling: TUNNEL_BUDGET_CEILING,
|
|
17
|
+
defaultWeight: TUNNEL_CALL_WEIGHTS.other,
|
|
18
|
+
maxRetryAfterSeconds: 300,
|
|
19
|
+
rules: [{ match: '^POST /tunnel$', weight: TUNNEL_CALL_WEIGHTS.create }],
|
|
20
|
+
reason:
|
|
21
|
+
'Cloudflare Quick Tunnels documentation (developers.cloudflare.com, fetched 2026-08-20) publishes a 200 concurrent in-flight data-plane cap but no scalar quick-control creation rate; the first-party cloudflared source POSTs /tunnel once per quick session. The owned volter-tunnel relay (a5abc628, 2026-08-20) publishes data-plane and signup limits, not a status-read rate. This account-wide connector therefore uses the kernel fallback: 60 weighted units/60s, reads weight 2 (30/min) and tunnel allocation weight 3 (20/min), never more permissive than an undeclared vendor.',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
declareRateBudget(VENDOR, TUNNEL_RATE_BUDGET);
|
|
25
|
+
export const tunnelCallWeight = (method: string, path: string) => rateBudgetWeight(VENDOR, `${method.toUpperCase()} ${path}`);
|
|
26
|
+
export const tunnelBudgetPath = (opts: { root?: string; token?: string } | string = {}) => {
|
|
27
|
+
const value = typeof opts === 'string' ? { root: opts } : opts;
|
|
28
|
+
return rateBudgetPath({ ...value, vendor: VENDOR });
|
|
29
|
+
};
|
|
30
|
+
export type TunnelBudgetOptions = Omit<RateBudgetOptions, 'vendor'>;
|
|
31
|
+
export class TunnelBudget extends RateBudget {
|
|
32
|
+
constructor(opts: TunnelBudgetOptions = {}) { super({ ...opts, vendor: VENDOR }); }
|
|
33
|
+
}
|
|
34
|
+
export { RateBudgetError as TunnelBudgetError } from '@volter/world-core';
|