tabby-rdp 0.4.1 → 0.5.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/CHANGELOG.md +580 -0
- package/README.md +252 -34
- package/dist/accountForm.js +0 -1
- package/dist/accounts.js +0 -1
- package/dist/audio.js +0 -1
- package/dist/authorities.js +331 -0
- package/dist/byteQueue.js +102 -0
- package/dist/clipboard.js +106 -0
- package/dist/connectionStatus.js +0 -1
- package/dist/desk.js +16 -47
- package/dist/deskScript.js +196 -6
- package/dist/desktop.service.js +1763 -204
- package/dist/desktopForm.js +72 -13
- package/dist/desktops.js +243 -19
- package/dist/drives.js +525 -0
- package/dist/fileTransfer.js +1064 -72
- package/dist/gateway.js +651 -0
- package/dist/header.js +76 -5
- package/dist/help.js +34 -13
- package/dist/hyperv.js +129 -0
- package/dist/index.js +2 -2
- package/dist/keyboard.js +0 -1
- package/dist/microphone.js +57 -13
- package/dist/ntlm.js +225 -0
- package/dist/osd.js +56 -15
- package/dist/probe.js +15 -3
- package/dist/rdcleanpath.js +468 -52
- package/dist/rdpFile.js +53 -4
- package/dist/rdpProfile.js +75 -22
- package/dist/remoteSetup.js +103 -23
- package/dist/sessionLog.js +53 -0
- package/dist/settingsPage.js +117 -27
- package/dist/signin.js +429 -54
- package/dist/ssh.js +99 -18
- package/dist/sshChannelStream.js +50 -6
- package/dist/targets.js +185 -39
- package/dist/ui.js +98 -20
- package/dist/unshowable.js +41 -0
- package/dist/updates.js +37 -2
- package/dist/vms.js +36 -5
- package/dist/wake.js +31 -12
- package/package.json +4 -3
- package/remote/trd-pty.py +74 -13
- package/vendor/SHA256SUMS +26 -0
- package/vendor/iron-remote-desktop-rdp.js +418 -390
- package/vendor/ironrdp_web_bg.wasm +0 -0
- package/dist/accountForm.js.map +0 -1
- package/dist/accounts.js.map +0 -1
- package/dist/audio.js.map +0 -1
- package/dist/connectionStatus.js.map +0 -1
- package/dist/desk.js.map +0 -1
- package/dist/deskScript.js.map +0 -1
- package/dist/desktop.service.js.map +0 -1
- package/dist/desktopForm.js.map +0 -1
- package/dist/desktops.js.map +0 -1
- package/dist/fileTransfer.js.map +0 -1
- package/dist/header.js.map +0 -1
- package/dist/help.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/keyboard.js.map +0 -1
- package/dist/microphone.js.map +0 -1
- package/dist/osd.js.map +0 -1
- package/dist/probe.js.map +0 -1
- package/dist/rdcleanpath.js.map +0 -1
- package/dist/rdpFile.js.map +0 -1
- package/dist/rdpProfile.js.map +0 -1
- package/dist/remoteSetup.js.map +0 -1
- package/dist/settingsPage.js.map +0 -1
- package/dist/signin.js.map +0 -1
- package/dist/ssh.js.map +0 -1
- package/dist/sshChannelStream.js.map +0 -1
- package/dist/targets.js.map +0 -1
- package/dist/ui.js.map +0 -1
- package/dist/updates.js.map +0 -1
- package/dist/vms.js.map +0 -1
- package/dist/wake.js.map +0 -1
package/dist/gateway.js
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GatewaySignInError = void 0;
|
|
4
|
+
exports.parseGateway = parseGateway;
|
|
5
|
+
exports.formatGateway = formatGateway;
|
|
6
|
+
exports.handshakeRequest = handshakeRequest;
|
|
7
|
+
exports.tunnelCreate = tunnelCreate;
|
|
8
|
+
exports.tunnelAuth = tunnelAuth;
|
|
9
|
+
exports.channelCreate = channelCreate;
|
|
10
|
+
exports.dataPacket = dataPacket;
|
|
11
|
+
exports.gatewayError = gatewayError;
|
|
12
|
+
exports.packetReader = packetReader;
|
|
13
|
+
exports.wsFrame = wsFrame;
|
|
14
|
+
exports.pongs = pongs;
|
|
15
|
+
exports.wsReader = wsReader;
|
|
16
|
+
exports.endPointBinding = endPointBinding;
|
|
17
|
+
exports.openThroughGateway = openThroughGateway;
|
|
18
|
+
const crypto_1 = require("crypto");
|
|
19
|
+
const net_1 = require("net");
|
|
20
|
+
const stream_1 = require("stream");
|
|
21
|
+
const tls = require("tls");
|
|
22
|
+
const url_1 = require("url");
|
|
23
|
+
const authorities_1 = require("./authorities");
|
|
24
|
+
const byteQueue_1 = require("./byteQueue");
|
|
25
|
+
const ntlm_1 = require("./ntlm");
|
|
26
|
+
/**
|
|
27
|
+
* A gateway's host in one canonical form, so its trust key, its credential scope, its SNI and the way it is shown all
|
|
28
|
+
* agree on it and an alternate spelling of the same gateway isn't taken for another one: an IPv6 literal compressed
|
|
29
|
+
* (so `[0:0::1]` and `[::1]` are one), any other host as the name it resolves to (lower case, international letters as
|
|
30
|
+
* punycode, one trailing dot dropped). Null for what names no host: an IPv6 literal that isn't one, an empty label
|
|
31
|
+
* (`gw..example`, or a second trailing dot), a label over 63 characters or a name over 253, and a name with a
|
|
32
|
+
* character no host name has (`#`, `?`, `@`, `%`, quotes, …), which the URL parser behind domainToASCII would otherwise
|
|
33
|
+
* read as the end of the name or decode (`gw.example#x` as `gw.example`, `a%41` as `aa`) rather than refuse.
|
|
34
|
+
*/
|
|
35
|
+
function canonicalGatewayHost(bracketed, bare) {
|
|
36
|
+
if (bracketed !== undefined) {
|
|
37
|
+
try {
|
|
38
|
+
return new URL(`http://[${bracketed}]`).hostname.replace(/^\[|\]$/g, '');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Letters of any script go to IDNA, which refuses what isn't a name; of ASCII, only what a host name is made of.
|
|
45
|
+
if (/[^A-Za-z0-9._-]/.test((bare ?? '').replace(/[^\x00-\x7f]/g, ''))) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const host = (0, url_1.domainToASCII)((bare ?? '').replace(/\.$/, ''));
|
|
49
|
+
return host && host.length <= 253 && host.split('.').every(label => label.length > 0 && label.length <= 63) ? host : null;
|
|
50
|
+
}
|
|
51
|
+
/** A `host` or `host:port` setting as a gateway (443 by default), or null if it isn't one. The host is canonical. */
|
|
52
|
+
function parseGateway(value) {
|
|
53
|
+
// Text only, as specOf reads a gateway: String() of an object from the config runs its own conversions, which can throw.
|
|
54
|
+
const m = /^\s*(?:\[([0-9a-f:.]+)\]|([^\s:/]+))(?::(\d{1,5}))?\s*$/i.exec(typeof value === 'string' ? value : '');
|
|
55
|
+
const port = Number(m?.[3] ?? 443);
|
|
56
|
+
const host = m && canonicalGatewayHost(m[1], m[2]);
|
|
57
|
+
return host && port > 0 && port < 65536 ? { host, port } : null;
|
|
58
|
+
}
|
|
59
|
+
/** A gateway as a setting writes it: `host`, or `host:port` when that isn't 443 (an IPv6 address in brackets). */
|
|
60
|
+
function formatGateway(gateway) {
|
|
61
|
+
const host = gateway.host.includes(':') ? `[${gateway.host}]` : gateway.host;
|
|
62
|
+
return gateway.port === 443 ? host : `${host}:${gateway.port}`;
|
|
63
|
+
}
|
|
64
|
+
/** The gateway refused the account (its HTTP sign-in, or its connection authorization policy). */
|
|
65
|
+
class GatewaySignInError extends Error {
|
|
66
|
+
}
|
|
67
|
+
exports.GatewaySignInError = GatewaySignInError;
|
|
68
|
+
// ---- packets ([MS-TSGU] 2.2.10) ------------------------------------------------------------------
|
|
69
|
+
const PKT_HANDSHAKE_REQUEST = 0x01;
|
|
70
|
+
const PKT_HANDSHAKE_RESPONSE = 0x02;
|
|
71
|
+
const PKT_TUNNEL_CREATE = 0x04;
|
|
72
|
+
const PKT_TUNNEL_RESPONSE = 0x05;
|
|
73
|
+
const PKT_TUNNEL_AUTH = 0x06;
|
|
74
|
+
const PKT_TUNNEL_AUTH_RESPONSE = 0x07;
|
|
75
|
+
const PKT_CHANNEL_CREATE = 0x08;
|
|
76
|
+
const PKT_CHANNEL_RESPONSE = 0x09;
|
|
77
|
+
const PKT_DATA = 0x0a;
|
|
78
|
+
const PKT_SERVICE_MESSAGE = 0x0b;
|
|
79
|
+
const PKT_KEEPALIVE = 0x0d;
|
|
80
|
+
const PKT_CLOSE_CHANNEL = 0x10;
|
|
81
|
+
const PKT_CLOSE_CHANNEL_RESPONSE = 0x11;
|
|
82
|
+
/** This client shows (that is: logs) a consent message the gateway has for its users. */
|
|
83
|
+
const CAPABILITY_MESSAGING_CONSENT_SIGN = 0x4;
|
|
84
|
+
/** A packet's largest payload: a 16-bit length, kept well under it. */
|
|
85
|
+
const MAX_DATA = 32 * 1024;
|
|
86
|
+
/** What an incoming packet may be at most (a data packet's 64 KiB, and room for a certificate in the tunnel response). */
|
|
87
|
+
const MAX_PACKET = 256 * 1024;
|
|
88
|
+
/**
|
|
89
|
+
* How many of the gateway's administrator's messages are logged: it decides how many it sends, and when, for as long
|
|
90
|
+
* as the connection lasts. An administrator has one to show, as the connection starts.
|
|
91
|
+
*/
|
|
92
|
+
const MAX_SERVICE_MESSAGES = 10;
|
|
93
|
+
function packet(type, body) {
|
|
94
|
+
const header = Buffer.alloc(8);
|
|
95
|
+
header.writeUInt16LE(type, 0);
|
|
96
|
+
header.writeUInt32LE(8 + body.length, 4);
|
|
97
|
+
return Buffer.concat([header, body]);
|
|
98
|
+
}
|
|
99
|
+
/** A length-prefixed, zero-terminated UTF-16 string, as the tunnel and channel packets carry names. */
|
|
100
|
+
function name(text) {
|
|
101
|
+
const value = Buffer.from(text + '\0', 'utf16le');
|
|
102
|
+
const length = Buffer.alloc(2);
|
|
103
|
+
length.writeUInt16LE(value.length);
|
|
104
|
+
return Buffer.concat([length, value]);
|
|
105
|
+
}
|
|
106
|
+
function handshakeRequest() {
|
|
107
|
+
// Version 1.0, client version 0, no extended authentication (the HTTP sign-in is the authentication).
|
|
108
|
+
return packet(PKT_HANDSHAKE_REQUEST, Buffer.from([1, 0, 0, 0, 0, 0]));
|
|
109
|
+
}
|
|
110
|
+
function tunnelCreate() {
|
|
111
|
+
const body = Buffer.alloc(8);
|
|
112
|
+
body.writeUInt32LE(CAPABILITY_MESSAGING_CONSENT_SIGN, 0);
|
|
113
|
+
return packet(PKT_TUNNEL_CREATE, body);
|
|
114
|
+
}
|
|
115
|
+
function tunnelAuth(clientName) {
|
|
116
|
+
return packet(PKT_TUNNEL_AUTH, Buffer.concat([Buffer.alloc(2), name(clientName)]));
|
|
117
|
+
}
|
|
118
|
+
function channelCreate(host, port) {
|
|
119
|
+
const head = Buffer.alloc(6);
|
|
120
|
+
head.writeUInt8(1, 0); // one resource
|
|
121
|
+
head.writeUInt8(0, 1); // no alternative names
|
|
122
|
+
head.writeUInt16LE(port, 2);
|
|
123
|
+
head.writeUInt16LE(3, 4); // protocol: RDP over TCP
|
|
124
|
+
return packet(PKT_CHANNEL_CREATE, Buffer.concat([head, name(host)]));
|
|
125
|
+
}
|
|
126
|
+
function dataPacket(data) {
|
|
127
|
+
const length = Buffer.alloc(2);
|
|
128
|
+
length.writeUInt16LE(data.length);
|
|
129
|
+
return packet(PKT_DATA, Buffer.concat([length, data]));
|
|
130
|
+
}
|
|
131
|
+
/** What a gateway's error code means, in words for the user (the code itself goes to the log). */
|
|
132
|
+
function gatewayError(code, target) {
|
|
133
|
+
switch (code & 0xffff) {
|
|
134
|
+
case 0x59da: return `the gateway doesn't let this account connect to ${target} (its resource authorization policy)`;
|
|
135
|
+
case 0x59db:
|
|
136
|
+
case 0x59ed: return 'the gateway doesn\'t let this account connect (its connection authorization policy)';
|
|
137
|
+
case 0x59dd: return `the gateway couldn't reach ${target}`;
|
|
138
|
+
case 0x59e6: return 'the gateway has no connections left to give';
|
|
139
|
+
case 0x59e8:
|
|
140
|
+
case 0x59e9:
|
|
141
|
+
case 0x59f9: return 'the gateway asks for a way of signing in that isn\'t supported here (a smart card or a sign-in page)';
|
|
142
|
+
case 0x59ee: return 'the gateway has no certificate set up';
|
|
143
|
+
default: return `the gateway refused the connection (0x${(code >>> 0).toString(16)})`;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The start of a gateway's service message (a length, then UTF-16 text), for the log: up to its terminator, 300
|
|
148
|
+
* characters at most. Only that much is read: the gateway decides how long it is, up to a packet's size, and looking
|
|
149
|
+
* through all of it for where it ends could take the square of that.
|
|
150
|
+
*/
|
|
151
|
+
function serviceMessage(body) {
|
|
152
|
+
const text = body.subarray(body.length >= 2 ? 2 : 0).subarray(0, 600).toString('utf16le');
|
|
153
|
+
const nul = text.indexOf('\0');
|
|
154
|
+
return nul < 0 ? text : text.slice(0, nul);
|
|
155
|
+
}
|
|
156
|
+
/** Splits a byte stream into the gateway's packets (8 bytes of header: type, reserved, total length). */
|
|
157
|
+
function packetReader(onPacket) {
|
|
158
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
159
|
+
return chunk => {
|
|
160
|
+
pending.push(chunk);
|
|
161
|
+
while (pending.length >= 8) {
|
|
162
|
+
const length = pending.peek(8).readUInt32LE(4);
|
|
163
|
+
if (length < 8 || length > MAX_PACKET) {
|
|
164
|
+
throw new Error(`a gateway packet of ${length} bytes`);
|
|
165
|
+
}
|
|
166
|
+
if (pending.length < length) {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
const packet = pending.take(length);
|
|
170
|
+
onPacket(packet.readUInt16LE(0), packet.subarray(8));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
// ---- WebSocket frames (RFC 6455), client side -----------------------------------------------------
|
|
175
|
+
/** A binary (or control) frame from the client: masked, as the client's must be. */
|
|
176
|
+
function wsFrame(payload, opcode = 0x2) {
|
|
177
|
+
const length = payload.length;
|
|
178
|
+
const header = Buffer.alloc(length < 126 ? 2 : length < 65536 ? 4 : 10);
|
|
179
|
+
header[0] = 0x80 | opcode;
|
|
180
|
+
if (length < 126) {
|
|
181
|
+
header[1] = 0x80 | length;
|
|
182
|
+
}
|
|
183
|
+
else if (length < 65536) {
|
|
184
|
+
header[1] = 0x80 | 126;
|
|
185
|
+
header.writeUInt16BE(length, 2);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
header[1] = 0x80 | 127;
|
|
189
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
190
|
+
}
|
|
191
|
+
const mask = (0, crypto_1.randomBytes)(4);
|
|
192
|
+
const masked = Buffer.alloc(length);
|
|
193
|
+
for (let i = 0; i < length; i++) {
|
|
194
|
+
masked[i] = payload[i] ^ mask[i & 3];
|
|
195
|
+
}
|
|
196
|
+
return Buffer.concat([header, mask, masked]);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Answers pings with pongs: one waiting to go out at a time, for the latest ping, as RFC 6455 allows (5.5.3). A gateway
|
|
200
|
+
* that sends pings and doesn't read what comes back would otherwise have the pongs pile up here as fast as it sends.
|
|
201
|
+
*/
|
|
202
|
+
function pongs(socket, ended) {
|
|
203
|
+
let waiting = false;
|
|
204
|
+
let latest = null;
|
|
205
|
+
const pong = (payload) => {
|
|
206
|
+
if (waiting) {
|
|
207
|
+
latest = Buffer.from(payload);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
waiting = true;
|
|
211
|
+
socket.write(wsFrame(payload, 0xa), () => {
|
|
212
|
+
waiting = false;
|
|
213
|
+
const next = latest;
|
|
214
|
+
latest = null;
|
|
215
|
+
if (next && !ended()) {
|
|
216
|
+
pong(next);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
return pong;
|
|
221
|
+
}
|
|
222
|
+
/** Splits the server's bytes into frames: data frames' payloads (fragments as they come), pings, and a close. */
|
|
223
|
+
function wsReader(on) {
|
|
224
|
+
const pending = new byteQueue_1.ByteQueue();
|
|
225
|
+
return chunk => {
|
|
226
|
+
pending.push(chunk);
|
|
227
|
+
for (;;) {
|
|
228
|
+
// The header: 2 bytes, then up to 8 of length (and 4 of mask, read with the frame).
|
|
229
|
+
const head = pending.peek(10);
|
|
230
|
+
if (head.length < 2) {
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
const opcode = head[0] & 0x0f;
|
|
234
|
+
const masked = (head[1] & 0x80) !== 0;
|
|
235
|
+
let length = head[1] & 0x7f;
|
|
236
|
+
let at = 2;
|
|
237
|
+
if (length === 126) {
|
|
238
|
+
if (head.length < 4) {
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
length = head.readUInt16BE(2);
|
|
242
|
+
at = 4;
|
|
243
|
+
}
|
|
244
|
+
else if (length === 127) {
|
|
245
|
+
if (head.length < 10) {
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
const long = head.readBigUInt64BE(2);
|
|
249
|
+
if (long > BigInt(MAX_PACKET * 4)) {
|
|
250
|
+
throw new Error('a WebSocket frame too large to be the gateway\'s');
|
|
251
|
+
}
|
|
252
|
+
length = Number(long);
|
|
253
|
+
at = 10;
|
|
254
|
+
}
|
|
255
|
+
// Close, ping and pong carry 125 bytes at most (RFC 6455, 5.5): each ping is answered with as much.
|
|
256
|
+
if (opcode >= 0x8 && length > 125) {
|
|
257
|
+
throw new Error('a WebSocket control frame too large to be the gateway\'s');
|
|
258
|
+
}
|
|
259
|
+
const maskAt = at;
|
|
260
|
+
if (masked) {
|
|
261
|
+
at += 4;
|
|
262
|
+
}
|
|
263
|
+
if (pending.length < at + length) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
const frame = pending.take(at + length);
|
|
267
|
+
let payload = frame.subarray(at);
|
|
268
|
+
if (masked) {
|
|
269
|
+
payload = Buffer.from(payload);
|
|
270
|
+
for (let i = 0; i < length; i++) {
|
|
271
|
+
payload[i] ^= frame[maskAt + (i & 3)];
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (opcode === 0x8) {
|
|
275
|
+
on.close();
|
|
276
|
+
}
|
|
277
|
+
else if (opcode === 0x9) {
|
|
278
|
+
on.ping(payload);
|
|
279
|
+
}
|
|
280
|
+
else if (opcode === 0x0 || opcode === 0x1 || opcode === 0x2) {
|
|
281
|
+
// The packets inside are a stream of their own: fragments and whole messages alike just add to it.
|
|
282
|
+
on.data(payload);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
// ---- the HTTP upgrade, with NTLM --------------------------------------------------------------------
|
|
288
|
+
/** What a response's status line and headers may be at most. */
|
|
289
|
+
const MAX_HEAD = 64 * 1024;
|
|
290
|
+
/** What its body may be at most: a 401's page, a kilobyte or so, which isn't kept anyway. */
|
|
291
|
+
const MAX_BODY = 256 * 1024;
|
|
292
|
+
/** A header's name: an HTTP token (RFC 9110, 5.6.2). */
|
|
293
|
+
const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9a-z-]+$/;
|
|
294
|
+
/**
|
|
295
|
+
* One HTTP response's status and headers, read from the stream. A 401's body (its page) is counted off as it comes, not
|
|
296
|
+
* kept; a length (Content-Length) that isn't a number, or is over MAX_BODY, is refused. What follows is left for the
|
|
297
|
+
* next reader.
|
|
298
|
+
*/
|
|
299
|
+
function readResponse(stream, timeoutMs) {
|
|
300
|
+
return new Promise((resolve, reject) => {
|
|
301
|
+
// The status line and headers, copied in as they come.
|
|
302
|
+
const head = Buffer.alloc(MAX_HEAD);
|
|
303
|
+
let filled = 0;
|
|
304
|
+
let response = null;
|
|
305
|
+
// How much of the body is still to come, and what came after it.
|
|
306
|
+
let body = 0;
|
|
307
|
+
let rest = null;
|
|
308
|
+
const done = (error) => {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
stream.off('data', onData);
|
|
311
|
+
stream.off('error', onError);
|
|
312
|
+
stream.off('close', onClose);
|
|
313
|
+
// Paused before anything left over goes back, so that it waits for whoever reads next.
|
|
314
|
+
stream.pause();
|
|
315
|
+
if (rest?.length) {
|
|
316
|
+
stream.unshift(rest);
|
|
317
|
+
}
|
|
318
|
+
error ? reject(error) : resolve(response);
|
|
319
|
+
};
|
|
320
|
+
const onError = (e) => done(e);
|
|
321
|
+
const onClose = () => done(new Error('the gateway closed the connection'));
|
|
322
|
+
const timer = setTimeout(() => done(new Error('the gateway didn\'t answer in time')), timeoutMs);
|
|
323
|
+
const onData = (chunk) => {
|
|
324
|
+
if (!response) {
|
|
325
|
+
// Only what is new is searched, from three bytes back: the blank line can straddle two pieces.
|
|
326
|
+
const from = Math.max(0, filled - 3);
|
|
327
|
+
const start = filled;
|
|
328
|
+
filled += chunk.copy(head, filled);
|
|
329
|
+
const end = head.subarray(0, filled).indexOf('\r\n\r\n', from);
|
|
330
|
+
if (end < 0) {
|
|
331
|
+
if (filled === MAX_HEAD) {
|
|
332
|
+
done(new Error('the gateway\'s answer isn\'t HTTP'));
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const lines = head.subarray(0, end).toString('latin1').split('\r\n');
|
|
337
|
+
const status = Number(/^HTTP\/1\.[01] (\d{3})/.exec(lines[0])?.[1]);
|
|
338
|
+
// A CR or LF that doesn't end a line is no HTTP a gateway sends.
|
|
339
|
+
if (!status || lines.some(line => line.includes('\r') || line.includes('\n'))) {
|
|
340
|
+
return done(new Error('the gateway\'s answer isn\'t HTTP'));
|
|
341
|
+
}
|
|
342
|
+
const headers = new Map();
|
|
343
|
+
for (const line of lines.slice(1)) {
|
|
344
|
+
// Split at the first colon, the value trimmed: no pattern whose parts can take the same characters,
|
|
345
|
+
// which a long line can make cost the square of its length.
|
|
346
|
+
const colon = line.indexOf(':');
|
|
347
|
+
const name = colon > 0 ? line.slice(0, colon).toLowerCase() : '';
|
|
348
|
+
if (HEADER_NAME.test(name)) {
|
|
349
|
+
const values = headers.get(name) ?? [];
|
|
350
|
+
values.push(line.slice(colon + 1).trim());
|
|
351
|
+
headers.set(name, values);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
// Only a 401's page has to be got past: a sign-in round follows it on the same connection. Any other
|
|
355
|
+
// answer ends the sign-in, connection and all (and a switch of protocols has no body).
|
|
356
|
+
const length = status === 401 ? (headers.get('content-length')?.[0] ?? '0').trim() : '0';
|
|
357
|
+
if (!/^\d{1,9}$/.test(length) || Number(length) > MAX_BODY) {
|
|
358
|
+
return done(new Error('the gateway\'s answer is malformed or too large'));
|
|
359
|
+
}
|
|
360
|
+
response = { status, headers };
|
|
361
|
+
body = Number(length);
|
|
362
|
+
// What this piece has after the blank line.
|
|
363
|
+
chunk = chunk.subarray(end + 4 - start);
|
|
364
|
+
}
|
|
365
|
+
if (chunk.length < body) {
|
|
366
|
+
body -= chunk.length;
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
rest = chunk.subarray(body);
|
|
370
|
+
done(null);
|
|
371
|
+
};
|
|
372
|
+
stream.on('data', onData);
|
|
373
|
+
stream.on('error', onError);
|
|
374
|
+
stream.on('close', onClose);
|
|
375
|
+
stream.resume();
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* What ties a sign-in to the TLS connection it is made over (RFC 5929's tls-server-end-point): the server's
|
|
380
|
+
* certificate, hashed with its signature's hash, or SHA-256 where that is MD5, SHA-1 or none. A gateway checks it by
|
|
381
|
+
* default (extended protection): a sign-in relayed from another connection doesn't have it.
|
|
382
|
+
*/
|
|
383
|
+
function endPointBinding(certificate) {
|
|
384
|
+
// Certificate ::= SEQUENCE { tbsCertificate SEQUENCE, signatureAlgorithm SEQUENCE, signature }: the second member.
|
|
385
|
+
const member = (at) => {
|
|
386
|
+
let length = certificate[at + 1];
|
|
387
|
+
let start = at + 2;
|
|
388
|
+
if (length & 0x80) {
|
|
389
|
+
const bytes = length & 0x7f;
|
|
390
|
+
length = 0;
|
|
391
|
+
for (let i = 0; i < bytes; i++) {
|
|
392
|
+
length = length * 256 + certificate[start + i];
|
|
393
|
+
}
|
|
394
|
+
start += bytes;
|
|
395
|
+
}
|
|
396
|
+
return { start, end: start + length };
|
|
397
|
+
};
|
|
398
|
+
let algorithm = '';
|
|
399
|
+
try {
|
|
400
|
+
const tbs = member(member(0).start);
|
|
401
|
+
algorithm = certificate.subarray(tbs.end, member(tbs.end).end).toString('hex');
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
// not a certificate this reads: SHA-256
|
|
405
|
+
}
|
|
406
|
+
// sha384/sha512 with RSA, ECDSA, or as RSASSA-PSS's hash.
|
|
407
|
+
const hash = /2a864886f70d01010c|2a8648ce3d040303|608648016503040202/.test(algorithm) ? 'sha384'
|
|
408
|
+
: /2a864886f70d01010d|2a8648ce3d040304|608648016503040203/.test(algorithm) ? 'sha512' : 'sha256';
|
|
409
|
+
return Buffer.concat([Buffer.from('tls-server-end-point:', 'latin1'), (0, crypto_1.createHash)(hash).update(certificate).digest()]);
|
|
410
|
+
}
|
|
411
|
+
/** Upgrades the connection to the gateway's WebSocket, signing in with NTLM on the way. */
|
|
412
|
+
async function upgrade(stream, gateway, account, certificate, timeoutMs, proceed = () => { }) {
|
|
413
|
+
const key = (0, crypto_1.randomBytes)(16).toString('base64');
|
|
414
|
+
const connection = `{${(0, crypto_1.randomUUID)().toUpperCase()}}`;
|
|
415
|
+
const request = (authorization) => [
|
|
416
|
+
'RDG_OUT_DATA /remoteDesktopGateway/ HTTP/1.1',
|
|
417
|
+
`Host: ${gateway.host}${gateway.port === 443 ? '' : `:${gateway.port}`}`,
|
|
418
|
+
'Connection: Upgrade',
|
|
419
|
+
'Upgrade: websocket',
|
|
420
|
+
'Sec-WebSocket-Version: 13',
|
|
421
|
+
`Sec-WebSocket-Key: ${key}`,
|
|
422
|
+
`RDG-Connection-Id: ${connection}`,
|
|
423
|
+
`Authorization: NTLM ${authorization.toString('base64')}`,
|
|
424
|
+
'', '',
|
|
425
|
+
].join('\r\n');
|
|
426
|
+
const negotiate = (0, ntlm_1.negotiateMessage)();
|
|
427
|
+
proceed();
|
|
428
|
+
stream.write(request(negotiate));
|
|
429
|
+
const first = await readResponse(stream, timeoutMs);
|
|
430
|
+
if (first.status !== 401) {
|
|
431
|
+
throw new Error(first.status === 101 ? 'the gateway let the connection in without a sign-in' : `the gateway answered ${first.status} to the sign-in`);
|
|
432
|
+
}
|
|
433
|
+
const offered = first.headers.get('www-authenticate') ?? [];
|
|
434
|
+
const challenge = offered.map(v => /^NTLM\s+([A-Za-z0-9+/=]+)\s*$/.exec(v)?.[1]).find(Boolean);
|
|
435
|
+
if (!challenge) {
|
|
436
|
+
throw new Error(offered.some(v => /^NTLM\b/i.test(v))
|
|
437
|
+
? 'the gateway didn\'t take up the NTLM sign-in'
|
|
438
|
+
: `the gateway doesn't offer an NTLM sign-in (it offers: ${offered.map(v => v.split(' ')[0]).join(', ') || 'nothing'})`);
|
|
439
|
+
}
|
|
440
|
+
const challengeMessage = Buffer.from(challenge, 'base64');
|
|
441
|
+
proceed();
|
|
442
|
+
stream.write(request((0, ntlm_1.authenticateMessage)(negotiate, challengeMessage, {
|
|
443
|
+
...account, channelBinding: endPointBinding(certificate), targetName: `HTTP/${gateway.host}`,
|
|
444
|
+
})));
|
|
445
|
+
const second = await readResponse(stream, timeoutMs);
|
|
446
|
+
if (second.status === 401 || second.status === 403) {
|
|
447
|
+
throw new GatewaySignInError('The gateway refused the sign-in.');
|
|
448
|
+
}
|
|
449
|
+
if (second.status !== 101) {
|
|
450
|
+
// An older gateway takes the request for its HTTP transport (two connections, one each way), not spoken here.
|
|
451
|
+
throw new Error(second.status === 200
|
|
452
|
+
? 'the gateway doesn\'t take WebSocket connections (Windows Server 2012 R2 and later do)'
|
|
453
|
+
: `the gateway answered ${second.status} after the sign-in`);
|
|
454
|
+
}
|
|
455
|
+
const accept = (0, crypto_1.createHash)('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
|
456
|
+
if (second.headers.get('sec-websocket-accept')?.[0] !== accept) {
|
|
457
|
+
throw new Error('the gateway\'s WebSocket answer doesn\'t match the request');
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Opens a stream to target.host:target.port through the gateway. `open` gives a TCP stream to the gateway (directly, or
|
|
462
|
+
* through an SSH host). `checkCertificate` decides on the gateway's certificate before the account is used: it gets
|
|
463
|
+
* the SHA-256 fingerprint (`AB:CD:…`), whether the certificate is valid for the gateway's name by this computer's
|
|
464
|
+
* certificate authorities (see certificateAuthorities), why not (Node's code for it, '' when it is), and what the
|
|
465
|
+
* certificate says of itself (see CertificateDetails), and throws to refuse.
|
|
466
|
+
*/
|
|
467
|
+
async function openThroughGateway(open, gateway, account, target, checkCertificate, options = {}) {
|
|
468
|
+
const log = options.log ?? (() => null);
|
|
469
|
+
const timeoutMs = options.timeoutMs ?? 20000;
|
|
470
|
+
const targetName = `${target.host}:${target.port}`;
|
|
471
|
+
const signal = options.signal;
|
|
472
|
+
// Nothing more goes to the gateway once what it is for has gone: checked before each step and message of the
|
|
473
|
+
// sign-in, and the connection ends then, which also ends what waits on it (the TLS handshake, an answer).
|
|
474
|
+
const proceed = () => {
|
|
475
|
+
if (signal?.aborted) {
|
|
476
|
+
throw new Error('the desktop was closed meanwhile');
|
|
477
|
+
}
|
|
478
|
+
};
|
|
479
|
+
proceed();
|
|
480
|
+
const raw = await open();
|
|
481
|
+
let secure = null;
|
|
482
|
+
const abort = () => {
|
|
483
|
+
secure?.destroy();
|
|
484
|
+
raw.destroy();
|
|
485
|
+
};
|
|
486
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
487
|
+
try {
|
|
488
|
+
proceed();
|
|
489
|
+
// A name is sent (SNI), an address isn't; either is what the certificate is checked for.
|
|
490
|
+
const named = !(0, net_1.isIP)(gateway.host);
|
|
491
|
+
const socket = tls.connect({
|
|
492
|
+
socket: raw, host: gateway.host, rejectUnauthorized: false, ca: (0, authorities_1.certificateAuthorities)(),
|
|
493
|
+
...named ? { servername: gateway.host } : {}, ...options.tls,
|
|
494
|
+
});
|
|
495
|
+
secure = socket;
|
|
496
|
+
// A step like the others: a gateway that takes the connection and never answers TLS would keep it for good.
|
|
497
|
+
let timer;
|
|
498
|
+
await new Promise((resolve, reject) => {
|
|
499
|
+
timer = setTimeout(() => reject(new Error('the gateway didn\'t answer in time (TLS)')), timeoutMs);
|
|
500
|
+
socket.once('secureConnect', resolve);
|
|
501
|
+
socket.once('error', reject);
|
|
502
|
+
socket.once('close', () => reject(new Error('the gateway closed the connection during TLS')));
|
|
503
|
+
}).finally(() => clearTimeout(timer));
|
|
504
|
+
const certificate = socket.getPeerCertificate(true);
|
|
505
|
+
if (!certificate?.fingerprint256) {
|
|
506
|
+
throw new Error('the gateway sent no certificate');
|
|
507
|
+
}
|
|
508
|
+
// Where its chain stands and whether it is for the gateway's name, which Node's code for why it isn't valid can
|
|
509
|
+
// hide (see certificateDetails).
|
|
510
|
+
const reason = socket.authorized ? '' : String(socket.authorizationError ?? '');
|
|
511
|
+
const checked = socket.authorized ? undefined : { name: gateway.host, authorities: options.tls?.ca ?? (0, authorities_1.certificateAuthorities)(), reason };
|
|
512
|
+
await checkCertificate(certificate.fingerprint256, socket.authorized, reason, (0, authorities_1.certificateDetails)(certificate, checked));
|
|
513
|
+
await upgrade(socket, gateway, account, certificate.raw, timeoutMs, proceed);
|
|
514
|
+
log(`gateway: signed in to ${gateway.host}`);
|
|
515
|
+
// From here on: WebSocket frames, the gateway's packets inside.
|
|
516
|
+
let expecting = null;
|
|
517
|
+
let stream = null;
|
|
518
|
+
let ended = false;
|
|
519
|
+
let messages = 0;
|
|
520
|
+
const end = (error) => {
|
|
521
|
+
if (ended) {
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
ended = true;
|
|
525
|
+
expecting?.reject(error ?? new Error('the gateway closed the connection'));
|
|
526
|
+
socket.destroy();
|
|
527
|
+
stream?.destroy(error);
|
|
528
|
+
};
|
|
529
|
+
const packets = packetReader((type, body) => {
|
|
530
|
+
if (type === PKT_DATA) {
|
|
531
|
+
const length = body.length >= 2 ? body.readUInt16LE(0) : 0;
|
|
532
|
+
// The reader not keeping up: nothing more is read from the gateway until it asks again.
|
|
533
|
+
if (stream && !stream.push(Buffer.from(body.subarray(2, 2 + length)))) {
|
|
534
|
+
socket.pause();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
else if (type === PKT_KEEPALIVE) {
|
|
538
|
+
// nothing to answer: the gateway only checks the line
|
|
539
|
+
}
|
|
540
|
+
else if (type === PKT_SERVICE_MESSAGE) {
|
|
541
|
+
if (++messages <= MAX_SERVICE_MESSAGES) {
|
|
542
|
+
log(`gateway: its administrator's message: ${serviceMessage(body)}`);
|
|
543
|
+
}
|
|
544
|
+
else if (messages === MAX_SERVICE_MESSAGES + 1) {
|
|
545
|
+
log(`gateway: more messages from its administrator; past ${MAX_SERVICE_MESSAGES}, they aren't logged`);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else if (type === PKT_CLOSE_CHANNEL) {
|
|
549
|
+
socket.write(wsFrame(packet(PKT_CLOSE_CHANNEL_RESPONSE, Buffer.alloc(8))));
|
|
550
|
+
end();
|
|
551
|
+
}
|
|
552
|
+
else if (expecting && type === expecting.type) {
|
|
553
|
+
const waiting = expecting;
|
|
554
|
+
expecting = null;
|
|
555
|
+
waiting.resolve(body);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
const frames = wsReader({
|
|
559
|
+
data: payload => packets(payload),
|
|
560
|
+
ping: pongs(socket, () => ended),
|
|
561
|
+
close: () => end(),
|
|
562
|
+
});
|
|
563
|
+
socket.on('data', (chunk) => {
|
|
564
|
+
try {
|
|
565
|
+
frames(chunk);
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
end(e);
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
socket.on('error', (e) => end(e));
|
|
572
|
+
socket.on('close', () => end());
|
|
573
|
+
socket.resume();
|
|
574
|
+
/** Sends a packet and waits for the answer of the given type: its body. */
|
|
575
|
+
const exchange = (send, type, step) => new Promise((resolve, reject) => {
|
|
576
|
+
const timer = setTimeout(() => {
|
|
577
|
+
expecting = null;
|
|
578
|
+
reject(new Error(`the gateway didn't answer in time (${step})`));
|
|
579
|
+
}, timeoutMs);
|
|
580
|
+
expecting = {
|
|
581
|
+
type,
|
|
582
|
+
resolve: body => { clearTimeout(timer); resolve(body); },
|
|
583
|
+
reject: e => { clearTimeout(timer); reject(e); },
|
|
584
|
+
};
|
|
585
|
+
try {
|
|
586
|
+
proceed();
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
expecting = null;
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
return reject(e);
|
|
592
|
+
}
|
|
593
|
+
socket.write(wsFrame(send));
|
|
594
|
+
});
|
|
595
|
+
const refused = (code, step) => {
|
|
596
|
+
log(`gateway: ${step} refused: 0x${(code >>> 0).toString(16)}`);
|
|
597
|
+
const message = gatewayError(code, targetName);
|
|
598
|
+
// Not being allowed in by policy is the account's doing, like a refused sign-in.
|
|
599
|
+
return [0x59db, 0x59ed].includes(code & 0xffff) ? new GatewaySignInError(`${message[0].toUpperCase()}${message.slice(1)}.`) : new Error(message);
|
|
600
|
+
};
|
|
601
|
+
const handshake = await exchange(handshakeRequest(), PKT_HANDSHAKE_RESPONSE, 'handshake');
|
|
602
|
+
if (handshake.length < 4 || handshake.readUInt32LE(0) !== 0) {
|
|
603
|
+
throw refused(handshake.length < 4 ? 0 : handshake.readUInt32LE(0), 'the handshake');
|
|
604
|
+
}
|
|
605
|
+
// The tunnel: server version (2), status (4), fields present (2), reserved (2), then what the fields say.
|
|
606
|
+
const tunnel = await exchange(tunnelCreate(), PKT_TUNNEL_RESPONSE, 'tunnel');
|
|
607
|
+
if (tunnel.length < 10 || tunnel.readUInt32LE(2) !== 0) {
|
|
608
|
+
throw refused(tunnel.length < 10 ? 0 : tunnel.readUInt32LE(2), 'the tunnel');
|
|
609
|
+
}
|
|
610
|
+
const authorized = await exchange(tunnelAuth(options.clientName ?? 'tabby-rdp'), PKT_TUNNEL_AUTH_RESPONSE, 'authorization');
|
|
611
|
+
if (authorized.length < 4 || authorized.readUInt32LE(0) !== 0) {
|
|
612
|
+
throw refused(authorized.length < 4 ? 0 : authorized.readUInt32LE(0), 'the authorization');
|
|
613
|
+
}
|
|
614
|
+
const channel = await exchange(channelCreate(target.host, target.port), PKT_CHANNEL_RESPONSE, 'channel');
|
|
615
|
+
if (channel.length < 4 || channel.readUInt32LE(0) !== 0) {
|
|
616
|
+
throw refused(channel.length < 4 ? 0 : channel.readUInt32LE(0), 'the channel');
|
|
617
|
+
}
|
|
618
|
+
log(`gateway: channel to ${targetName} through ${gateway.host}`);
|
|
619
|
+
const tunnelStream = new stream_1.Duplex({
|
|
620
|
+
read() { socket.resume(); },
|
|
621
|
+
write(chunk, _encoding, callback) {
|
|
622
|
+
let flushed = true;
|
|
623
|
+
for (let at = 0; at < chunk.length; at += MAX_DATA) {
|
|
624
|
+
flushed = socket.write(wsFrame(dataPacket(chunk.subarray(at, at + MAX_DATA))));
|
|
625
|
+
}
|
|
626
|
+
// The gateway not keeping up: the writer waits for it.
|
|
627
|
+
flushed ? callback() : socket.once('drain', () => callback());
|
|
628
|
+
},
|
|
629
|
+
final(callback) {
|
|
630
|
+
socket.write(wsFrame(packet(PKT_CLOSE_CHANNEL, Buffer.alloc(4))));
|
|
631
|
+
callback();
|
|
632
|
+
},
|
|
633
|
+
destroy(error, callback) {
|
|
634
|
+
ended = true;
|
|
635
|
+
socket.destroy();
|
|
636
|
+
callback(error);
|
|
637
|
+
},
|
|
638
|
+
});
|
|
639
|
+
stream = tunnelStream;
|
|
640
|
+
return tunnelStream;
|
|
641
|
+
}
|
|
642
|
+
catch (e) {
|
|
643
|
+
secure?.destroy();
|
|
644
|
+
raw.destroy();
|
|
645
|
+
throw e;
|
|
646
|
+
}
|
|
647
|
+
finally {
|
|
648
|
+
// Only for the sign-in and the tunnel's setup: the tunnel itself ends with the client's connection (see track).
|
|
649
|
+
signal?.removeEventListener('abort', abort);
|
|
650
|
+
}
|
|
651
|
+
}
|