ai-remote 0.3.1 → 0.4.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/dist/cli.mjs +232 -81
- package/{src/index.ts → dist/index.d.ts} +0 -1
- package/dist/index.js +0 -1
- package/dist/protocols/index.d.ts +31 -0
- package/dist/protocols/index.js +0 -1
- package/dist/protocols/rdp/buffer.d.ts +38 -0
- package/dist/protocols/rdp/buffer.js +0 -1
- package/dist/protocols/rdp/caps.d.ts +49 -0
- package/dist/protocols/rdp/caps.js +0 -1
- package/dist/protocols/rdp/cert.d.ts +18 -0
- package/dist/protocols/rdp/cert.js +0 -1
- package/dist/protocols/rdp/client.d.ts +56 -0
- package/dist/protocols/rdp/client.js +0 -1
- package/dist/protocols/rdp/cliprdr.d.ts +27 -0
- package/dist/protocols/rdp/cliprdr.js +0 -1
- package/dist/protocols/rdp/credssp.d.ts +34 -0
- package/dist/protocols/rdp/credssp.js +0 -1
- package/dist/protocols/rdp/crypto.d.ts +63 -0
- package/dist/protocols/rdp/crypto.js +0 -1
- package/dist/protocols/rdp/display.d.ts +38 -0
- package/dist/protocols/rdp/display.js +0 -1
- package/dist/protocols/rdp/gcc.d.ts +23 -0
- package/dist/protocols/rdp/gcc.js +0 -1
- package/dist/protocols/rdp/keymap.d.ts +9 -0
- package/dist/protocols/rdp/keymap.js +0 -1
- package/dist/protocols/rdp/mcs.d.ts +40 -0
- package/dist/protocols/rdp/mcs.js +0 -1
- package/dist/protocols/rdp/ntlm.d.ts +61 -0
- package/dist/protocols/rdp/ntlm.js +0 -1
- package/dist/protocols/rdp/pdu.d.ts +155 -0
- package/dist/protocols/rdp/pdu.js +0 -1
- package/dist/protocols/rdp/rail.d.ts +119 -0
- package/dist/protocols/rdp/rail.js +0 -1
- package/dist/protocols/rdp/rle.d.ts +13 -0
- package/dist/protocols/rdp/rle.js +0 -1
- package/dist/protocols/rdp/sec.d.ts +59 -0
- package/dist/protocols/rdp/sec.js +0 -1
- package/dist/protocols/rdp/session.d.ts +62 -0
- package/dist/protocols/rdp/session.js +0 -1
- package/dist/protocols/rdp/tls.d.ts +18 -0
- package/dist/protocols/rdp/tls.js +0 -1
- package/dist/protocols/rdp/vchannel.d.ts +28 -0
- package/dist/protocols/rdp/vchannel.js +0 -1
- package/dist/protocols/rdp/x224.d.ts +40 -0
- package/dist/protocols/rdp/x224.js +0 -1
- package/dist/protocols/ssh/kex.d.ts +97 -0
- package/dist/protocols/ssh/kex.js +0 -1
- package/dist/protocols/ssh/messages.d.ts +50 -0
- package/dist/protocols/ssh/messages.js +0 -1
- package/dist/protocols/ssh/session.d.ts +66 -0
- package/dist/protocols/ssh/session.js +0 -1
- package/dist/protocols/ssh/terminal.d.ts +46 -0
- package/dist/protocols/ssh/terminal.js +0 -1
- package/dist/protocols/ssh/transport.d.ts +62 -0
- package/dist/protocols/ssh/transport.js +0 -1
- package/dist/protocols/ssh/wire.d.ts +52 -0
- package/dist/protocols/ssh/wire.js +0 -1
- package/dist/protocols/types.d.ts +127 -0
- package/dist/protocols/types.js +0 -1
- package/dist/protocols/vnc/input.d.ts +5 -0
- package/dist/protocols/vnc/input.js +0 -1
- package/dist/protocols/vnc/session.d.ts +12 -0
- package/dist/protocols/vnc/session.js +0 -1
- package/dist/shared/connection.d.ts +81 -0
- package/dist/shared/connection.js +0 -1
- package/dist/shared/device.d.ts +26 -0
- package/dist/shared/device.js +0 -1
- package/dist/shared/hosts.d.ts +63 -0
- package/dist/shared/hosts.js +0 -1
- package/dist/shared/icons.d.ts +46 -0
- package/dist/shared/icons.js +0 -1
- package/dist/shared/protocol.d.ts +28 -0
- package/dist/shared/protocol.js +0 -1
- package/dist/shared/signals.d.ts +37 -0
- package/dist/shared/signals.js +0 -1
- package/package.json +16 -14
- package/dist/index.js.map +0 -7
- package/dist/protocols/index.js.map +0 -7
- package/dist/protocols/rdp/buffer.js.map +0 -7
- package/dist/protocols/rdp/caps.js.map +0 -7
- package/dist/protocols/rdp/cert.js.map +0 -7
- package/dist/protocols/rdp/client.js.map +0 -7
- package/dist/protocols/rdp/cliprdr.js.map +0 -7
- package/dist/protocols/rdp/credssp.js.map +0 -7
- package/dist/protocols/rdp/crypto.js.map +0 -7
- package/dist/protocols/rdp/display.js.map +0 -7
- package/dist/protocols/rdp/gcc.js.map +0 -7
- package/dist/protocols/rdp/keymap.js.map +0 -7
- package/dist/protocols/rdp/mcs.js.map +0 -7
- package/dist/protocols/rdp/ntlm.js.map +0 -7
- package/dist/protocols/rdp/pdu.js.map +0 -7
- package/dist/protocols/rdp/rail.js.map +0 -7
- package/dist/protocols/rdp/rle.js.map +0 -7
- package/dist/protocols/rdp/sec.js.map +0 -7
- package/dist/protocols/rdp/session.js.map +0 -7
- package/dist/protocols/rdp/tls.js.map +0 -7
- package/dist/protocols/rdp/vchannel.js.map +0 -7
- package/dist/protocols/rdp/x224.js.map +0 -7
- package/dist/protocols/ssh/kex.js.map +0 -7
- package/dist/protocols/ssh/messages.js.map +0 -7
- package/dist/protocols/ssh/session.js.map +0 -7
- package/dist/protocols/ssh/terminal.js.map +0 -7
- package/dist/protocols/ssh/transport.js.map +0 -7
- package/dist/protocols/ssh/wire.js.map +0 -7
- package/dist/protocols/types.js.map +0 -7
- package/dist/protocols/vnc/input.js.map +0 -7
- package/dist/protocols/vnc/session.js.map +0 -7
- package/dist/shared/connection.js.map +0 -7
- package/dist/shared/device.js.map +0 -7
- package/dist/shared/hosts.js.map +0 -7
- package/dist/shared/icons.js.map +0 -7
- package/dist/shared/protocol.js.map +0 -7
- package/dist/shared/signals.js.map +0 -7
- package/src/cli/cli.ts +0 -452
- package/src/cli/daemon.ts +0 -295
- package/src/cli/framebuffer.ts +0 -115
- package/src/cli/generated/viewer-bundle.ts +0 -5
- package/src/cli/ipc.ts +0 -78
- package/src/cli/paths.ts +0 -28
- package/src/cli/png.ts +0 -106
- package/src/cli/session.ts +0 -263
- package/src/cli/shell.ts +0 -184
- package/src/cli/transport.ts +0 -86
- package/src/cli/viewer-client/assets.d.ts +0 -4
- package/src/cli/viewer-client/main.ts +0 -300
- package/src/cli/viewer-page.ts +0 -102
- package/src/cli/viewer.ts +0 -272
- package/src/cli/wsserver.ts +0 -144
- package/src/protocols/index.ts +0 -72
- package/src/protocols/rdp/buffer.ts +0 -204
- package/src/protocols/rdp/caps.ts +0 -304
- package/src/protocols/rdp/cert.ts +0 -190
- package/src/protocols/rdp/client.ts +0 -1371
- package/src/protocols/rdp/cliprdr.ts +0 -344
- package/src/protocols/rdp/credssp.ts +0 -319
- package/src/protocols/rdp/crypto.ts +0 -416
- package/src/protocols/rdp/display.ts +0 -750
- package/src/protocols/rdp/gcc.ts +0 -162
- package/src/protocols/rdp/keymap.ts +0 -68
- package/src/protocols/rdp/mcs.ts +0 -283
- package/src/protocols/rdp/ntlm.ts +0 -380
- package/src/protocols/rdp/pdu.ts +0 -497
- package/src/protocols/rdp/rail.ts +0 -480
- package/src/protocols/rdp/rle.ts +0 -343
- package/src/protocols/rdp/sec.ts +0 -184
- package/src/protocols/rdp/session.ts +0 -393
- package/src/protocols/rdp/tls.ts +0 -452
- package/src/protocols/rdp/vchannel.ts +0 -89
- package/src/protocols/rdp/x224.ts +0 -139
- package/src/protocols/ssh/kex.ts +0 -227
- package/src/protocols/ssh/messages.ts +0 -54
- package/src/protocols/ssh/session.ts +0 -879
- package/src/protocols/ssh/terminal.ts +0 -242
- package/src/protocols/ssh/transport.ts +0 -745
- package/src/protocols/ssh/wire.ts +0 -220
- package/src/protocols/types.ts +0 -146
- package/src/protocols/vnc/input.ts +0 -13
- package/src/protocols/vnc/novnc.d.ts +0 -75
- package/src/protocols/vnc/session.ts +0 -367
- package/src/shared/connection.ts +0 -206
- package/src/shared/device.ts +0 -28
- package/src/shared/hosts.ts +0 -240
- package/src/shared/icons.ts +0 -49
- package/src/shared/protocol.ts +0 -55
- package/src/shared/signals.ts +0 -67
package/src/cli/viewer.ts
DELETED
|
@@ -1,272 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The window a human watches the session through.
|
|
3
|
-
*
|
|
4
|
-
* The session already exists and already holds the pixels; this is an
|
|
5
|
-
* attachment to it. Viewers may come and go, and there may be none at all --
|
|
6
|
-
* a viewer that could end a session would make supervision a dependency
|
|
7
|
-
* rather than a feature.
|
|
8
|
-
*
|
|
9
|
-
* A viewer that has just attached needs the whole desktop, not a delta, which
|
|
10
|
-
* is exactly what the framebuffer is for: it sends one keyframe on connect and
|
|
11
|
-
* painted rectangles after that.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { createServer } from 'node:http';
|
|
15
|
-
import type net from 'node:net';
|
|
16
|
-
import { randomBytes } from 'node:crypto';
|
|
17
|
-
import { spawn } from 'node:child_process';
|
|
18
|
-
import { deflateSync } from 'node:zlib';
|
|
19
|
-
import type { RdpSession } from './session';
|
|
20
|
-
import type { Shell } from './shell';
|
|
21
|
-
import { accept, type WebSocketPeer } from './wsserver';
|
|
22
|
-
import { PAGE } from './viewer-page';
|
|
23
|
-
|
|
24
|
-
export interface Viewer {
|
|
25
|
-
url: string;
|
|
26
|
-
/** Resolves when a browser has actually attached. */
|
|
27
|
-
opened: Promise<void>;
|
|
28
|
-
/** Pop the window again, for a viewer that was closed by hand. */
|
|
29
|
-
launch(): void;
|
|
30
|
-
close(): void;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Frame layout, little-endian:
|
|
35
|
-
* u8 kind 1 = keyframe, 2 = rectangle
|
|
36
|
-
* u16 left, u16 top, u16 width, u16 height
|
|
37
|
-
* deflated RGBA
|
|
38
|
-
*/
|
|
39
|
-
function encodeRect(kind: number, left: number, top: number, width: number, height: number, rgba: Uint8Array): Buffer {
|
|
40
|
-
const header = Buffer.alloc(9);
|
|
41
|
-
header.writeUInt8(kind, 0);
|
|
42
|
-
header.writeUInt16LE(left, 1);
|
|
43
|
-
header.writeUInt16LE(top, 3);
|
|
44
|
-
header.writeUInt16LE(width, 5);
|
|
45
|
-
header.writeUInt16LE(height, 7);
|
|
46
|
-
return Buffer.concat([header, deflateSync(rgba, { level: 1 })]);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export async function startViewer(
|
|
50
|
-
session: RdpSession,
|
|
51
|
-
port: number,
|
|
52
|
-
{ launch = true, openShell }: { launch?: boolean; openShell?: () => Promise<Shell> } = {}
|
|
53
|
-
): Promise<Viewer> {
|
|
54
|
-
const token = randomBytes(16).toString('hex');
|
|
55
|
-
const peers = new Set<WebSocketPeer>();
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Which viewers may send input.
|
|
59
|
-
*
|
|
60
|
-
* The gate lives here rather than in the page, because a gate in the page is
|
|
61
|
-
* not a gate: anything that can open a socket can skip it. A viewer attaches
|
|
62
|
-
* as an observer and has to ask for control, which is also the right default
|
|
63
|
-
* when the session was opened by an agent -- the human watches first.
|
|
64
|
-
*/
|
|
65
|
-
const drivers = new WeakSet<WebSocketPeer>();
|
|
66
|
-
|
|
67
|
-
/** Undoes each viewer's subscription to shell output when it goes away. */
|
|
68
|
-
const detachShell = new Map<WebSocketPeer, () => void>();
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Attach a viewer to the terminal, opening it if this is the first ask.
|
|
72
|
-
*
|
|
73
|
-
* The scrollback goes out first, so a viewer that opens the terminal ten
|
|
74
|
-
* minutes into a session sees what has already been run rather than an empty
|
|
75
|
-
* pane that looks broken.
|
|
76
|
-
*/
|
|
77
|
-
async function attachShell(peer: WebSocketPeer): Promise<void> {
|
|
78
|
-
if (!openShell) {
|
|
79
|
-
peer.send(JSON.stringify({
|
|
80
|
-
type: 'shell-error',
|
|
81
|
-
message: 'This session has no terminal available.',
|
|
82
|
-
}));
|
|
83
|
-
return;
|
|
84
|
-
}
|
|
85
|
-
if (detachShell.has(peer)) return;
|
|
86
|
-
|
|
87
|
-
peer.send(JSON.stringify({ type: 'shell-status', state: 'opening' }));
|
|
88
|
-
let shell: Shell;
|
|
89
|
-
try {
|
|
90
|
-
shell = await openShell();
|
|
91
|
-
} catch (error) {
|
|
92
|
-
peer.send(JSON.stringify({
|
|
93
|
-
type: 'shell-error',
|
|
94
|
-
message: error instanceof Error ? error.message : String(error),
|
|
95
|
-
}));
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
const history = shell.scrollback;
|
|
100
|
-
if (history.length) {
|
|
101
|
-
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(history).toString('base64') }));
|
|
102
|
-
}
|
|
103
|
-
peer.send(JSON.stringify({ type: 'shell-status', state: 'open' }));
|
|
104
|
-
|
|
105
|
-
detachShell.set(peer, shell.onData((bytes) => {
|
|
106
|
-
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(bytes).toString('base64') }));
|
|
107
|
-
}));
|
|
108
|
-
|
|
109
|
-
shellFor.set(peer, shell);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const shellFor = new Map<WebSocketPeer, Shell>();
|
|
113
|
-
|
|
114
|
-
let resolveOpened: () => void;
|
|
115
|
-
const opened = new Promise<void>((resolve) => { resolveOpened = resolve; });
|
|
116
|
-
|
|
117
|
-
const server = createServer((request, response) => {
|
|
118
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
119
|
-
if (url.pathname === '/' && url.searchParams.get('t') === token) {
|
|
120
|
-
response.writeHead(200, {
|
|
121
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
122
|
-
'Cache-Control': 'no-store',
|
|
123
|
-
});
|
|
124
|
-
response.end(PAGE);
|
|
125
|
-
return;
|
|
126
|
-
}
|
|
127
|
-
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
128
|
-
response.end('Not found');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
server.on('upgrade', (request, socket) => {
|
|
132
|
-
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
133
|
-
if (url.searchParams.get('t') !== token) { socket.destroy(); return; }
|
|
134
|
-
|
|
135
|
-
// Node types an upgrade socket as Duplex; every runtime it has is a
|
|
136
|
-
// net.Socket, which is what lets the peer turn Nagle off.
|
|
137
|
-
const peer = accept(request, socket as net.Socket);
|
|
138
|
-
if (!peer) return;
|
|
139
|
-
peers.add(peer);
|
|
140
|
-
resolveOpened();
|
|
141
|
-
|
|
142
|
-
const { width, height } = session.framebuffer;
|
|
143
|
-
peer.send(JSON.stringify({ type: 'hello', width, height, host: session.options.host }));
|
|
144
|
-
if (width && height) {
|
|
145
|
-
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
peer.addEventListener('message', (event: Event) => {
|
|
149
|
-
const raw = (event as MessageEvent).data as string;
|
|
150
|
-
if (process.env.REMOTECTL_DEBUG) console.log('[viewer] <-', raw);
|
|
151
|
-
try {
|
|
152
|
-
const message = JSON.parse(raw);
|
|
153
|
-
|
|
154
|
-
if (message.type === 'control') {
|
|
155
|
-
if (message.on) drivers.add(peer); else drivers.delete(peer);
|
|
156
|
-
peer.send(JSON.stringify({ type: 'control', on: Boolean(message.on) }));
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (message.type === 'shell-attach') { void attachShell(peer); return; }
|
|
161
|
-
|
|
162
|
-
if (!drivers.has(peer)) return;
|
|
163
|
-
|
|
164
|
-
if (message.type === 'shell-input') {
|
|
165
|
-
shellFor.get(peer)?.write(String(message.text ?? ''));
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (message.type === 'shell-resize') {
|
|
169
|
-
shellFor.get(peer)?.resize(Number(message.columns) || 120, Number(message.rows) || 30);
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
applyInput(session, message);
|
|
174
|
-
} catch (error) {
|
|
175
|
-
// A malformed frame from a viewer must never take the session down.
|
|
176
|
-
console.warn('[viewer] dropped an unreadable message', error);
|
|
177
|
-
}
|
|
178
|
-
});
|
|
179
|
-
peer.addEventListener('close', () => {
|
|
180
|
-
peers.delete(peer);
|
|
181
|
-
detachShell.get(peer)?.();
|
|
182
|
-
detachShell.delete(peer);
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Loopback only. The viewer carries a live desktop and takes input for it.
|
|
188
|
-
*
|
|
189
|
-
* A busy port is not a reason to fail: a second session, or a stale process
|
|
190
|
-
* holding the default, would otherwise take the whole connection down. The
|
|
191
|
-
* chosen port is reported back, so nothing has to guess which one it got.
|
|
192
|
-
*/
|
|
193
|
-
const bound = await new Promise<number>((resolve, reject) => {
|
|
194
|
-
const listen = (candidate: number, retry: boolean) => {
|
|
195
|
-
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
196
|
-
if (retry && error.code === 'EADDRINUSE') { listen(0, false); return; }
|
|
197
|
-
reject(error);
|
|
198
|
-
});
|
|
199
|
-
server.listen(candidate, '127.0.0.1', () => {
|
|
200
|
-
const address = server.address();
|
|
201
|
-
resolve(typeof address === 'object' && address ? address.port : candidate);
|
|
202
|
-
});
|
|
203
|
-
};
|
|
204
|
-
listen(port, true);
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
session.onResize((width, height) => {
|
|
208
|
-
for (const peer of peers) {
|
|
209
|
-
peer.send(JSON.stringify({ type: 'resize', width, height }));
|
|
210
|
-
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
211
|
-
}
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
session.onPaint((rects) => {
|
|
215
|
-
if (!peers.size) return;
|
|
216
|
-
for (const rect of rects) {
|
|
217
|
-
const payload = encodeRect(2, rect.left, rect.top, rect.width, rect.height, rect.rgba);
|
|
218
|
-
for (const peer of peers) peer.send(payload);
|
|
219
|
-
}
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
const url = `http://127.0.0.1:${bound}/?t=${token}`;
|
|
223
|
-
if (launch) open(url);
|
|
224
|
-
|
|
225
|
-
return {
|
|
226
|
-
url,
|
|
227
|
-
opened,
|
|
228
|
-
launch() { open(url); },
|
|
229
|
-
close() {
|
|
230
|
-
for (const peer of peers) peer.close();
|
|
231
|
-
server.close();
|
|
232
|
-
},
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
function applyInput(session: RdpSession, message: any): void {
|
|
237
|
-
switch (message.type) {
|
|
238
|
-
case 'move':
|
|
239
|
-
session.movePointer(message.x, message.y);
|
|
240
|
-
break;
|
|
241
|
-
case 'button':
|
|
242
|
-
session.pointerButton(message.x, message.y, message.button ?? 0, Boolean(message.pressed));
|
|
243
|
-
break;
|
|
244
|
-
case 'scroll':
|
|
245
|
-
session.scroll(message.x, message.y, message.dx ?? 0, message.dy ?? 0);
|
|
246
|
-
break;
|
|
247
|
-
case 'key':
|
|
248
|
-
session.sendKeyCode(message.code, Boolean(message.pressed));
|
|
249
|
-
break;
|
|
250
|
-
case 'text':
|
|
251
|
-
session.typeText(String(message.text ?? ''));
|
|
252
|
-
break;
|
|
253
|
-
case 'cad':
|
|
254
|
-
session.sendCtrlAltDel();
|
|
255
|
-
break;
|
|
256
|
-
default:
|
|
257
|
-
break;
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/** Pop the window. A failure here is not a failure of the session. */
|
|
262
|
-
function open(url: string): void {
|
|
263
|
-
const command = process.platform === 'darwin' ? 'open'
|
|
264
|
-
: process.platform === 'win32' ? 'cmd'
|
|
265
|
-
: 'xdg-open';
|
|
266
|
-
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
267
|
-
try {
|
|
268
|
-
spawn(command, args, { stdio: 'ignore', detached: true }).unref();
|
|
269
|
-
} catch {
|
|
270
|
-
// Headless, or no browser. The URL was printed; that is enough.
|
|
271
|
-
}
|
|
272
|
-
}
|
package/src/cli/wsserver.ts
DELETED
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Just enough WebSocket to talk to a browser tab.
|
|
3
|
-
*
|
|
4
|
-
* Node has no WebSocket server, and pulling one in would cost `npx remotectl`
|
|
5
|
-
* its single-download start. What the viewer needs is small: an upgrade
|
|
6
|
-
* handshake, binary frames out, text frames in.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createHash } from 'node:crypto';
|
|
10
|
-
import type { IncomingMessage } from 'node:http';
|
|
11
|
-
import type net from 'node:net';
|
|
12
|
-
|
|
13
|
-
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
14
|
-
|
|
15
|
-
export class WebSocketPeer extends EventTarget {
|
|
16
|
-
#socket: net.Socket;
|
|
17
|
-
#buffer = Buffer.alloc(0);
|
|
18
|
-
/** Stop writing. Set as soon as closing begins, from either end. */
|
|
19
|
-
#closed = false;
|
|
20
|
-
/**
|
|
21
|
-
* Whether the close event has gone out.
|
|
22
|
-
*
|
|
23
|
-
* Kept apart from #closed on purpose: one flag doing both jobs meant a peer
|
|
24
|
-
* that closed itself -- which is what a browser tab does when it goes away --
|
|
25
|
-
* swallowed its own close event, and the session went on sending frames to a
|
|
26
|
-
* viewer that had left.
|
|
27
|
-
*/
|
|
28
|
-
#reported = false;
|
|
29
|
-
|
|
30
|
-
constructor(socket: net.Socket) {
|
|
31
|
-
super();
|
|
32
|
-
this.#socket = socket;
|
|
33
|
-
socket.on('data', (chunk: Buffer) => this.#onData(chunk));
|
|
34
|
-
socket.on('close', () => this.#onClose());
|
|
35
|
-
socket.on('error', () => this.#onClose());
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
get closed(): boolean { return this.#closed; }
|
|
39
|
-
|
|
40
|
-
send(data: string | Uint8Array): void {
|
|
41
|
-
if (this.#closed) return;
|
|
42
|
-
const isText = typeof data === 'string';
|
|
43
|
-
const payload = isText ? Buffer.from(data, 'utf8') : Buffer.from(data);
|
|
44
|
-
this.#socket.write(frame(isText ? 0x1 : 0x2, payload));
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
close(): void {
|
|
48
|
-
if (!this.#closed) {
|
|
49
|
-
this.#closed = true;
|
|
50
|
-
try { this.#socket.end(frame(0x8, Buffer.alloc(0))); } catch { /* already gone */ }
|
|
51
|
-
}
|
|
52
|
-
this.#onClose();
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
#onClose(): void {
|
|
56
|
-
this.#closed = true;
|
|
57
|
-
if (this.#reported) return;
|
|
58
|
-
this.#reported = true;
|
|
59
|
-
this.dispatchEvent(new Event('close'));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
#onData(chunk: Buffer): void {
|
|
63
|
-
if (process.env.REMOTECTL_DEBUG) console.log('[ws] data', chunk.length, 'bytes');
|
|
64
|
-
this.#buffer = Buffer.concat([this.#buffer, chunk]);
|
|
65
|
-
|
|
66
|
-
for (;;) {
|
|
67
|
-
const parsed = readFrame(this.#buffer);
|
|
68
|
-
if (!parsed) return;
|
|
69
|
-
this.#buffer = parsed.rest;
|
|
70
|
-
|
|
71
|
-
if (parsed.opcode === 0x8) { this.close(); return; }
|
|
72
|
-
if (parsed.opcode === 0x9) { this.#socket.write(frame(0xa, parsed.payload)); continue; }
|
|
73
|
-
if (parsed.opcode === 0x1) {
|
|
74
|
-
this.dispatchEvent(new MessageEvent('message', { data: parsed.payload.toString('utf8') }));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Server frames are never masked, and the viewer sends nothing large. */
|
|
81
|
-
function frame(opcode: number, payload: Buffer): Buffer {
|
|
82
|
-
const length = payload.length;
|
|
83
|
-
let header: Buffer;
|
|
84
|
-
|
|
85
|
-
if (length < 126) {
|
|
86
|
-
header = Buffer.from([0x80 | opcode, length]);
|
|
87
|
-
} else if (length < 65536) {
|
|
88
|
-
header = Buffer.alloc(4);
|
|
89
|
-
header[0] = 0x80 | opcode;
|
|
90
|
-
header[1] = 126;
|
|
91
|
-
header.writeUInt16BE(length, 2);
|
|
92
|
-
} else {
|
|
93
|
-
header = Buffer.alloc(10);
|
|
94
|
-
header[0] = 0x80 | opcode;
|
|
95
|
-
header[1] = 127;
|
|
96
|
-
header.writeBigUInt64BE(BigInt(length), 2);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
return Buffer.concat([header, payload]);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function readFrame(buffer: Buffer) {
|
|
103
|
-
if (buffer.length < 2) return null;
|
|
104
|
-
|
|
105
|
-
const opcode = buffer[0] & 0x0f;
|
|
106
|
-
const masked = (buffer[1] & 0x80) !== 0;
|
|
107
|
-
let length = buffer[1] & 0x7f;
|
|
108
|
-
let offset = 2;
|
|
109
|
-
|
|
110
|
-
if (length === 126) {
|
|
111
|
-
if (buffer.length < 4) return null;
|
|
112
|
-
length = buffer.readUInt16BE(2);
|
|
113
|
-
offset = 4;
|
|
114
|
-
} else if (length === 127) {
|
|
115
|
-
if (buffer.length < 10) return null;
|
|
116
|
-
length = Number(buffer.readBigUInt64BE(2));
|
|
117
|
-
offset = 10;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const maskKey = masked ? buffer.subarray(offset, offset + 4) : null;
|
|
121
|
-
if (masked) offset += 4;
|
|
122
|
-
if (buffer.length < offset + length) return null;
|
|
123
|
-
|
|
124
|
-
const payload = Buffer.from(buffer.subarray(offset, offset + length));
|
|
125
|
-
if (maskKey) for (let i = 0; i < payload.length; i++) payload[i] ^= maskKey[i % 4];
|
|
126
|
-
|
|
127
|
-
return { opcode, payload, rest: buffer.subarray(offset + length) };
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Complete the HTTP upgrade a browser sends, and hand back the peer. */
|
|
131
|
-
export function accept(request: IncomingMessage, socket: net.Socket): WebSocketPeer | null {
|
|
132
|
-
const key = request.headers['sec-websocket-key'];
|
|
133
|
-
if (typeof key !== 'string') { socket.destroy(); return null; }
|
|
134
|
-
|
|
135
|
-
const acceptKey = createHash('sha1').update(key + GUID).digest('base64');
|
|
136
|
-
socket.write(
|
|
137
|
-
'HTTP/1.1 101 Switching Protocols\r\n'
|
|
138
|
-
+ 'Upgrade: websocket\r\n'
|
|
139
|
-
+ 'Connection: Upgrade\r\n'
|
|
140
|
-
+ `Sec-WebSocket-Accept: ${acceptKey}\r\n\r\n`
|
|
141
|
-
);
|
|
142
|
-
socket.setNoDelay(true);
|
|
143
|
-
return new WebSocketPeer(socket);
|
|
144
|
-
}
|
package/src/protocols/index.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The protocol registry the workspace talks to.
|
|
3
|
-
*
|
|
4
|
-
* The UI owns the toolbar, the dialogs, the stats rail and the on-screen
|
|
5
|
-
* keyboard, none of which care whether a session is VNC or RDP. Everything that
|
|
6
|
-
* does differ -- the endpoint, how a client is constructed, the wording of a
|
|
7
|
-
* failure, the labels in the connect dialog -- is looked up here instead of
|
|
8
|
-
* branching at each site.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { rdpDriver } from './rdp/session';
|
|
12
|
-
import { vncDriver } from './vnc/session';
|
|
13
|
-
import type { DisconnectContext, ProtocolDriver } from './types';
|
|
14
|
-
import type { RemoteProtocol } from '../shared/protocol';
|
|
15
|
-
|
|
16
|
-
export type { ProtocolDriver, RemoteSession, SessionOptions, ScreenGeometry } from './types';
|
|
17
|
-
|
|
18
|
-
export const DRIVERS: Record<RemoteProtocol, ProtocolDriver> = {
|
|
19
|
-
vnc: vncDriver,
|
|
20
|
-
rdp: rdpDriver,
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** Unknown names fall back to VNC, which is what the dialog opens on. */
|
|
24
|
-
export function driverFor(protocol: string | undefined): ProtocolDriver {
|
|
25
|
-
return DRIVERS[protocol as RemoteProtocol] ?? DRIVERS.vnc;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** What the workspace knows about a session that has just ended. */
|
|
29
|
-
export interface DisconnectDiagnostic {
|
|
30
|
-
protocol: RemoteProtocol;
|
|
31
|
-
/** The transport close, when one arrived. A 1006 means it did not. */
|
|
32
|
-
close: { code: number; reason: string } | null;
|
|
33
|
-
/** True once the session had reached the desktop. */
|
|
34
|
-
established: boolean;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Explain a disconnect in terms someone can act on. The protocol gets first
|
|
39
|
-
* say, then the close codes that mean the same thing either way.
|
|
40
|
-
*/
|
|
41
|
-
export function explainDisconnect(
|
|
42
|
-
diagnostic: DisconnectDiagnostic,
|
|
43
|
-
clean: boolean,
|
|
44
|
-
protocolMessage: string
|
|
45
|
-
): string {
|
|
46
|
-
// A client that reports its own failure states it in the protocol's terms,
|
|
47
|
-
// which is always more specific than anything the transport can say.
|
|
48
|
-
if (protocolMessage) return protocolMessage;
|
|
49
|
-
|
|
50
|
-
const code = diagnostic.close?.code;
|
|
51
|
-
const reason = diagnostic.close?.reason.trim() ?? '';
|
|
52
|
-
const suffix = code ? ` (WebSocket ${code}${reason ? `: ${reason}` : ''})` : '';
|
|
53
|
-
const context: DisconnectContext = { ...(code === undefined ? {} : { code }), reason, suffix };
|
|
54
|
-
|
|
55
|
-
const driver = driverFor(diagnostic.protocol);
|
|
56
|
-
const specific = driver.describeDisconnect(context);
|
|
57
|
-
if (specific) return specific;
|
|
58
|
-
|
|
59
|
-
const name = driver.protocol.toUpperCase();
|
|
60
|
-
if (/tcp (reached )?eof/i.test(reason)) {
|
|
61
|
-
return `The ${name} host accepted the connection and then closed it${suffix}. `
|
|
62
|
-
+ 'Check its remote-access permissions and the account name.';
|
|
63
|
-
}
|
|
64
|
-
if (code === 1006) {
|
|
65
|
-
return 'The connection was interrupted before a close reason arrived (WebSocket 1006). '
|
|
66
|
-
+ 'This can happen during a deployment or a brief network change. Automatic reconnect '
|
|
67
|
-
+ 'did not restore the session; reconnect once, then check Worker live logs if it repeats.';
|
|
68
|
-
}
|
|
69
|
-
if (reason) return `The remote session ended${suffix}.`;
|
|
70
|
-
if (clean) return `The remote server closed the session cleanly${suffix || ' (no reason supplied)'}.`;
|
|
71
|
-
return `The ${name} negotiation failed${suffix || ' before the server supplied a close reason'}.`;
|
|
72
|
-
}
|
|
@@ -1,204 +0,0 @@
|
|
|
1
|
-
// Binary reader/writer used by every RDP layer. RDP mixes endianness freely --
|
|
2
|
-
// TPKT and MCS are big endian, everything above them is little endian -- so the
|
|
3
|
-
// byte order is part of each call rather than a mode on the object.
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Plain `Uint8Array` throughout. Pinning the buffer type parameter would be
|
|
7
|
-
* more precise, but every layer above this one passes byte slices around and
|
|
8
|
-
* the variance between `ArrayBuffer` and `ArrayBufferLike` then has to be
|
|
9
|
-
* restated at each boundary for no benefit the protocol code can use.
|
|
10
|
-
*/
|
|
11
|
-
function allocate(length: number): Uint8Array {
|
|
12
|
-
return new Uint8Array(length);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export class Writer {
|
|
16
|
-
private bytes: Uint8Array;
|
|
17
|
-
private view: DataView;
|
|
18
|
-
private offset = 0;
|
|
19
|
-
|
|
20
|
-
constructor(capacity = 512) {
|
|
21
|
-
this.bytes = allocate(capacity);
|
|
22
|
-
this.view = new DataView(this.bytes.buffer);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
#ensure(extra: number): void {
|
|
26
|
-
if (this.offset + extra <= this.bytes.length) return;
|
|
27
|
-
let capacity = this.bytes.length * 2;
|
|
28
|
-
while (capacity < this.offset + extra) capacity *= 2;
|
|
29
|
-
const grown = allocate(capacity);
|
|
30
|
-
grown.set(this.bytes.subarray(0, this.offset));
|
|
31
|
-
this.bytes = grown;
|
|
32
|
-
this.view = new DataView(grown.buffer);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
u8(value: number): this {
|
|
36
|
-
this.#ensure(1);
|
|
37
|
-
this.view.setUint8(this.offset, value);
|
|
38
|
-
this.offset += 1;
|
|
39
|
-
return this;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
u16le(value: number): this {
|
|
43
|
-
this.#ensure(2);
|
|
44
|
-
this.view.setUint16(this.offset, value, true);
|
|
45
|
-
this.offset += 2;
|
|
46
|
-
return this;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
u16be(value: number): this {
|
|
50
|
-
this.#ensure(2);
|
|
51
|
-
this.view.setUint16(this.offset, value, false);
|
|
52
|
-
this.offset += 2;
|
|
53
|
-
return this;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
u32le(value: number): this {
|
|
57
|
-
this.#ensure(4);
|
|
58
|
-
this.view.setUint32(this.offset, value >>> 0, true);
|
|
59
|
-
this.offset += 4;
|
|
60
|
-
return this;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
u32be(value: number): this {
|
|
64
|
-
this.#ensure(4);
|
|
65
|
-
this.view.setUint32(this.offset, value >>> 0, false);
|
|
66
|
-
this.offset += 4;
|
|
67
|
-
return this;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
raw(bytes: Uint8Array): this {
|
|
71
|
-
this.#ensure(bytes.length);
|
|
72
|
-
this.bytes.set(bytes, this.offset);
|
|
73
|
-
this.offset += bytes.length;
|
|
74
|
-
return this;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
zeros(count: number): this {
|
|
78
|
-
this.#ensure(count);
|
|
79
|
-
this.offset += count;
|
|
80
|
-
return this;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** UTF-16LE, optionally padded or truncated to a fixed byte width. */
|
|
84
|
-
utf16le(text: string, fixedBytes?: number): this {
|
|
85
|
-
const start = this.offset;
|
|
86
|
-
for (const unit of text) {
|
|
87
|
-
const code = unit.codePointAt(0) ?? 0;
|
|
88
|
-
// Fixed-width fields must keep room for the terminating NUL.
|
|
89
|
-
if (fixedBytes !== undefined && this.offset - start >= fixedBytes - 2) break;
|
|
90
|
-
if (code > 0xffff) {
|
|
91
|
-
// Surrogate pair: both halves are already what UTF-16 needs.
|
|
92
|
-
for (let i = 0; i < unit.length; i++) this.u16le(unit.charCodeAt(i));
|
|
93
|
-
} else {
|
|
94
|
-
this.u16le(code);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
if (fixedBytes !== undefined) {
|
|
98
|
-
this.zeros(fixedBytes - (this.offset - start));
|
|
99
|
-
}
|
|
100
|
-
return this;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Overwrite an earlier 16-bit slot, for length fields known only later. */
|
|
104
|
-
patchU16le(position: number, value: number): this {
|
|
105
|
-
this.view.setUint16(position, value, true);
|
|
106
|
-
return this;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
patchU16be(position: number, value: number): this {
|
|
110
|
-
this.view.setUint16(position, value, false);
|
|
111
|
-
return this;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
get length(): number {
|
|
115
|
-
return this.offset;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
take(): Uint8Array {
|
|
119
|
-
return this.bytes.slice(0, this.offset);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export class Reader {
|
|
124
|
-
private readonly bytes: Uint8Array;
|
|
125
|
-
private readonly view: DataView;
|
|
126
|
-
offset: number;
|
|
127
|
-
|
|
128
|
-
constructor(bytes: Uint8Array, offset = 0) {
|
|
129
|
-
this.bytes = bytes;
|
|
130
|
-
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
131
|
-
this.offset = offset;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
get remaining(): number {
|
|
135
|
-
return this.bytes.length - this.offset;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
#need(count: number): void {
|
|
139
|
-
if (this.remaining < count) {
|
|
140
|
-
throw new Error(`RDP: truncated frame (needed ${count} bytes, ${this.remaining} left)`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
u8(): number {
|
|
145
|
-
this.#need(1);
|
|
146
|
-
return this.view.getUint8(this.offset++);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
u16le(): number {
|
|
150
|
-
this.#need(2);
|
|
151
|
-
const value = this.view.getUint16(this.offset, true);
|
|
152
|
-
this.offset += 2;
|
|
153
|
-
return value;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
u16be(): number {
|
|
157
|
-
this.#need(2);
|
|
158
|
-
const value = this.view.getUint16(this.offset, false);
|
|
159
|
-
this.offset += 2;
|
|
160
|
-
return value;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
u32le(): number {
|
|
164
|
-
this.#need(4);
|
|
165
|
-
const value = this.view.getUint32(this.offset, true);
|
|
166
|
-
this.offset += 4;
|
|
167
|
-
return value;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
u32be(): number {
|
|
171
|
-
this.#need(4);
|
|
172
|
-
const value = this.view.getUint32(this.offset, false);
|
|
173
|
-
this.offset += 4;
|
|
174
|
-
return value;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
raw(count: number): Uint8Array {
|
|
178
|
-
this.#need(count);
|
|
179
|
-
const slice = this.bytes.subarray(this.offset, this.offset + count);
|
|
180
|
-
this.offset += count;
|
|
181
|
-
return slice;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
skip(count: number): this {
|
|
185
|
-
this.#need(count);
|
|
186
|
-
this.offset += count;
|
|
187
|
-
return this;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
rest(): Uint8Array {
|
|
191
|
-
return this.bytes.subarray(this.offset);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
export function concatBytes(...parts: readonly Uint8Array[]): Uint8Array {
|
|
196
|
-
const total = parts.reduce((sum, part) => sum + part.length, 0);
|
|
197
|
-
const out = allocate(total);
|
|
198
|
-
let offset = 0;
|
|
199
|
-
for (const part of parts) {
|
|
200
|
-
out.set(part, offset);
|
|
201
|
-
offset += part.length;
|
|
202
|
-
}
|
|
203
|
-
return out;
|
|
204
|
-
}
|