diff --git a/.vscode/settings.json b/.vscode/settings.json index 566efda8..7e88aea1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,5 @@ { - "editor.formatOnSave": true, + "editor.formatOnSave": false, "editor.tabSize": 2, "editor.insertSpaces": true, "files.trimTrailingWhitespace": true, diff --git a/lib/connect.js b/lib/connect.js index b6aef2fe..e876d700 100644 --- a/lib/connect.js +++ b/lib/connect.js @@ -1,4 +1,4 @@ -const { EventEmitter } = require('events'); +const { EventEmitter, once } = require('events'); const fs = require('fs'); const async = require('async'); const { @@ -12,7 +12,7 @@ const { const { MongoClient } = require('mongodb'); const { parseConnectionString } = require('mongodb/lib/core'); const Connection = require('./extended-model'); -const createSSHTunnel = require('./ssh-tunnel'); +const { default: SSHTunnel } = require('@mongodb-js/ssh-tunnel'); const debug = require('debug')('mongodb-connection-model:connect'); @@ -125,8 +125,9 @@ const getTasks = (model, setupListeners) => { const tasks = {}; const _statuses = {}; let options = {}; - let tunnel; - let client; + /** @type {SSHTunnel} */ + let tunnel = null; + let client = null; const status = (message, cb) => { if (_statuses[message]) { @@ -189,19 +190,27 @@ const getTasks = (model, setupListeners) => { }); assign(tasks, { - [Tasks.CreateSSHTunnel]: (cb) => { - const ctx = status('Create SSH Tunnel', cb); + [Tasks.CreateSSHTunnel]: async() => { + const ctx = status('Create SSH Tunnel'); if (model.sshTunnel === 'NONE') { return ctx.skip('The selected SSH Tunnel mode is NONE.'); } - tunnel = createSSHTunnel(model, ctx); + tunnel = new SSHTunnel(model.sshTunnelOptions); + + try { + await tunnel.listen(); + ctx(null); + } catch (err) { + ctx(err); + throw err; + } } }); assign(tasks, { - [Tasks.ConnectToMongoDB]: (cb) => { + [Tasks.ConnectToMongoDB]: async() => { const ctx = status('Connect to MongoDB'); // @note: Durran: @@ -215,6 +224,7 @@ const getTasks = (model, setupListeners) => { validOptions.useNewUrlParser = true; validOptions.useUnifiedTopology = true; + if ( model.directConnection === undefined && model.hosts.length === 1 && @@ -234,29 +244,37 @@ const getTasks = (model, setupListeners) => { setupListeners(mongoClient); } - mongoClient.connect((err, _client) => { - ctx(err); - - if (err) { - if (tunnel) { - debug('data-service connection error, shutting down ssh tunnel'); - tunnel.close(); + /** @type {Promise} */ + const waitForTunnelError = (async() => { + const [error] = await once(tunnel || new EventEmitter(), 'error'); + throw error; + })(); + + const closeTunnelOnError = async(tunnelToClose) => { + if (tunnelToClose) { + debug('data-service connection error, shutting down ssh tunnel'); + try { + await tunnelToClose.close(); + debug('ssh tunnel stopped'); + } catch (err) { + debug('ssh tunnel stopped with error: %s', err.message); } - - return cb(err); } + }; + try { + const _client = await Promise.race([ + mongoClient.connect(), + waitForTunnelError + ]); client = _client; - - if (tunnel) { - client.on('close', () => { - debug('data-service disconnected. shutting down ssh tunnel'); - tunnel.close(); - }); - } - - cb(null, { url: model.driverUrlWithSsh, options: validOptions }); - }); + ctx(null); + return { url: model.driverUrlWithSsh, options: validOptions }; + } catch (err) { + await closeTunnelOnError(tunnel); + ctx(err); + throw err; + } } }); @@ -332,7 +350,7 @@ const connect = (model, setupListeners, done) => { logTaskStatus('Successfully connected'); - return done(null, tasks.client, connectionOptions); + return done(null, tasks.client, tasks.tunnel, connectionOptions); }); return tasks.state; diff --git a/lib/ssh-tunnel.js b/lib/ssh-tunnel.js deleted file mode 100644 index 8208abbd..00000000 --- a/lib/ssh-tunnel.js +++ /dev/null @@ -1,219 +0,0 @@ -var assert = require('assert'); -var ssh2 = require('ssh2'); -var net = require('net'); -var EventEmitter = require('events').EventEmitter; -var inherits = require('util').inherits; -var debug = require('debug')('mongodb-connection-model:ssh-tunnel'); -var ssh2debug = require('debug')('ssh2:client'); -var async = require('async'); - -function SSHTunnel(model) { - assert(model.hostname, 'hostname required'); - assert(model.port, 'port required'); - - this.model = model; - this.options = this.model.sshTunnelOptions; - this.options.debug = function (msg) { - ssh2debug(msg); - }; -} -inherits(SSHTunnel, EventEmitter); - -SSHTunnel.prototype.createTunnel = function (done) { - var hadError = null; - - const onStartupError = function (err) { - hadError = err; - debug('ssh tunnel startup error', err); - done(err); - }; - - this.tunnel = new ssh2.Client(); - this.tunnel - .on('end', function () { - debug('ssh tunnel is disconnected.'); - }) - .on('close', (closeError) => { - if (!hadError && closeError) { - hadError = closeError; - } - - if (hadError) { - debug('ssh tunnel is closed due to errors.'); - } else { - debug('ssh tunnel is closed.'); - } - this.tunnel.end(); - }) - .on('error', onStartupError) - .on('ready', () => { - debug('ssh tunnel is ready.'); - this.tunnel.removeListener('error', onStartupError); - done(); - }); - - process.nextTick(() => { - try { - this.tunnel.connect(this.options); - } catch (err) { - debug('ssh tunnel error during connect call'); - onStartupError(err); - } - }); - - return this.tunnel; -}; - -SSHTunnel.prototype.forward = function (done) { - let isTimedOut = false; - - const timeout = setTimeout(() => { - isTimedOut = true; - this.tunnel.end(); - done(new Error('Timed out while waiting for forwardOut')); - }, this.options.forwardTimeout); - - const onForward = (err, stream) => { - if (isTimedOut) { - debug('port forward timed out.'); - - return null; - } - - clearTimeout(timeout); - - if (err) { - debug('error forwarding', err); - this.tunnel.end(); - - return done(err); - } - - stream.on('close', () => debug('port forward stream is closed.')); - debug('successfully forwarded'); - done(null, stream); - }; - debug( - 'forwarding', - this.options.srcAddr, - this.options.srcPort, - this.options.dstAddr, - this.options.dstPort - ); - this.tunnel.forwardOut( - this.options.srcAddr, - this.options.srcPort, - this.options.dstAddr, - this.options.dstPort, - onForward - ); -}; - -SSHTunnel.prototype.createServer = function (done) { - if (this.server) { - debug('already started server'); - done(); - - return this.server; - } - - this.server = net - .createServer((connection) => { - this.forward((err, stream) => { - if (err) { - debug('Forward failed', err); - - return done(err); - } - - connection.pipe(stream).pipe(connection); - debug('tunnel pipeline created.'); - - stream.on('close', () => { - debug('closing server'); - this.server.close(); - }); - }); - }) - .on('error', (err) => { - if (err.message.indexOf('listen EADDRINUSE') === 0) { - err.message = - `Local port ${this.options.localPort} ` + - '(chosen randomly) is already in use. ' + - 'You can click connect to try again with a different port.'; - } - debug('createServer error', err); - done(err); - this.tunnel.end(); - }) - .on('close', () => this.tunnel.end()) - .listen(this.options.localPort, this.options.localAddr, () => { - debug('local tcp server listening.'); - this.emit('status', { - message: 'Create SSH Tunnel', - complete: true - }); - done(); - }); - - return this.server; -}; - -SSHTunnel.prototype.listen = function (done) { - this.emit('status', { - message: 'Create SSH Tunnel', - pending: true - }); - async.series( - [ - this.createTunnel.bind(this), - this.forward.bind(this), - this.createServer.bind(this) - ], - (err) => { - if (err) { - err.message = `Error creating SSH Tunnel: ${err.message}`; - - return done(err); - } - - done(); - } - ); - - return this; -}; - -SSHTunnel.prototype.close = function () { - this.emit('status', { - message: 'Closing SSH Tunnel', - pending: true - }); - - if (this.tunnel) { - this.tunnel.end(); - } - - if (this.server) { - this.server.close(); - } - - this.emit('status', { - message: 'Closing SSH Tunnel', - complete: true - }); -}; - -module.exports = (model, done) => { - const tunnel = new SSHTunnel(model); - - if (model.sshTunnel === 'NONE') { - done(); - - return tunnel; - } - - tunnel.listen(done); - - return tunnel; -}; diff --git a/package-lock.json b/package-lock.json index 57945a1e..170b1f83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -187,6 +187,14 @@ } } }, + "@mongodb-js/ssh-tunnel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/ssh-tunnel/-/ssh-tunnel-1.2.0.tgz", + "integrity": "sha512-tG8CVPInP3TKUeaBFrNugQ14l5GwC4mIMuuX14aZmSCZ2olnBsPFreD5+CtMywQyUJqGkZWJDbAb6/grrn8vQw==", + "requires": { + "ssh2": "^0.8.9" + } + }, "@nodelib/fs.scandir": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.3.tgz", diff --git a/package.json b/package.json index 4359bf61..c6d50073 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,14 @@ "scripts": { "ci": "npm run check && npm test", "pretest": "mongodb-runner install", - "test": "mocha", + "test": "mocha --timeout 15000", "check": "mongodb-js-precommit" }, "peerDependencies": { "mongodb": "3.x" }, "dependencies": { + "@mongodb-js/ssh-tunnel": "^1.2.0", "ampersand-model": "^8.0.0", "ampersand-rest-collection": "^6.0.0", "async": "^3.1.0", diff --git a/test/connect.test.js b/test/connect.test.js index af1bb5a6..bed69649 100644 --- a/test/connect.test.js +++ b/test/connect.test.js @@ -4,6 +4,7 @@ const Connection = require('../'); const connect = Connection.connect; const mock = require('mock-require'); const sinon = require('sinon'); +const SSHTunnel = require('@mongodb-js/ssh-tunnel').default; const setupListeners = () => {}; @@ -27,7 +28,7 @@ describe('connection model connector', () => { connect( model, setupListeners, - (connectErr, client, { url, options }) => { + (connectErr, client, _tunnel, { url, options }) => { if (connectErr) throw connectErr; assert.strictEqual( @@ -74,13 +75,17 @@ describe('connection model connector', () => { }); describe('ssh tunnel failures', () => { - const spy = sinon.spy(); - - mock('../lib/ssh-tunnel', (model, cb) => { - // simulate successful tunnel creation - cb(); - // then return a mocked tunnel object with a spy close() function - return { close: spy }; + let closeSpy; + + mock('@mongodb-js/ssh-tunnel', { + default: class MockTunnel extends SSHTunnel { + constructor(...args) { + super(...args); + this.serverClose = closeSpy = sinon.spy( + this.serverClose.bind(this) + ); + } + } }); const MockConnection = mock.reRequire('../lib/extended-model'); @@ -99,12 +104,49 @@ describe('connection model connector', () => { assert(model.isValid()); mockConnect(model, setupListeners, (err) => { - // must throw error here, because the connection details are invalid - assert.ok(err); - assert.ok(/ECONNREFUSED/.test(err.message)); - // assert that tunnel.close() was called once - assert.ok(spy.calledOnce); - done(); + try { + // must throw error here, because the connection details are invalid + assert.ok(err); + // assert that tunnel.close() was called once + assert.ok( + closeSpy.calledOnce, + 'Expected tunnel.close to be called exactly once' + ); + done(); + } catch (e) { + done(e); + } + }); + }); + + it('should propagate tunnel error if tunnel fails to connect', (done) => { + const model = new MockConnection({ + hostname: 'localhost', + port: 27020, + sshTunnel: 'USER_PASSWORD', + sshTunnelHostname: 'my.ssh-server.com', + sshTunnelPassword: 'password', + sshTunnelUsername: 'my-user', + extraOptions: { + serverSelectionTimeoutMS: 1000, + socketTimeoutMS: 1000 + } + }); + + assert(model.isValid()); + mockConnect(model, setupListeners, (err) => { + try { + const regex = /ENOTFOUND my.ssh-server.com/; + + assert.ok(err); + assert.ok( + regex.test(err.message), + `Expected "${err.message}" to match ${regex}` + ); + done(); + } catch (e) { + done(e); + } }); }); }); diff --git a/test/ssh-tunnel.test.js b/test/ssh-tunnel.test.js deleted file mode 100644 index d30e3580..00000000 --- a/test/ssh-tunnel.test.js +++ /dev/null @@ -1,397 +0,0 @@ -const assert = require('assert'); -const Connection = require('../'); -const createSSHTunnel = require('../lib/ssh-tunnel'); -const fs = require('fs'); -const path = require('path'); - -describe('sshTunnel', function () { - it.skip('should error when ssh fails', (done) => { - const c = new Connection({ - hostname: '127.0.0.1', - sshTunnel: 'USER_PASSWORD', - sshTunnelHostname: 'my.ssh-server.com', - sshTunnelUsername: 'my-user', - sshTunnelPassword: 'password' - }); - - const tunnel = createSSHTunnel(c, (err) => { - if (err) { - return done(err); - } - - tunnel.test((_err) => { - if (!_err) { - done(new Error('Should have failed to connect')); - } - - done(); - }); - }); - }); - - describe('sshTunnelPort', () => { - it('should have the default value', () => { - const c = new Connection(); - - assert.equal(c.sshTunnelPort, 22); - }); - - it('should also accept a string', () => { - const c = new Connection({ - sshTunnelPort: '2222' - }); - - assert.equal(c.sshTunnelPort, '2222'); - }); - }); - - describe('NONE', () => { - const c = new Connection({ hostname: '127.0.0.1', sshTunnel: 'NONE' }); - - it('should be valid', () => { - assert(c.isValid()); - }); - - describe('sshTunnelOptions', () => { - it('should return an empty object', () => { - assert.equal(c.sshTunnelOptions.hostname, null); - }); - }); - }); - - describe('USER_PASSWORD', () => { - it('should require `sshTunnelHostname`', () => { - const c = new Connection({ - sshTunnel: 'USER_PASSWORD', - sshTunnelPort: 5000, - sshTunnelUsername: 'username', - sshTunnelPassword: 'password' - }); - - assert(!c.isValid()); - }); - - it('should require `sshTunnelUsername`', () => { - const c = new Connection({ - sshTunnel: 'USER_PASSWORD', - sshTunnelHostname: '127.0.0.1', - sshTunnelPort: 5000, - sshTunnelPassword: 'password' - }); - - assert(!c.isValid()); - }); - - it('should require `sshTunnelPassword`', () => { - const c = new Connection({ - sshTunnel: 'USER_PASSWORD', - sshTunnelHostname: '127.0.0.1', - sshTunnelPort: 5000, - sshTunnelUsername: 'username' - }); - - assert(!c.isValid()); - }); - - const connection = new Connection({ - sshTunnel: 'USER_PASSWORD', - sshTunnelHostname: 'my.ssh-server.com', - sshTunnelUsername: 'my-user', - hostname: 'mongodb.my-internal-host.com', - port: 27000, - sshTunnelPort: 3000, - sshTunnelPassword: 'password' - }); - - it('should be valid', () => { - assert(connection.isValid()); - }); - - describe('sshTunnelOptions', () => { - const options = connection.sshTunnelOptions; - - it('maps sshTunnelUsername -> username', () => { - assert.equal(options.username, 'my-user'); - }); - - it('maps sshTunnelHostname -> host (jumpbox visible from localhost)', () => { - assert.equal(options.host, 'my.ssh-server.com'); - }); - - it('maps hostname -> dstAddr (mongod server address from jumpbox)', () => { - assert.equal(options.dstAddr, 'mongodb.my-internal-host.com'); - }); - - it('maps port -> dstPort (mongod server port)', () => { - assert.equal(options.dstPort, 27000); - }); - - it('maps sshTunnelPort (remote sshd port) -> port', () => { - assert.equal(options.port, 3000); - }); - - it('chooses a random localPort between 29170-29899', () => { - assert.ok(options.localPort >= 29170, options.localPort); - assert.ok(options.localPort <= 29899, options.localPort); - }); - - it('maps sshTunnelPassword -> password', () => { - assert.equal(options.password, 'password'); - }); - }); - }); - - describe('IDENTITY_FILE', () => { - it('sets the private key to undefined', () => { - const connnectOptions = { - sshTunnel: 'IDENTITY_FILE', - // If we have an invalid identity directory - sshTunnelIdentityFile: ['/path/to/.ssh/me.pub'], - sshTunnelPort: 5000, - // And don't specify a derived property beforehand - // sshTunnelBindToLocalPort: 29555, - sshTunnelUsername: 'username' - }; - - assert.equal( - new Connection(connnectOptions).sshTunnelOptions.privateKey, - undefined - ); - }); - - it('should require `sshTunnelHostname`', () => { - const c = new Connection({ - sshTunnel: 'IDENTITY_FILE', - sshTunnelIdentityFile: '/path/to/.ssh/me.pub', - sshTunnelPort: 5000, - sshTunnelBindToLocalPort: 29555, - sshTunnelUsername: 'username' - }); - - assert(!c.isValid()); - }); - - it('should require `sshTunnelUsername`', () => { - const c = new Connection({ - sshTunnel: 'IDENTITY_FILE', - sshTunnelIdentityFile: '/path/to/.ssh/me.pub', - sshTunnelHostname: '127.0.0.1', - sshTunnelPort: 5000, - sshTunnelBindToLocalPort: 29555, - sshTunnelPassphrase: 'password' - }); - - assert(!c.isValid()); - }); - - it('should require `sshTunnelIdentityFile`', () => { - const c = new Connection({ - sshTunnel: 'IDENTITY_FILE', - sshTunnelUsername: 'username', - sshTunnelHostname: '127.0.0.1', - sshTunnelPort: 5000, - sshTunnelBindToLocalPort: 29555, - sshTunnelPassphrase: 'password' - }); - assert(!c.isValid()); - }); - - describe('When `sshTunnelPassphrase` is provided', () => { - const fileName = path.join(__dirname, 'fake-identity-file.txt'); - const connectionOptions = { - sshTunnel: 'IDENTITY_FILE', - sshTunnelHostname: 'my.ssh-server.com', - sshTunnelUsername: 'my-user', - sshTunnelIdentityFile: [fileName], - hostname: 'mongodb.my-internal-host.com', - port: 27000, - sshTunnelPort: 3000, - sshTunnelPassphrase: 'passphrase' - }; - const c = new Connection(connectionOptions); - - it('should be valid', () => { - assert(c.isValid()); - }); - - describe('sshTunnelOptions', () => { - const options = c.sshTunnelOptions; - - it('maps sshTunnelUsername -> username', () => { - assert.equal(options.username, 'my-user'); - }); - - it('maps sshTunnelIdentityFile -> privateKey', () => { - /* eslint no-sync: 0 */ - assert.equal( - options.privateKey.toString(), - fs.readFileSync(fileName).toString() - ); - }); - - it('maps hostname -> host', () => { - assert.equal(options.host, 'my.ssh-server.com'); - }); - - it('maps sshTunnelHostname -> host (jumpbox visible from localhost)', () => { - assert.equal(options.host, 'my.ssh-server.com'); - }); - - it('maps hostname -> dstAddr (mongod server address from jumpbox)', () => { - assert.equal(options.dstAddr, 'mongodb.my-internal-host.com'); - }); - - it('maps port -> dstPort (mongod server port)', () => { - assert.equal(options.dstPort, 27000); - }); - - it('maps sshTunnelPort (remote sshd port) -> port', () => { - assert.equal(options.port, 3000); - }); - - it('chooses a random localPort between 29170-29899', () => { - assert.ok(options.localPort >= 29170, options.localPort); - assert.ok(options.localPort <= 29899, options.localPort); - }); - - it('maps sshTunnelPassphrase -> passphrase', () => { - assert.equal(options.passphrase, 'passphrase'); - }); - - it('driverUrl does not change after setting multiple options', () => { - const driverUrl = c.driverUrl; - - // I think we have to invalidate two levels of Ampersand cache here - c.sshTunnelPassphrase = 'fooPASS'; - c.mongodbUsername = 'admin'; - assert.equal(driverUrl, c.driverUrl); - }); - }); - }); - - describe('When `sshTunnelPassphrase` is NOT provided', () => { - const fileName = path.join(__dirname, 'fake-identity-file.txt'); - const c = new Connection({ - sshTunnel: 'IDENTITY_FILE', - sshTunnelHostname: 'my.ssh-server.com', - sshTunnelUsername: 'my-user', - sshTunnelIdentityFile: [fileName], - hostname: 'mongodb.my-internal-host.com', - port: 27000, - sshTunnelPort: 3000 - }); - - it('should be valid', () => { - assert(c.isValid()); - }); - - it('should inject ssh tunnel port', (done) => { - assert.equal( - c.driverUrl, - 'mongodb://mongodb.my-internal-host.com:27000/?readPreference=primary&ssl=false' - ); - - Connection.from(c.driverUrlWithSsh, (error, sshModel) => { - assert(!error); - assert.equal(sshModel.hostname, '127.0.0.1'); - assert.notEqual(c.port, sshModel.port); - done(); - }); - }); - - describe('sshTunnelOptions', () => { - const options = c.sshTunnelOptions; - - it('maps sshTunnelUsername -> username', () => { - assert.equal(options.username, 'my-user'); - }); - - it('maps sshTunnelIdentityFile -> privateKey', () => { - /* eslint no-sync: 0 */ - assert.equal( - options.privateKey.toString(), - fs.readFileSync(fileName).toString() - ); - }); - - it('maps sshTunnelHostname -> host (jumpbox visible from localhost)', () => { - assert.equal(options.host, 'my.ssh-server.com'); - }); - - it('maps hostname -> dstAddr (mongod server address from jumpbox)', () => { - assert.equal(options.dstAddr, 'mongodb.my-internal-host.com'); - }); - - it('maps port -> dstPort (mongod server port)', () => { - assert.equal(options.dstPort, 27000); - }); - - it('maps sshTunnelPort (remote sshd port) -> port', () => { - assert.equal(options.port, 3000); - }); - - it('chooses a random localPort between 29170-29899', () => { - assert.ok(options.localPort >= 29170, options.localPort); - assert.ok(options.localPort <= 29899, options.localPort); - }); - }); - }); - }); - - describe('#functional', () => { - const setupListeners = () => {}; - - describe('aws', () => { - const identityFilePath = path.join(__dirname, 'aws-identity-file.pem'); - - before((done) => { - if (!process.env.AWS_SSH_TUNNEL_IDENTITY_FILE) { - return done(); - } - - fs.writeFile( - identityFilePath, - process.env.AWS_SSH_TUNNEL_IDENTITY_FILE, - done - ); - }); - - after((done) => { - if (!process.env.AWS_SSH_TUNNEL_IDENTITY_FILE) { - return done(); - } - - fs.unlink(identityFilePath, done); - }); - - it('should connect successfully', function (done) { - if (!process.env.AWS_SSH_TUNNEL_HOSTNAME) { - return this.skip( - 'Set the `AWS_SSH_TUNNEL_HOSTNAME` environment variable' - ); - } - - if (!process.env.AWS_SSH_TUNNEL_IDENTITY_FILE) { - return this.skip( - 'Set the `AWS_SSH_TUNNEL_IDENTITY_FILE` environment variable' - ); - } - - const c = new Connection({ - sshTunnel: 'IDENTITY_FILE', - sshTunnelHostname: process.env.AWS_SSH_TUNNEL_HOSTNAME, - sshTunnelUsername: process.env.AWS_SSH_TUNNEL_USERNAME || 'ec2-user', - sshTunnelIdentityFile: [identityFilePath] - }); - - Connection.connect(c, setupListeners, done); - }); - }); - - describe('key formats', () => { - it('should support pem'); - it('should support ppk'); - it('should error on unsupported formats'); - }); - }); -});