node.jssocket.iossh2

How to ensure a single, private ssh connection using ssh2 with socket.io in Meteor


I am using ssh2 and socket.io to enable a real-time ssh connection to a remote server for users of my Meteor 1.8.1 app. The app runs on Ubuntu under Nginx and Phusion Passenger. Here is what the app needs to do:

I have the ssh connection working but I can't figure out how to destroy the ssh connection at the end of the user's session. Each time they press disconnect" then "connect", another ssh session is started and the old ssh session is still operational, so each ssh command that is sent is executed multiple times and multiple responses are sent to the browser.

I'm also concerned that the connection isn't secure; in development I'm creating the server with require('http').createServer();. In production, on my Ubuntu server with SSL configured, is it enough to use require('https').createServer(); or is there other configuration required, e.g. of Nginx? Socket.io falls back to older technologies when websocket isn't available; how is that secured?

I have read a lot of articles and stack overflow posts, but I'm finding this very confusing and most of the material is out of date. For example socketio-auth is not maintained. I can find almost nothing in the Socket.io documentation on authentication or authorization - there is a handshake entry but it's not clear to me from this whether it's the function I need or how to use it.

Here's my code.

Server

    io.on('connection', (socket) => {
        console.log('socket id', socket.id); // this shows a new id after disconnect / reconnect

        const conn = new SSHClient();

        socket.on('disconnect', () => {
            console.log('disconnect on server');
            conn.end();
        });

        conn.on('ready', () => {
            socket.emit('message', '*** SSH CONNECTION ESTABLISHED ***');
            socket.emit('ready', 'ready');

            conn.shell((err, stream) => {
                stream.write('stty -echo \n'); // don't echo our own command back, or the user's password...

                if (err) {
                    return socket.emit('message', `*** SSH SHELL ERROR: ' ${err.message} ***`);
                }
                socket.on('path', (path) => {
                    // path is a request for a directory listing
                    if (typeof path === 'string') {
                        const bashCommand = `ls -l ${path} --time-style=full-iso`;
                        console.log('*** WRITE'); // if you disconnect and reconnect this runs twice. Disconnect and reconnect again, it runs 3 times.
                        console.log('socket id again', socket.id); // this shows the same new socket id each time
                        stream.write(`${bashCommand} \n`);
                    }
                });
                stream.on('data', (d) => {
                    socket.emit('data', response); // tell the browser!
                }).on('close', () => {
                    conn.end();
                });
            });
        }).on('close', () => {
            socket.emit('message', '*** SSH CONNECTION CLOSED ***');
        }).on('error', (err) => {
            socket.emit('message', `*** SSH CONNECTION ERROR: ${err.message} ***`);
        }).connect({
            'host': hosturl,
            'username': ausername,
            'agent': anagent, // just for dev I'm using public / private key from my local machine but this will be replaced with the user's entered credentials
        });
    }).on('disconnect', () => {
        console.log('user disconnected');
    });

    server.listen(8080);

Client:

const io = require('socket.io-client');
const EventEmitter = require('events');

class MyEmitter extends EventEmitter {};
const myEmitter = new MyEmitter();

const PORT = 8080;

let socket;

myEmitter.on('connectClicked', () => {
    if (socket) {
        this.connected.set(socket.connected);
    }

    if (this.connected.get() === false) {
        socket = io(`http://localhost:${PORT}`);

        socket.on('connect', () => {
            this.connected.set(true);

            socket.on('ready', () => {
                console.log('ready');
            });

            // Backend -> Browser
            socket.on('message', (data) => {
                console.log('socket on message', data);
            });

            // Backend -> Browser
            socket.on('data', (data) => {
                console.log('got data', data);
                this.parseResponse(data); // client function to handle data, not shown here
            });

            // Browser -> Backend
            myEmitter.on('selectDirectory', () => {
                console.log('*** SELECT DIRECTORY');
                socket.emit('path', pathArray.join('/')); // path array is set in client code, it is a simple array of directory names
            });

            socket.on('disconnect', () => {
                console.log('\r\n*** Disconnected from backend***\r\n');
                this.connected.set(false);
            });
        });
    }

    myEmitter.on('disconnectClicked', () => {
        socket.disconnect();
    });
});

Solution

  • The answer to keeping the ssh connections separate is to maintain a list of current ssh connections and rework the code so that received ssh data is sent only to the browser that corresponds to the incoming message.

    I've also given up on socket.io because I can't be confident about security. I'm now using Meteor's inbuilt DDP messaging system via the Meteor Direct Stream Access package. I think this avoids opening up any new points of access to my web server.