@opentunnel/client 0.0.0 → 0.1.1
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 +100 -0
- package/package.json +42 -1
- package/src/effect/api.ts +39 -0
- package/src/effect/client.ts +300 -0
- package/src/effect/csr.ts +85 -0
- package/src/effect/errors.ts +13 -0
- package/src/effect/index.ts +6 -0
- package/src/effect/storage.ts +222 -0
- package/src/effect/tunnel.ts +533 -0
- package/src/effect/types.ts +116 -0
- package/src/promise/client.ts +120 -0
- package/src/promise/index.ts +14 -0
- package/src/promise/storage.ts +54 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { Option } from "effect";
|
|
2
|
+
import * as Net from "node:net";
|
|
3
|
+
import { Duplex } from "node:stream";
|
|
4
|
+
import * as Tls from "node:tls";
|
|
5
|
+
import { BridgeProtocol } from "@opentunnel/protocol/bridge-protocol";
|
|
6
|
+
import { Names } from "@opentunnel/protocol/names";
|
|
7
|
+
import type {
|
|
8
|
+
OpenTunnelClientEvent,
|
|
9
|
+
OpenTunnelIdentity,
|
|
10
|
+
OpenTunnelRoutes,
|
|
11
|
+
OpenTunnelStatus,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
|
|
14
|
+
const VERSION = "0.1.0";
|
|
15
|
+
const MAX_CONNS = 256;
|
|
16
|
+
/** Bytes queued on the WebSocket before local sockets stop being read. */
|
|
17
|
+
const HIGH_WATER_MARK = 1024 * 1024;
|
|
18
|
+
const LOW_WATER_MARK = 256 * 1024;
|
|
19
|
+
/** Bytes buffered for one public connection before it is treated as stalled. */
|
|
20
|
+
const STALLED_CHANNEL_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
/**
|
|
23
|
+
* How often to check for a renewed certificate: rarely while the current one
|
|
24
|
+
* is fresh, every minute once renewal is due (it completes within minutes).
|
|
25
|
+
*/
|
|
26
|
+
const CERTIFICATE_CHECK_FRESH_MS = 12 * 60 * 60 * 1000;
|
|
27
|
+
const CERTIFICATE_CHECK_DUE_MS = 60 * 1000;
|
|
28
|
+
const RENEWAL_WINDOW_MS = 30 * DAY_MS;
|
|
29
|
+
|
|
30
|
+
type SessionEnd =
|
|
31
|
+
| { readonly type: "shutdown" }
|
|
32
|
+
| { readonly type: "routes-changed" }
|
|
33
|
+
| { readonly type: "fatal"; readonly error: Error }
|
|
34
|
+
| { readonly type: "retry"; readonly error: string; readonly attached: boolean };
|
|
35
|
+
|
|
36
|
+
export class OpenTunnelAttachError extends Error {
|
|
37
|
+
constructor(readonly code: string) {
|
|
38
|
+
super(`Bridge attach failed: ${code}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fatalAttachCodes = new Set<string>([
|
|
43
|
+
BridgeProtocol.BridgeErrorCode.BAD_TOKEN,
|
|
44
|
+
BridgeProtocol.BridgeErrorCode.CERT_NOT_READY,
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
export const validateRoutes = (routes: OpenTunnelRoutes): void => {
|
|
48
|
+
for (const [name, target] of Object.entries(routes)) {
|
|
49
|
+
if (!Names.isValidRoute(name)) throw new Error(`Invalid route name '${name}'`);
|
|
50
|
+
if (!Names.parseTarget(target)) {
|
|
51
|
+
throw new Error(`Invalid target '${target}' for route '${name}': use host:port`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const secureContextFor = (identity: OpenTunnelIdentity) =>
|
|
57
|
+
Tls.createSecureContext({
|
|
58
|
+
key: identity.privateKey,
|
|
59
|
+
cert: `${identity.certificate}\n${identity.chain}`,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const sameNames = (left: OpenTunnelRoutes, right: OpenTunnelRoutes) => {
|
|
63
|
+
const a = Object.keys(left).sort();
|
|
64
|
+
const b = Object.keys(right).sort();
|
|
65
|
+
return a.length === b.length && a.every((name, index) => name === b[index]);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const backoff = (attempt: number) => {
|
|
69
|
+
const base = Math.min(
|
|
70
|
+
BridgeProtocol.BridgeTiming.RECONNECT_BACKOFF_MIN_MS * 2 ** Math.min(attempt, 16),
|
|
71
|
+
BridgeProtocol.BridgeTiming.RECONNECT_BACKOFF_MAX_MS,
|
|
72
|
+
);
|
|
73
|
+
return Math.round(base / 2 + Math.random() * (base / 2));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface TunnelOptions {
|
|
77
|
+
readonly api: URL;
|
|
78
|
+
readonly identity: OpenTunnelIdentity;
|
|
79
|
+
readonly routes: OpenTunnelRoutes;
|
|
80
|
+
readonly onEvent: (event: OpenTunnelClientEvent) => void;
|
|
81
|
+
/** Called with the identity after the server renews its certificate. */
|
|
82
|
+
readonly onRenewed?: (identity: OpenTunnelIdentity) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A running tunnel: one bridge session at a time, reconnecting with backoff
|
|
87
|
+
* until closed or a fatal attach error.
|
|
88
|
+
*/
|
|
89
|
+
export class Tunnel {
|
|
90
|
+
private routes: OpenTunnelRoutes;
|
|
91
|
+
private identity: OpenTunnelIdentity;
|
|
92
|
+
private secureContext: Tls.SecureContext;
|
|
93
|
+
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
94
|
+
private session: Session | undefined;
|
|
95
|
+
private shutdown = false;
|
|
96
|
+
private wake: (() => void) | undefined;
|
|
97
|
+
private readonly status: {
|
|
98
|
+
-readonly [K in keyof OpenTunnelStatus]: OpenTunnelStatus[K];
|
|
99
|
+
};
|
|
100
|
+
readonly ready: Promise<void>;
|
|
101
|
+
readonly closed: Promise<void>;
|
|
102
|
+
|
|
103
|
+
constructor(private readonly options: TunnelOptions) {
|
|
104
|
+
validateRoutes(options.routes);
|
|
105
|
+
this.routes = { ...options.routes };
|
|
106
|
+
this.identity = options.identity;
|
|
107
|
+
this.secureContext = secureContextFor(options.identity);
|
|
108
|
+
this.status = {
|
|
109
|
+
state: "connecting",
|
|
110
|
+
hostname: options.identity.hostname,
|
|
111
|
+
routes: this.routes,
|
|
112
|
+
connections: 0,
|
|
113
|
+
};
|
|
114
|
+
let ready!: () => void;
|
|
115
|
+
let failed!: (error: unknown) => void;
|
|
116
|
+
this.ready = new Promise((resolve, reject) => {
|
|
117
|
+
ready = resolve;
|
|
118
|
+
failed = reject;
|
|
119
|
+
});
|
|
120
|
+
this.closed = this.run(ready).then(
|
|
121
|
+
() => ready(),
|
|
122
|
+
(error) => {
|
|
123
|
+
failed(error);
|
|
124
|
+
throw error;
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
this.ready.catch(() => undefined);
|
|
128
|
+
void this.refreshCertificate();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fetches the server's current certificate and, when the server has
|
|
133
|
+
* renewed it, uses it for new connections. Checks on start, then on a timer.
|
|
134
|
+
*/
|
|
135
|
+
private async refreshCertificate(): Promise<void> {
|
|
136
|
+
if (this.shutdown) return;
|
|
137
|
+
try {
|
|
138
|
+
const url = new URL(
|
|
139
|
+
`/api/tunnel/${encodeURIComponent(this.identity.id)}/certificate`,
|
|
140
|
+
this.options.api,
|
|
141
|
+
);
|
|
142
|
+
const response = await fetch(url, { headers: { authorization: `Bearer ${this.identity.token}` } });
|
|
143
|
+
const body = response.ok
|
|
144
|
+
? ((await response.json()) as {
|
|
145
|
+
state?: { type?: string; certificate?: string; chain?: string; expiry?: string };
|
|
146
|
+
})
|
|
147
|
+
: undefined;
|
|
148
|
+
const state = body?.state;
|
|
149
|
+
if (
|
|
150
|
+
state?.type === "ready" &&
|
|
151
|
+
state.certificate &&
|
|
152
|
+
state.expiry &&
|
|
153
|
+
state.certificate !== this.identity.certificate
|
|
154
|
+
) {
|
|
155
|
+
const renewed: OpenTunnelIdentity = {
|
|
156
|
+
...this.identity,
|
|
157
|
+
certificate: state.certificate,
|
|
158
|
+
chain: state.chain ?? "",
|
|
159
|
+
certificateExpiry: new Date(state.expiry),
|
|
160
|
+
};
|
|
161
|
+
this.secureContext = secureContextFor(renewed);
|
|
162
|
+
this.identity = renewed;
|
|
163
|
+
this.options.onRenewed?.(renewed);
|
|
164
|
+
this.emit({ type: "certificate-renewed", expiry: state.expiry });
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
// Not fatal: the current certificate keeps serving; retry on the next check.
|
|
168
|
+
}
|
|
169
|
+
if (this.shutdown) return;
|
|
170
|
+
const due = this.identity.certificateExpiry.getTime() - Date.now() < RENEWAL_WINDOW_MS;
|
|
171
|
+
this.refreshTimer = setTimeout(
|
|
172
|
+
() => void this.refreshCertificate(),
|
|
173
|
+
due ? CERTIFICATE_CHECK_DUE_MS : CERTIFICATE_CHECK_FRESH_MS,
|
|
174
|
+
);
|
|
175
|
+
this.refreshTimer.unref?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
getStatus(): OpenTunnelStatus {
|
|
179
|
+
return { ...this.status, routes: { ...this.status.routes } };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Replaces the routes. Changing only targets applies to new connections
|
|
184
|
+
* immediately; adding or removing names re-attaches the bridge.
|
|
185
|
+
*/
|
|
186
|
+
setRoutes(routes: OpenTunnelRoutes): void {
|
|
187
|
+
validateRoutes(routes);
|
|
188
|
+
const previous = this.routes;
|
|
189
|
+
this.routes = { ...routes };
|
|
190
|
+
this.status.routes = this.routes;
|
|
191
|
+
if (!sameNames(previous, this.routes)) this.session?.end({ type: "routes-changed" });
|
|
192
|
+
this.wake?.();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
close(): Promise<void> {
|
|
196
|
+
this.shutdown = true;
|
|
197
|
+
clearTimeout(this.refreshTimer);
|
|
198
|
+
this.session?.end({ type: "shutdown" });
|
|
199
|
+
this.wake?.();
|
|
200
|
+
return this.closed.catch(() => undefined);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
private emit(event: OpenTunnelClientEvent) {
|
|
204
|
+
this.options.onEvent(event);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private sleep(ms: number) {
|
|
208
|
+
return new Promise<void>((resolve) => {
|
|
209
|
+
const timer = setTimeout(done, ms);
|
|
210
|
+
function done() {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
resolve();
|
|
213
|
+
}
|
|
214
|
+
this.wake = done;
|
|
215
|
+
}).finally(() => {
|
|
216
|
+
this.wake = undefined;
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async run(onReady: () => void): Promise<void> {
|
|
221
|
+
let attempt = 0;
|
|
222
|
+
try {
|
|
223
|
+
while (!this.shutdown) {
|
|
224
|
+
if (Object.keys(this.routes).length === 0) {
|
|
225
|
+
this.status.state = "waiting-routes";
|
|
226
|
+
await this.sleep(2 ** 31 - 1);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
this.status.state = "connecting";
|
|
230
|
+
this.emit({ type: "connecting", attempt });
|
|
231
|
+
const session = new Session(this.options, this.routes, () => this.secureContext, {
|
|
232
|
+
emit: (event) => this.emit(event),
|
|
233
|
+
target: (route) => this.routes[route],
|
|
234
|
+
attached: (sessionID) => {
|
|
235
|
+
this.status.state = "connected";
|
|
236
|
+
this.status.session = sessionID;
|
|
237
|
+
this.status.lastError = undefined;
|
|
238
|
+
this.status.connectedAt = Date.now();
|
|
239
|
+
onReady();
|
|
240
|
+
},
|
|
241
|
+
connections: (count) => {
|
|
242
|
+
this.status.connections = count;
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
this.session = session;
|
|
246
|
+
const end = await session.run();
|
|
247
|
+
this.session = undefined;
|
|
248
|
+
this.status.session = undefined;
|
|
249
|
+
this.status.connections = 0;
|
|
250
|
+
|
|
251
|
+
if (end.type === "shutdown") break;
|
|
252
|
+
if (end.type === "fatal") throw end.error;
|
|
253
|
+
if (end.type === "routes-changed") {
|
|
254
|
+
this.emit({ type: "disconnected", reason: "routes changed" });
|
|
255
|
+
attempt = 0;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (end.attached) attempt = 0;
|
|
259
|
+
this.emit({ type: "disconnected", reason: end.error });
|
|
260
|
+
const delay = backoff(attempt);
|
|
261
|
+
attempt++;
|
|
262
|
+
this.status.state = "reconnecting";
|
|
263
|
+
this.status.lastError = end.error;
|
|
264
|
+
this.emit({ type: "reconnecting", attempt, delayMs: delay });
|
|
265
|
+
await this.sleep(delay);
|
|
266
|
+
}
|
|
267
|
+
this.status.state = "stopped";
|
|
268
|
+
this.emit({ type: "stopped" });
|
|
269
|
+
} catch (error) {
|
|
270
|
+
this.status.state = "stopped";
|
|
271
|
+
this.status.lastError = error instanceof Error ? error.message : String(error);
|
|
272
|
+
this.emit({ type: "stopped", error: this.status.lastError });
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
interface SessionHooks {
|
|
279
|
+
readonly emit: (event: OpenTunnelClientEvent) => void;
|
|
280
|
+
readonly target: (route: string) => string | undefined;
|
|
281
|
+
readonly attached: (session: string) => void;
|
|
282
|
+
readonly connections: (count: number) => void;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
class Session {
|
|
286
|
+
private socket: WebSocket | undefined;
|
|
287
|
+
private finish: ((end: SessionEnd) => void) | undefined;
|
|
288
|
+
private readonly channels = new Map<number, Channel>();
|
|
289
|
+
private readonly drainWaiters: Array<() => void> = [];
|
|
290
|
+
private drainTimer: ReturnType<typeof setInterval> | undefined;
|
|
291
|
+
private heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
292
|
+
private attachTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
293
|
+
private lastReceived = Date.now();
|
|
294
|
+
private attached = false;
|
|
295
|
+
|
|
296
|
+
constructor(
|
|
297
|
+
private readonly options: TunnelOptions,
|
|
298
|
+
private readonly routes: OpenTunnelRoutes,
|
|
299
|
+
private readonly secureContext: () => Tls.SecureContext,
|
|
300
|
+
private readonly hooks: SessionHooks,
|
|
301
|
+
) {}
|
|
302
|
+
|
|
303
|
+
run(): Promise<SessionEnd> {
|
|
304
|
+
return new Promise((resolve) => {
|
|
305
|
+
this.finish = resolve;
|
|
306
|
+
const url = new URL(
|
|
307
|
+
`/api/tunnel/${encodeURIComponent(this.options.identity.id)}/connect`,
|
|
308
|
+
this.options.api,
|
|
309
|
+
);
|
|
310
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
311
|
+
const socket = new WebSocket(url, BridgeProtocol.WEBSOCKET_SUBPROTOCOL);
|
|
312
|
+
socket.binaryType = "arraybuffer";
|
|
313
|
+
this.socket = socket;
|
|
314
|
+
this.attachTimeout = setTimeout(
|
|
315
|
+
() => this.end({ type: "retry", error: "bridge attach timed out", attached: false }),
|
|
316
|
+
BridgeProtocol.BridgeTiming.CONNECT_TIMEOUT_MS,
|
|
317
|
+
);
|
|
318
|
+
socket.addEventListener("open", () => {
|
|
319
|
+
this.sendControl({
|
|
320
|
+
type: "attach",
|
|
321
|
+
token: this.options.identity.token,
|
|
322
|
+
transport: "ws",
|
|
323
|
+
routes: Object.keys(this.routes).sort(),
|
|
324
|
+
client: { version: VERSION, max_conns: MAX_CONNS },
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
socket.addEventListener("message", (event) => {
|
|
328
|
+
this.lastReceived = Date.now();
|
|
329
|
+
if (typeof event.data === "string") this.onControl(event.data);
|
|
330
|
+
else this.onData(new Uint8Array(event.data as ArrayBuffer));
|
|
331
|
+
});
|
|
332
|
+
socket.addEventListener("close", (event) => {
|
|
333
|
+
const reason = `${event.code} ${event.reason}`.trim();
|
|
334
|
+
this.end({ type: "retry", error: `bridge closed: ${reason}`, attached: this.attached });
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
end(end: SessionEnd) {
|
|
340
|
+
const finish = this.finish;
|
|
341
|
+
if (!finish) return;
|
|
342
|
+
this.finish = undefined;
|
|
343
|
+
clearTimeout(this.attachTimeout);
|
|
344
|
+
clearInterval(this.heartbeat);
|
|
345
|
+
clearInterval(this.drainTimer);
|
|
346
|
+
for (const waiter of this.drainWaiters.splice(0)) waiter();
|
|
347
|
+
for (const channel of [...this.channels.values()]) channel.destroy("bridge disconnected");
|
|
348
|
+
if (this.socket && this.socket.readyState <= WebSocket.OPEN) this.socket.close(1000);
|
|
349
|
+
finish(end);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private onControl(text: string) {
|
|
353
|
+
const decoded = BridgeProtocol.decodeServerMessage(text);
|
|
354
|
+
if (Option.isNone(decoded)) return;
|
|
355
|
+
const message = decoded.value;
|
|
356
|
+
if (!this.attached) {
|
|
357
|
+
if (message.type === "attached") {
|
|
358
|
+
this.attached = true;
|
|
359
|
+
clearTimeout(this.attachTimeout);
|
|
360
|
+
const heartbeatMs = Math.max(message.heartbeat_ms, 1_000);
|
|
361
|
+
const idleTimeoutMs = Math.max(message.idle_timeout_ms, 5_000);
|
|
362
|
+
this.heartbeat = setInterval(() => {
|
|
363
|
+
if (Date.now() - this.lastReceived > idleTimeoutMs) {
|
|
364
|
+
this.end({ type: "retry", error: "bridge idle timeout", attached: true });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.sendControl({ type: "ping", time_sent: Date.now() });
|
|
368
|
+
}, heartbeatMs);
|
|
369
|
+
this.hooks.attached(message.session);
|
|
370
|
+
this.hooks.emit({ type: "connected", session: message.session, routes: [...message.routes] });
|
|
371
|
+
} else if (message.type === "attach_error") {
|
|
372
|
+
this.end(
|
|
373
|
+
fatalAttachCodes.has(message.code)
|
|
374
|
+
? { type: "fatal", error: new OpenTunnelAttachError(message.code) }
|
|
375
|
+
: { type: "retry", error: `attach failed: ${message.code}`, attached: false },
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
switch (message.type) {
|
|
381
|
+
case "open":
|
|
382
|
+
return this.open(message.conn, message.sni, message.peer);
|
|
383
|
+
case "end":
|
|
384
|
+
return this.channels.get(message.conn)?.endInbound();
|
|
385
|
+
case "reset":
|
|
386
|
+
return this.channels.get(message.conn)?.destroy(message.code, false);
|
|
387
|
+
case "ping":
|
|
388
|
+
return this.sendControl({ type: "pong", time_sent: message.time_sent });
|
|
389
|
+
case "drain":
|
|
390
|
+
return this.end({ type: "retry", error: `server draining: ${message.reason}`, attached: true });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private onData(frame: Uint8Array) {
|
|
395
|
+
const parsed = BridgeProtocol.parseDataFrame(frame);
|
|
396
|
+
if (!parsed) return;
|
|
397
|
+
this.channels.get(parsed.conn)?.push(parsed.payload);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
private open(conn: number, sni: string, peer: string) {
|
|
401
|
+
const route = Names.routeForSni(sni, this.options.identity.hostname);
|
|
402
|
+
const target = route === undefined ? undefined : this.hooks.target(route);
|
|
403
|
+
const parsed = target === undefined ? undefined : Names.parseTarget(target);
|
|
404
|
+
if (route === undefined || !parsed) {
|
|
405
|
+
this.sendControl({ type: "reset", conn, code: BridgeProtocol.BridgeErrorCode.UNKNOWN_ROUTE });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const channel = new Channel(conn, route, parsed, this.secureContext(), this);
|
|
409
|
+
this.channels.set(conn, channel);
|
|
410
|
+
this.hooks.connections(this.channels.size);
|
|
411
|
+
this.hooks.emit({ type: "connection-opened", conn, route, peer });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Called by a channel once it has fully closed. */
|
|
415
|
+
closed(channel: Channel, error: string | undefined) {
|
|
416
|
+
if (this.channels.get(channel.conn) !== channel) return;
|
|
417
|
+
this.channels.delete(channel.conn);
|
|
418
|
+
this.hooks.connections(this.channels.size);
|
|
419
|
+
this.hooks.emit({
|
|
420
|
+
type: "connection-closed",
|
|
421
|
+
conn: channel.conn,
|
|
422
|
+
route: channel.route,
|
|
423
|
+
...(error === undefined ? {} : { error }),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
sendControl(message: unknown) {
|
|
428
|
+
if (this.socket?.readyState === WebSocket.OPEN) this.socket.send(JSON.stringify(message));
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Sends data for a connection; `done` runs once the WebSocket can take more. */
|
|
432
|
+
sendData(conn: number, data: Uint8Array, done: (error?: Error) => void) {
|
|
433
|
+
const socket = this.socket;
|
|
434
|
+
if (!this.finish || socket?.readyState !== WebSocket.OPEN) {
|
|
435
|
+
done(new Error("bridge closed"));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
for (let offset = 0; offset < data.byteLength; offset += BridgeProtocol.DataFrame.MAX_PAYLOAD_SIZE) {
|
|
439
|
+
const chunk = data.subarray(offset, offset + BridgeProtocol.DataFrame.MAX_PAYLOAD_SIZE);
|
|
440
|
+
socket.send(BridgeProtocol.buildDataFrame(conn, chunk));
|
|
441
|
+
}
|
|
442
|
+
if (socket.bufferedAmount < HIGH_WATER_MARK) {
|
|
443
|
+
done();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
this.drainWaiters.push(() => done());
|
|
447
|
+
this.drainTimer ??= setInterval(() => {
|
|
448
|
+
if (this.socket && this.socket.bufferedAmount > LOW_WATER_MARK && this.finish) return;
|
|
449
|
+
clearInterval(this.drainTimer);
|
|
450
|
+
this.drainTimer = undefined;
|
|
451
|
+
for (const waiter of this.drainWaiters.splice(0)) waiter();
|
|
452
|
+
}, 5);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Terminates TLS for one public connection and forwards it to the target. */
|
|
457
|
+
class Channel {
|
|
458
|
+
private readonly raw: Duplex;
|
|
459
|
+
private readonly tls: Tls.TLSSocket;
|
|
460
|
+
private upstream: Net.Socket | undefined;
|
|
461
|
+
private done = false;
|
|
462
|
+
|
|
463
|
+
constructor(
|
|
464
|
+
readonly conn: number,
|
|
465
|
+
readonly route: string,
|
|
466
|
+
target: { readonly host: string; readonly port: number },
|
|
467
|
+
secureContext: Tls.SecureContext,
|
|
468
|
+
private readonly session: Session,
|
|
469
|
+
) {
|
|
470
|
+
this.raw = new Duplex({
|
|
471
|
+
allowHalfOpen: true,
|
|
472
|
+
read() {},
|
|
473
|
+
write: (chunk: Uint8Array, _encoding, callback) => session.sendData(conn, chunk, callback),
|
|
474
|
+
final: (callback) => {
|
|
475
|
+
session.sendControl({ type: "end", conn });
|
|
476
|
+
callback();
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
this.tls = new Tls.TLSSocket(this.raw as unknown as Net.Socket, {
|
|
480
|
+
isServer: true,
|
|
481
|
+
secureContext,
|
|
482
|
+
});
|
|
483
|
+
this.tls.on("secure", () => {
|
|
484
|
+
const upstream = Net.connect({ host: target.host, port: target.port, allowHalfOpen: true });
|
|
485
|
+
this.upstream = upstream;
|
|
486
|
+
upstream.setNoDelay(true);
|
|
487
|
+
upstream.on("error", (error) =>
|
|
488
|
+
this.destroy(
|
|
489
|
+
upstream.connecting
|
|
490
|
+
? BridgeProtocol.BridgeErrorCode.UPSTREAM_CONNECT_FAILED
|
|
491
|
+
: BridgeProtocol.BridgeErrorCode.UPSTREAM_IO_ERROR,
|
|
492
|
+
true,
|
|
493
|
+
error.message,
|
|
494
|
+
),
|
|
495
|
+
);
|
|
496
|
+
this.tls.pipe(upstream);
|
|
497
|
+
upstream.pipe(this.tls);
|
|
498
|
+
});
|
|
499
|
+
this.tls.on("error", (error) =>
|
|
500
|
+
this.destroy(BridgeProtocol.BridgeErrorCode.UPSTREAM_IO_ERROR, true, error.message),
|
|
501
|
+
);
|
|
502
|
+
this.tls.on("close", () => this.finish(undefined));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
push(payload: Uint8Array) {
|
|
506
|
+
if (this.done) return;
|
|
507
|
+
if (this.raw.readableLength > STALLED_CHANNEL_BYTES) {
|
|
508
|
+
this.destroy(BridgeProtocol.BridgeErrorCode.UPSTREAM_IO_ERROR, true, "local connection stalled");
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
this.raw.push(payload);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
endInbound() {
|
|
515
|
+
if (!this.done) this.raw.push(null);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Aborts the connection, optionally telling the server. */
|
|
519
|
+
destroy(code: string, notify = true, error = code) {
|
|
520
|
+
if (this.done) return;
|
|
521
|
+
if (notify) this.session.sendControl({ type: "reset", conn: this.conn, code });
|
|
522
|
+
this.finish(error);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
private finish(error: string | undefined) {
|
|
526
|
+
if (this.done) return;
|
|
527
|
+
this.done = true;
|
|
528
|
+
this.upstream?.destroy();
|
|
529
|
+
this.tls.destroy();
|
|
530
|
+
this.raw.destroy();
|
|
531
|
+
this.session.closed(this, error);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Effect, Scope, Stream } from "effect";
|
|
2
|
+
import type { OpenTunnelError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export interface OpenTunnelProfileOptions {
|
|
5
|
+
readonly profile?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type OpenTunnelProvisionStage =
|
|
9
|
+
| "creating-tunnel"
|
|
10
|
+
| "generating-key"
|
|
11
|
+
| "generating-csr"
|
|
12
|
+
| "resuming-certificate"
|
|
13
|
+
| "requesting-certificate"
|
|
14
|
+
| "waiting-certificate"
|
|
15
|
+
| "saving-identity"
|
|
16
|
+
| "ready";
|
|
17
|
+
|
|
18
|
+
/** Route name (`@` for the tunnel hostname, or a subdomain label) to `host:port`. */
|
|
19
|
+
export type OpenTunnelRoutes = Readonly<Record<string, string>>;
|
|
20
|
+
|
|
21
|
+
export interface OpenTunnelIdentity {
|
|
22
|
+
readonly id: string;
|
|
23
|
+
readonly hostname: string;
|
|
24
|
+
readonly token: string;
|
|
25
|
+
readonly privateKey: string;
|
|
26
|
+
readonly certificate: string;
|
|
27
|
+
readonly chain: string;
|
|
28
|
+
readonly certificateExpiry: Date;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface OpenTunnelPendingIdentity {
|
|
32
|
+
readonly id: string;
|
|
33
|
+
readonly hostname: string;
|
|
34
|
+
readonly token: string;
|
|
35
|
+
readonly privateKey: string;
|
|
36
|
+
readonly csr: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface OpenTunnelStoredTunnel {
|
|
40
|
+
readonly profile: string;
|
|
41
|
+
readonly tunnel: OpenTunnelIdentity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type OpenTunnelClientEvent =
|
|
45
|
+
| { readonly type: "connecting"; readonly attempt: number }
|
|
46
|
+
| { readonly type: "connected"; readonly session: string; readonly routes: ReadonlyArray<string> }
|
|
47
|
+
| { readonly type: "disconnected"; readonly reason: string }
|
|
48
|
+
| { readonly type: "reconnecting"; readonly attempt: number; readonly delayMs: number }
|
|
49
|
+
| { readonly type: "connection-opened"; readonly conn: number; readonly route: string; readonly peer: string }
|
|
50
|
+
| { readonly type: "connection-closed"; readonly conn: number; readonly route: string; readonly error?: string }
|
|
51
|
+
| { readonly type: "certificate-renewed"; readonly expiry: string }
|
|
52
|
+
| { readonly type: "stopped"; readonly error?: string };
|
|
53
|
+
|
|
54
|
+
export interface OpenTunnelStatus {
|
|
55
|
+
readonly state: "waiting-routes" | "connecting" | "connected" | "reconnecting" | "stopped";
|
|
56
|
+
readonly hostname: string;
|
|
57
|
+
readonly routes: OpenTunnelRoutes;
|
|
58
|
+
readonly session?: string;
|
|
59
|
+
readonly connections: number;
|
|
60
|
+
readonly lastError?: string;
|
|
61
|
+
/** Unix milliseconds of the last successful attach. */
|
|
62
|
+
readonly connectedAt?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface OpenTunnelConnectOptions extends OpenTunnelProfileOptions {
|
|
66
|
+
readonly routes: OpenTunnelRoutes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface OpenTunnelConnection {
|
|
70
|
+
readonly tunnel: OpenTunnelIdentity;
|
|
71
|
+
readonly events: Stream.Stream<OpenTunnelClientEvent>;
|
|
72
|
+
readonly status: Effect.Effect<OpenTunnelStatus>;
|
|
73
|
+
readonly setRoutes: (routes: OpenTunnelRoutes) => Effect.Effect<void, OpenTunnelError>;
|
|
74
|
+
/** Completes when the tunnel stops after a fatal error or is closed. */
|
|
75
|
+
readonly closed: Effect.Effect<void, OpenTunnelError>;
|
|
76
|
+
readonly close: Effect.Effect<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface OpenTunnelEffectClient {
|
|
80
|
+
readonly profile: {
|
|
81
|
+
readonly list: () => Effect.Effect<ReadonlyArray<string>, OpenTunnelError>;
|
|
82
|
+
};
|
|
83
|
+
readonly tunnel: {
|
|
84
|
+
readonly list: () => Effect.Effect<ReadonlyArray<OpenTunnelStoredTunnel>, OpenTunnelError>;
|
|
85
|
+
readonly get: (
|
|
86
|
+
options?: OpenTunnelProfileOptions,
|
|
87
|
+
) => Effect.Effect<OpenTunnelIdentity | undefined, OpenTunnelError>;
|
|
88
|
+
readonly pending: (
|
|
89
|
+
options?: OpenTunnelProfileOptions,
|
|
90
|
+
) => Effect.Effect<Pick<OpenTunnelPendingIdentity, "id" | "hostname"> | undefined, OpenTunnelError>;
|
|
91
|
+
readonly resume: (
|
|
92
|
+
options?: OpenTunnelProfileOptions & {
|
|
93
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
94
|
+
},
|
|
95
|
+
) => Effect.Effect<OpenTunnelIdentity | undefined, OpenTunnelError>;
|
|
96
|
+
readonly create: (
|
|
97
|
+
options?: OpenTunnelProfileOptions & {
|
|
98
|
+
readonly onProgress?: (stage: OpenTunnelProvisionStage) => void;
|
|
99
|
+
},
|
|
100
|
+
) => Effect.Effect<OpenTunnelIdentity, OpenTunnelError>;
|
|
101
|
+
readonly ensure: (
|
|
102
|
+
options?: OpenTunnelProfileOptions,
|
|
103
|
+
) => Effect.Effect<OpenTunnelIdentity, OpenTunnelError>;
|
|
104
|
+
readonly remove: (
|
|
105
|
+
options?: OpenTunnelProfileOptions,
|
|
106
|
+
) => Effect.Effect<void, OpenTunnelError>;
|
|
107
|
+
/**
|
|
108
|
+
* Starts forwarding routes for the profile's tunnel, creating it if
|
|
109
|
+
* needed. Succeeds once the bridge first attaches and keeps reconnecting
|
|
110
|
+
* until the scope closes.
|
|
111
|
+
*/
|
|
112
|
+
readonly connect: (
|
|
113
|
+
options: OpenTunnelConnectOptions,
|
|
114
|
+
) => Effect.Effect<OpenTunnelConnection, OpenTunnelError, Scope.Scope>;
|
|
115
|
+
};
|
|
116
|
+
}
|