ai-remote 0.1.0 → 0.3.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/dist/cli.mjs +8065 -0
- package/dist/index.js +6 -6
- package/dist/protocols/index.js +2 -2
- package/dist/protocols/rdp/caps.js +1 -1
- package/dist/protocols/rdp/cert.js +1 -1
- package/dist/protocols/rdp/client.js +18 -16
- package/dist/protocols/rdp/client.js.map +2 -2
- package/dist/protocols/rdp/cliprdr.js +1 -1
- package/dist/protocols/rdp/credssp.js +2 -2
- package/dist/protocols/rdp/display.js +2 -2
- package/dist/protocols/rdp/gcc.js +2 -2
- package/dist/protocols/rdp/mcs.js +2 -2
- package/dist/protocols/rdp/ntlm.js +2 -2
- package/dist/protocols/rdp/pdu.js +1 -1
- package/dist/protocols/rdp/rail.js +1 -1
- package/dist/protocols/rdp/sec.js +3 -3
- package/dist/protocols/rdp/session.js +3 -3
- package/dist/protocols/rdp/tls.js +3 -3
- package/dist/protocols/rdp/vchannel.js +1 -1
- package/dist/protocols/rdp/x224.js +1 -1
- package/dist/protocols/ssh/kex.js +1 -1
- package/dist/protocols/ssh/session.js +4 -3
- package/dist/protocols/ssh/session.js.map +2 -2
- package/dist/protocols/ssh/transport.js +8 -6
- package/dist/protocols/ssh/transport.js.map +2 -2
- package/dist/protocols/vnc/session.js +3 -3
- package/dist/shared/connection.js +1 -1
- package/dist/shared/hosts.js +1 -1
- package/package.json +13 -3
- package/src/cli/cli.ts +451 -0
- package/src/cli/daemon.ts +291 -0
- package/src/cli/framebuffer.ts +115 -0
- package/src/cli/generated/viewer-bundle.ts +5 -0
- package/src/cli/ipc.ts +78 -0
- package/src/cli/paths.ts +28 -0
- package/src/cli/png.ts +106 -0
- package/src/cli/session.ts +263 -0
- package/src/cli/shell.ts +171 -0
- package/src/cli/transport.ts +86 -0
- package/src/cli/viewer-client/assets.d.ts +4 -0
- package/src/cli/viewer-client/main.ts +300 -0
- package/src/cli/viewer-page.ts +102 -0
- package/src/cli/viewer.ts +269 -0
- package/src/cli/wsserver.ts +144 -0
- package/src/protocols/rdp/client.ts +9 -1
- package/src/protocols/ssh/session.ts +1 -0
- package/src/protocols/ssh/transport.ts +15 -2
|
@@ -0,0 +1,269 @@
|
|
|
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({ type: 'shell-error', message: 'This session was started without a terminal. Restart it with --ssh-user NAME.' }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (detachShell.has(peer)) return;
|
|
83
|
+
|
|
84
|
+
peer.send(JSON.stringify({ type: 'shell-status', state: 'opening' }));
|
|
85
|
+
let shell: Shell;
|
|
86
|
+
try {
|
|
87
|
+
shell = await openShell();
|
|
88
|
+
} catch (error) {
|
|
89
|
+
peer.send(JSON.stringify({
|
|
90
|
+
type: 'shell-error',
|
|
91
|
+
message: error instanceof Error ? error.message : String(error),
|
|
92
|
+
}));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const history = shell.scrollback;
|
|
97
|
+
if (history.length) {
|
|
98
|
+
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(history).toString('base64') }));
|
|
99
|
+
}
|
|
100
|
+
peer.send(JSON.stringify({ type: 'shell-status', state: 'open' }));
|
|
101
|
+
|
|
102
|
+
detachShell.set(peer, shell.onData((bytes) => {
|
|
103
|
+
peer.send(JSON.stringify({ type: 'shell-data', base64: Buffer.from(bytes).toString('base64') }));
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
shellFor.set(peer, shell);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const shellFor = new Map<WebSocketPeer, Shell>();
|
|
110
|
+
|
|
111
|
+
let resolveOpened: () => void;
|
|
112
|
+
const opened = new Promise<void>((resolve) => { resolveOpened = resolve; });
|
|
113
|
+
|
|
114
|
+
const server = createServer((request, response) => {
|
|
115
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
116
|
+
if (url.pathname === '/' && url.searchParams.get('t') === token) {
|
|
117
|
+
response.writeHead(200, {
|
|
118
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
119
|
+
'Cache-Control': 'no-store',
|
|
120
|
+
});
|
|
121
|
+
response.end(PAGE);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
125
|
+
response.end('Not found');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.on('upgrade', (request, socket) => {
|
|
129
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
130
|
+
if (url.searchParams.get('t') !== token) { socket.destroy(); return; }
|
|
131
|
+
|
|
132
|
+
// Node types an upgrade socket as Duplex; every runtime it has is a
|
|
133
|
+
// net.Socket, which is what lets the peer turn Nagle off.
|
|
134
|
+
const peer = accept(request, socket as net.Socket);
|
|
135
|
+
if (!peer) return;
|
|
136
|
+
peers.add(peer);
|
|
137
|
+
resolveOpened();
|
|
138
|
+
|
|
139
|
+
const { width, height } = session.framebuffer;
|
|
140
|
+
peer.send(JSON.stringify({ type: 'hello', width, height, host: session.options.host }));
|
|
141
|
+
if (width && height) {
|
|
142
|
+
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
peer.addEventListener('message', (event: Event) => {
|
|
146
|
+
const raw = (event as MessageEvent).data as string;
|
|
147
|
+
if (process.env.REMOTECTL_DEBUG) console.log('[viewer] <-', raw);
|
|
148
|
+
try {
|
|
149
|
+
const message = JSON.parse(raw);
|
|
150
|
+
|
|
151
|
+
if (message.type === 'control') {
|
|
152
|
+
if (message.on) drivers.add(peer); else drivers.delete(peer);
|
|
153
|
+
peer.send(JSON.stringify({ type: 'control', on: Boolean(message.on) }));
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (message.type === 'shell-attach') { void attachShell(peer); return; }
|
|
158
|
+
|
|
159
|
+
if (!drivers.has(peer)) return;
|
|
160
|
+
|
|
161
|
+
if (message.type === 'shell-input') {
|
|
162
|
+
shellFor.get(peer)?.write(String(message.text ?? ''));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (message.type === 'shell-resize') {
|
|
166
|
+
shellFor.get(peer)?.resize(Number(message.columns) || 120, Number(message.rows) || 30);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
applyInput(session, message);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
// A malformed frame from a viewer must never take the session down.
|
|
173
|
+
console.warn('[viewer] dropped an unreadable message', error);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
peer.addEventListener('close', () => {
|
|
177
|
+
peers.delete(peer);
|
|
178
|
+
detachShell.get(peer)?.();
|
|
179
|
+
detachShell.delete(peer);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Loopback only. The viewer carries a live desktop and takes input for it.
|
|
185
|
+
*
|
|
186
|
+
* A busy port is not a reason to fail: a second session, or a stale process
|
|
187
|
+
* holding the default, would otherwise take the whole connection down. The
|
|
188
|
+
* chosen port is reported back, so nothing has to guess which one it got.
|
|
189
|
+
*/
|
|
190
|
+
const bound = await new Promise<number>((resolve, reject) => {
|
|
191
|
+
const listen = (candidate: number, retry: boolean) => {
|
|
192
|
+
server.once('error', (error: NodeJS.ErrnoException) => {
|
|
193
|
+
if (retry && error.code === 'EADDRINUSE') { listen(0, false); return; }
|
|
194
|
+
reject(error);
|
|
195
|
+
});
|
|
196
|
+
server.listen(candidate, '127.0.0.1', () => {
|
|
197
|
+
const address = server.address();
|
|
198
|
+
resolve(typeof address === 'object' && address ? address.port : candidate);
|
|
199
|
+
});
|
|
200
|
+
};
|
|
201
|
+
listen(port, true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
session.onResize((width, height) => {
|
|
205
|
+
for (const peer of peers) {
|
|
206
|
+
peer.send(JSON.stringify({ type: 'resize', width, height }));
|
|
207
|
+
peer.send(encodeRect(1, 0, 0, width, height, session.framebuffer.pixels));
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
session.onPaint((rects) => {
|
|
212
|
+
if (!peers.size) return;
|
|
213
|
+
for (const rect of rects) {
|
|
214
|
+
const payload = encodeRect(2, rect.left, rect.top, rect.width, rect.height, rect.rgba);
|
|
215
|
+
for (const peer of peers) peer.send(payload);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const url = `http://127.0.0.1:${bound}/?t=${token}`;
|
|
220
|
+
if (launch) open(url);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
url,
|
|
224
|
+
opened,
|
|
225
|
+
launch() { open(url); },
|
|
226
|
+
close() {
|
|
227
|
+
for (const peer of peers) peer.close();
|
|
228
|
+
server.close();
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyInput(session: RdpSession, message: any): void {
|
|
234
|
+
switch (message.type) {
|
|
235
|
+
case 'move':
|
|
236
|
+
session.movePointer(message.x, message.y);
|
|
237
|
+
break;
|
|
238
|
+
case 'button':
|
|
239
|
+
session.pointerButton(message.x, message.y, message.button ?? 0, Boolean(message.pressed));
|
|
240
|
+
break;
|
|
241
|
+
case 'scroll':
|
|
242
|
+
session.scroll(message.x, message.y, message.dx ?? 0, message.dy ?? 0);
|
|
243
|
+
break;
|
|
244
|
+
case 'key':
|
|
245
|
+
session.sendKeyCode(message.code, Boolean(message.pressed));
|
|
246
|
+
break;
|
|
247
|
+
case 'text':
|
|
248
|
+
session.typeText(String(message.text ?? ''));
|
|
249
|
+
break;
|
|
250
|
+
case 'cad':
|
|
251
|
+
session.sendCtrlAltDel();
|
|
252
|
+
break;
|
|
253
|
+
default:
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Pop the window. A failure here is not a failure of the session. */
|
|
259
|
+
function open(url: string): void {
|
|
260
|
+
const command = process.platform === 'darwin' ? 'open'
|
|
261
|
+
: process.platform === 'win32' ? 'cmd'
|
|
262
|
+
: 'xdg-open';
|
|
263
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
264
|
+
try {
|
|
265
|
+
spawn(command, args, { stdio: 'ignore', detached: true }).unref();
|
|
266
|
+
} catch {
|
|
267
|
+
// Headless, or no browser. The URL was printed; that is enough.
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
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
|
+
}
|
|
@@ -150,6 +150,8 @@ export class RdpClient extends EventTarget {
|
|
|
150
150
|
keyboardLayout: 0x409,
|
|
151
151
|
clientName: 'cf-gateway',
|
|
152
152
|
security: 'auto',
|
|
153
|
+
/** Returns a WebSocket-shaped transport for `url`; default is a real one. */
|
|
154
|
+
openTransport: null,
|
|
153
155
|
clipboard: true,
|
|
154
156
|
remoteApp: null,
|
|
155
157
|
performance: PERF_DISABLE_WALLPAPER | PERF_DISABLE_FULLWINDOWDRAG | PERF_DISABLE_MENUANIMATIONS,
|
|
@@ -231,7 +233,13 @@ export class RdpClient extends EventTarget {
|
|
|
231
233
|
this.remoteAppFitTimer = null;
|
|
232
234
|
this.remoteAppCloseTimer = null;
|
|
233
235
|
|
|
234
|
-
this.
|
|
236
|
+
// The transport is injected so this client can run anywhere. In a browser
|
|
237
|
+
// it is a WebSocket to the gateway's relay; in the CLI it is a TCP socket
|
|
238
|
+
// straight at the host. Everything above it -- X.224, TLS, CredSSP, MCS --
|
|
239
|
+
// is the same either way, which is the whole reason it is a seam.
|
|
240
|
+
this.socket = this.options.openTransport
|
|
241
|
+
? this.options.openTransport(this.url)
|
|
242
|
+
: new WebSocket(this.url);
|
|
235
243
|
this.socket.binaryType = 'arraybuffer';
|
|
236
244
|
this.socket.addEventListener('open', () => this.#onOpen());
|
|
237
245
|
this.socket.addEventListener('message', (event) => this.#onMessage(event));
|
|
@@ -255,6 +255,7 @@ export class SshSession extends EventTarget {
|
|
|
255
255
|
|
|
256
256
|
this.transport = new SshTransport(url, {
|
|
257
257
|
verifyHost: options.verifyHost || (async () => true),
|
|
258
|
+
openTransport: options.openTransport || null,
|
|
258
259
|
log: (step: number, detail) => console.info(`[SSH] ${step}`, detail),
|
|
259
260
|
});
|
|
260
261
|
|
|
@@ -87,6 +87,7 @@ export class SshTransport extends EventTarget {
|
|
|
87
87
|
verifyHost: (info: any) => Promise<boolean> | boolean;
|
|
88
88
|
log: (...args: any[]) => void;
|
|
89
89
|
socket: WebSocket | null;
|
|
90
|
+
openTransport: ((url: string) => any) | null;
|
|
90
91
|
closed: boolean;
|
|
91
92
|
buffer: Uint8Array;
|
|
92
93
|
serverVersion: string;
|
|
@@ -113,12 +114,21 @@ export class SshTransport extends EventTarget {
|
|
|
113
114
|
* @param {(info: object) => Promise<boolean>} options.verifyHost decides
|
|
114
115
|
* whether an unknown or changed host key may be used.
|
|
115
116
|
*/
|
|
116
|
-
constructor(
|
|
117
|
+
constructor(
|
|
118
|
+
url: string,
|
|
119
|
+
{ verifyHost, log, openTransport }: {
|
|
120
|
+
verifyHost?: (info: any) => Promise<boolean> | boolean;
|
|
121
|
+
log?: (...args: any[]) => void;
|
|
122
|
+
/** Returns a WebSocket-shaped transport for `url`; default is a real one. */
|
|
123
|
+
openTransport?: ((url: string) => any) | null;
|
|
124
|
+
} = {}
|
|
125
|
+
) {
|
|
117
126
|
super();
|
|
118
127
|
|
|
119
128
|
this.url = url;
|
|
120
129
|
this.verifyHost = verifyHost || (() => true);
|
|
121
130
|
this.log = log || (() => {});
|
|
131
|
+
this.openTransport = openTransport || null;
|
|
122
132
|
|
|
123
133
|
this.socket = null;
|
|
124
134
|
this.closed = false;
|
|
@@ -142,7 +152,10 @@ export class SshTransport extends EventTarget {
|
|
|
142
152
|
// --- connection ----------------------------------------------------------
|
|
143
153
|
|
|
144
154
|
connect() {
|
|
145
|
-
this
|
|
155
|
+
// Injected so this client can run anywhere: a WebSocket to the gateway in
|
|
156
|
+
// a browser, a TCP socket straight at the host in the CLI. The key
|
|
157
|
+
// exchange and everything above it is identical either way.
|
|
158
|
+
this.socket = this.openTransport ? this.openTransport(this.url) : new WebSocket(this.url);
|
|
146
159
|
this.socket.binaryType = 'arraybuffer';
|
|
147
160
|
|
|
148
161
|
this.socket.addEventListener('open', () => {
|