1 |
|
2 |
|
3 | 'use strict';
|
4 |
|
5 | const assert = require('assert');
|
6 | const { spawn, spawnSync } = require('child_process');
|
7 | const { chmodSync, readdirSync } = require('fs');
|
8 | const { join } = require('path');
|
9 | const readline = require('readline');
|
10 |
|
11 | const Server = require('../lib/server.js');
|
12 |
|
13 | const {
|
14 | fixture,
|
15 | fixtureKey,
|
16 | FIXTURES_DIR,
|
17 | mustCall,
|
18 | mustCallAtLeast,
|
19 | } = require('./common.js');
|
20 |
|
21 | const SPAWN_OPTS = { windowsHide: true };
|
22 | const CLIENT_TIMEOUT = 5000;
|
23 |
|
24 | const debug = false;
|
25 | const opensshPath = 'ssh';
|
26 | let opensshVer;
|
27 |
|
28 |
|
29 | if (process.platform === 'win32') {
|
30 | console.log('Skipping OpenSSH integration tests on Windows');
|
31 | process.exit(0);
|
32 | }
|
33 |
|
34 |
|
35 | for (const file of readdirSync(FIXTURES_DIR, { withFileTypes: true })) {
|
36 | if (file.isFile())
|
37 | chmodSync(join(FIXTURES_DIR, file.name), 0o600);
|
38 | }
|
39 |
|
40 | {
|
41 |
|
42 | const {
|
43 | error, stderr, stdout
|
44 | } = spawnSync(opensshPath, ['-V'], SPAWN_OPTS);
|
45 |
|
46 | if (error) {
|
47 | console.error('OpenSSH client is required for these tests');
|
48 | process.exitCode = 5;
|
49 | return;
|
50 | }
|
51 |
|
52 | const re = /^OpenSSH_([\d.]+)/;
|
53 | let m = re.exec(stdout.toString());
|
54 | if (!m || !m[1]) {
|
55 | m = re.exec(stderr.toString());
|
56 | if (!m || !m[1]) {
|
57 | console.error('OpenSSH client is required for these tests');
|
58 | process.exitCode = 5;
|
59 | return;
|
60 | }
|
61 | }
|
62 |
|
63 | opensshVer = m[1];
|
64 | console.log(`Testing with OpenSSH version: ${opensshVer}`);
|
65 | }
|
66 |
|
67 |
|
68 |
|
69 | [
|
70 | { desc: 'RSA user key (old OpenSSH)',
|
71 | clientKey: fixtureKey('id_rsa') },
|
72 | { desc: 'RSA user key (new OpenSSH)',
|
73 | clientKey: fixtureKey('openssh_new_rsa') },
|
74 | { desc: 'DSA user key',
|
75 | clientKey: fixtureKey('id_dsa') },
|
76 | { desc: 'ECDSA user key',
|
77 | clientKey: fixtureKey('id_ecdsa') },
|
78 | ].forEach((test) => {
|
79 | const { desc, clientKey } = test;
|
80 | const username = 'KeyUser';
|
81 | const { server } = setup(
|
82 | desc,
|
83 | {
|
84 | client: {
|
85 | username,
|
86 | privateKeyPath: clientKey.fullPath,
|
87 | },
|
88 | server: { hostKeys: [ fixture('ssh_host_rsa_key') ] },
|
89 | debug,
|
90 | }
|
91 | );
|
92 |
|
93 | server.on('connection', mustCall((conn) => {
|
94 | let authAttempt = 0;
|
95 | conn.on('authentication', mustCall((ctx) => {
|
96 | assert(ctx.username === username,
|
97 | `Wrong username: ${ctx.username}`);
|
98 | switch (++authAttempt) {
|
99 | case 1:
|
100 | assert(ctx.method === 'none',
|
101 | `Wrong auth method: ${ctx.method}`);
|
102 | return ctx.reject();
|
103 | case 2:
|
104 | assert(ctx.signature, 'Missing publickey signature');
|
105 | assert(ctx.method === 'publickey',
|
106 | `Wrong auth method: ${ctx.method}`);
|
107 | assert(ctx.key.algo === clientKey.key.type,
|
108 | `Wrong key algo: ${ctx.key.algo}`);
|
109 | assert.deepStrictEqual(clientKey.key.getPublicSSH(),
|
110 | ctx.key.data,
|
111 | 'Public key mismatch');
|
112 | break;
|
113 | }
|
114 | if (ctx.signature) {
|
115 | assert(clientKey.key.verify(ctx.blob, ctx.signature) === true,
|
116 | 'Could not verify publickey signature');
|
117 | }
|
118 | ctx.accept();
|
119 | }, 2)).on('ready', mustCall(() => {
|
120 | conn.on('session', mustCall((accept, reject) => {
|
121 | accept().on('exec', mustCall((accept, reject) => {
|
122 | const stream = accept();
|
123 | stream.exit(0);
|
124 | stream.end();
|
125 | }));
|
126 | }));
|
127 | }));
|
128 | }));
|
129 | });
|
130 |
|
131 |
|
132 |
|
133 | [
|
134 | { desc: 'RSA host key (old OpenSSH)',
|
135 | hostKey: fixture('id_rsa') },
|
136 | { desc: 'RSA host key (new OpenSSH)',
|
137 | hostKey: fixture('openssh_new_rsa') },
|
138 | { desc: 'DSA host key',
|
139 | hostKey: fixture('ssh_host_dsa_key') },
|
140 | { desc: 'ECDSA host key',
|
141 | hostKey: fixture('ssh_host_ecdsa_key') },
|
142 | { desc: 'PPK',
|
143 | hostKey: fixture('id_rsa.ppk') },
|
144 | ].forEach((test) => {
|
145 | const { desc, hostKey } = test;
|
146 | const clientKey = fixtureKey('openssh_new_rsa');
|
147 | const username = 'KeyUser';
|
148 | const { server } = setup(
|
149 | desc,
|
150 | {
|
151 | client: {
|
152 | username,
|
153 | privateKeyPath: clientKey.fullPath,
|
154 | },
|
155 | server: { hostKeys: [ hostKey ] },
|
156 | debug,
|
157 | }
|
158 | );
|
159 |
|
160 | server.on('connection', mustCall((conn) => {
|
161 | let authAttempt = 0;
|
162 | conn.on('authentication', mustCall((ctx) => {
|
163 | assert(ctx.username === username,
|
164 | `Wrong username: ${ctx.username}`);
|
165 | switch (++authAttempt) {
|
166 | case 1:
|
167 | assert(ctx.method === 'none',
|
168 | `Wrong auth method: ${ctx.method}`);
|
169 | return ctx.reject();
|
170 | case 2:
|
171 | assert(ctx.signature, 'Missing publickey signature');
|
172 | assert(ctx.method === 'publickey',
|
173 | `Wrong auth method: ${ctx.method}`);
|
174 | assert(ctx.key.algo === clientKey.key.type,
|
175 | `Wrong key algo: ${ctx.key.algo}`);
|
176 | assert.deepStrictEqual(clientKey.key.getPublicSSH(),
|
177 | ctx.key.data,
|
178 | 'Public key mismatch');
|
179 | break;
|
180 | }
|
181 | if (ctx.signature) {
|
182 | assert(clientKey.key.verify(ctx.blob, ctx.signature) === true,
|
183 | 'Could not verify publickey signature');
|
184 | }
|
185 | ctx.accept();
|
186 | }, 2)).on('ready', mustCall(() => {
|
187 | conn.on('session', mustCall((accept, reject) => {
|
188 | accept().on('exec', mustCall((accept, reject) => {
|
189 | const stream = accept();
|
190 | stream.exit(0);
|
191 | stream.end();
|
192 | }));
|
193 | }));
|
194 | }));
|
195 | }));
|
196 | });
|
197 |
|
198 |
|
199 |
|
200 | {
|
201 | const clientKey = fixtureKey('openssh_new_rsa');
|
202 | const username = 'KeyUser';
|
203 | const { server } = setup(
|
204 | 'Server closes stdin too early',
|
205 | {
|
206 | client: {
|
207 | username,
|
208 | privateKeyPath: clientKey.fullPath,
|
209 | },
|
210 | server: { hostKeys: [ fixture('ssh_host_rsa_key') ] },
|
211 | debug,
|
212 | }
|
213 | );
|
214 |
|
215 | server.on('_child', mustCall((childProc) => {
|
216 | childProc.stderr.once('data', mustCall((data) => {
|
217 | childProc.stdin.end();
|
218 | }));
|
219 | childProc.stdin.write('ping');
|
220 | })).on('connection', mustCall((conn) => {
|
221 | let authAttempt = 0;
|
222 | conn.on('authentication', mustCall((ctx) => {
|
223 | assert(ctx.username === username,
|
224 | `Wrong username: ${ctx.username}`);
|
225 | switch (++authAttempt) {
|
226 | case 1:
|
227 | assert(ctx.method === 'none',
|
228 | `Wrong auth method: ${ctx.method}`);
|
229 | return ctx.reject();
|
230 | case 2:
|
231 | assert(ctx.signature, 'Missing publickey signature');
|
232 | assert(ctx.method === 'publickey',
|
233 | `Wrong auth method: ${ctx.method}`);
|
234 | assert(ctx.key.algo === clientKey.key.type,
|
235 | `Wrong key algo: ${ctx.key.algo}`);
|
236 | assert.deepStrictEqual(clientKey.key.getPublicSSH(),
|
237 | ctx.key.data,
|
238 | 'Public key mismatch');
|
239 | break;
|
240 | }
|
241 | if (ctx.signature) {
|
242 | assert(clientKey.key.verify(ctx.blob, ctx.signature) === true,
|
243 | 'Could not verify publickey signature');
|
244 | }
|
245 | ctx.accept();
|
246 | }, 2)).on('ready', mustCall(() => {
|
247 | conn.on('session', mustCall((accept, reject) => {
|
248 | accept().on('exec', mustCall((accept, reject) => {
|
249 | const stream = accept();
|
250 | stream.stdin.on('data', mustCallAtLeast((data) => {
|
251 | stream.stdout.write('pong on stdout');
|
252 | stream.stderr.write('pong on stderr');
|
253 | })).on('end', mustCall(() => {
|
254 | stream.stdout.write('pong on stdout');
|
255 | stream.stderr.write('pong on stderr');
|
256 | stream.exit(0);
|
257 | stream.close();
|
258 | }));
|
259 | }));
|
260 | }));
|
261 | }));
|
262 | }));
|
263 | }
|
264 | {
|
265 | const clientKey = fixtureKey('openssh_new_rsa');
|
266 | const username = 'KeyUser';
|
267 | const { server } = setup(
|
268 | 'Rekey',
|
269 | {
|
270 | client: {
|
271 | username,
|
272 | privateKeyPath: clientKey.fullPath,
|
273 | },
|
274 | server: { hostKeys: [ fixture('ssh_host_rsa_key') ] },
|
275 | debug,
|
276 | }
|
277 | );
|
278 |
|
279 | server.on('connection', mustCall((conn) => {
|
280 | let authAttempt = 0;
|
281 | conn.on('authentication', mustCall((ctx) => {
|
282 | assert(ctx.username === username,
|
283 | `Wrong username: ${ctx.username}`);
|
284 | switch (++authAttempt) {
|
285 | case 1:
|
286 | assert(ctx.method === 'none',
|
287 | `Wrong auth method: ${ctx.method}`);
|
288 | return ctx.reject();
|
289 | case 2:
|
290 | assert(ctx.signature, 'Missing publickey signature');
|
291 | assert(ctx.method === 'publickey',
|
292 | `Wrong auth method: ${ctx.method}`);
|
293 | assert(ctx.key.algo === clientKey.key.type,
|
294 | `Wrong key algo: ${ctx.key.algo}`);
|
295 | assert.deepStrictEqual(clientKey.key.getPublicSSH(),
|
296 | ctx.key.data,
|
297 | 'Public key mismatch');
|
298 | break;
|
299 | }
|
300 | if (ctx.signature) {
|
301 | assert(clientKey.key.verify(ctx.blob, ctx.signature) === true,
|
302 | 'Could not verify publickey signature');
|
303 | }
|
304 | ctx.accept();
|
305 | }, 2)).on('ready', mustCall(() => {
|
306 | conn.on('session', mustCall((accept, reject) => {
|
307 | const session = accept();
|
308 | conn.rekey();
|
309 | session.on('exec', mustCall((accept, reject) => {
|
310 | const stream = accept();
|
311 | stream.exit(0);
|
312 | stream.end();
|
313 | }));
|
314 | }));
|
315 | }));
|
316 | }));
|
317 | }
|
318 |
|
319 |
|
320 | function setup(title, configs) {
|
321 | const {
|
322 | client: clientCfg,
|
323 | server: serverCfg,
|
324 | allReady: allReady_,
|
325 | timeout: timeout_,
|
326 | debug,
|
327 | noForceServerReady,
|
328 | } = configs;
|
329 | let clientClose = false;
|
330 | let serverClose = false;
|
331 | let serverReady = false;
|
332 | let client;
|
333 | const msg = (text) => {
|
334 | return `${title}: ${text}`;
|
335 | };
|
336 |
|
337 | const timeout = (typeof timeout_ === 'number'
|
338 | ? timeout_
|
339 | : CLIENT_TIMEOUT);
|
340 |
|
341 | const allReady = (typeof allReady_ === 'function' ? allReady_ : undefined);
|
342 |
|
343 | if (debug) {
|
344 | serverCfg.debug = (...args) => {
|
345 | console.log(`[${title}][SERVER]`, ...args);
|
346 | };
|
347 | }
|
348 |
|
349 | const serverReadyFn = (noForceServerReady ? onReady : mustCall(onReady));
|
350 | const server = new Server(serverCfg);
|
351 |
|
352 | server.on('error', onError)
|
353 | .on('connection', mustCall((conn) => {
|
354 | conn.on('error', onError)
|
355 | .on('ready', serverReadyFn);
|
356 | server.close();
|
357 | }))
|
358 | .on('close', mustCall(onClose));
|
359 |
|
360 | function onError(err) {
|
361 | const which = (arguments.length >= 3 ? 'client' : 'server');
|
362 | assert(false, msg(`Unexpected ${which} error: ${err}`));
|
363 | }
|
364 |
|
365 | function onReady() {
|
366 | assert(!serverReady, msg('Received multiple ready events for server'));
|
367 | serverReady = true;
|
368 | allReady && allReady();
|
369 | }
|
370 |
|
371 | function onClose() {
|
372 | if (arguments.length >= 3) {
|
373 | assert(!clientClose, msg('Received multiple close events for client'));
|
374 | clientClose = true;
|
375 | } else {
|
376 | assert(!serverClose, msg('Received multiple close events for server'));
|
377 | serverClose = true;
|
378 | }
|
379 | }
|
380 |
|
381 | process.nextTick(mustCall(() => {
|
382 | server.listen(0, 'localhost', mustCall(() => {
|
383 | const args = [
|
384 | '-o', 'UserKnownHostsFile=/dev/null',
|
385 | '-o', 'StrictHostKeyChecking=no',
|
386 | '-o', 'CheckHostIP=no',
|
387 | '-o', 'ConnectTimeout=3',
|
388 | '-o', 'GlobalKnownHostsFile=/dev/null',
|
389 | '-o', 'GSSAPIAuthentication=no',
|
390 | '-o', 'IdentitiesOnly=yes',
|
391 | '-o', 'BatchMode=yes',
|
392 | '-o', 'VerifyHostKeyDNS=no',
|
393 |
|
394 | '-vvvvvv',
|
395 | '-T',
|
396 | '-o', 'KbdInteractiveAuthentication=no',
|
397 | '-o', 'HostbasedAuthentication=no',
|
398 | '-o', 'PasswordAuthentication=no',
|
399 | '-o', 'PubkeyAuthentication=yes',
|
400 | '-o', 'PreferredAuthentications=publickey'
|
401 | ];
|
402 |
|
403 | if (clientCfg.privateKeyPath)
|
404 | args.push('-o', `IdentityFile=${clientCfg.privateKeyPath}`);
|
405 |
|
406 | if (!/^[0-6]\./.test(opensshVer)) {
|
407 |
|
408 |
|
409 | args.push('-o', 'HostKeyAlgorithms=+ssh-dss');
|
410 | args.push('-o', 'PubkeyAcceptedKeyTypes=+ssh-dss');
|
411 | }
|
412 |
|
413 | args.push('-p', server.address().port.toString(),
|
414 | '-l', clientCfg.username,
|
415 | 'localhost',
|
416 | 'uptime');
|
417 |
|
418 | client = spawn(opensshPath, args, SPAWN_OPTS);
|
419 | server.emit('_child', client);
|
420 |
|
421 | if (debug) {
|
422 | readline.createInterface({
|
423 | input: client.stdout
|
424 | }).on('line', (line) => {
|
425 | console.log(`[${title}][CLIENT][STDOUT]`, line);
|
426 | });
|
427 | readline.createInterface({
|
428 | input: client.stderr
|
429 | }).on('line', (line) => {
|
430 | console.error(`[${title}][CLIENT][STDERR]`, line);
|
431 | });
|
432 | } else {
|
433 | client.stdout.resume();
|
434 | client.stderr.resume();
|
435 | }
|
436 |
|
437 | client.on('error', (err) => {
|
438 | onError(err, null, null);
|
439 | }).on('exit', (code) => {
|
440 | clearTimeout(client.timer);
|
441 | if (code !== 0)
|
442 | return onError(new Error(`Non-zero exit code ${code}`), null, null);
|
443 | onClose(null, null, null);
|
444 | });
|
445 |
|
446 | client.timer = setTimeout(() => {
|
447 | assert(false, msg('Client timeout'));
|
448 | }, timeout);
|
449 | }));
|
450 | }));
|
451 |
|
452 | return { server };
|
453 | }
|