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