31

I have been scouring the face of the web looking to answer a question which I had thought would be simple. My goal is straight forward. I want to build out a simple web-based SSH client using Node.js module(s). I have found several options if I want to connect to the node server itself, but can't seem to find any examples of connecting to a REMOTE server.

Essentially the outcome I am looking for is a workflow like this : Connect to webserver -> Click on a server name in a list of servers -> Enter SSH session to the server I clicked on

The only thing I have found that's even remotely close to what I am looking for is guacamole. I do not want to use guacamole, however, as I want this application to be OS independent. Currently I am building it on a windows 10 platform, and will port it over to fedora when I am done.

I found this tutorial for creating an SSH terminal. However, all this does is creates (or attempts to create) an SSH connection to the local system.

Another options that looked absolutely fantastic was tty.js. Alas, the bottom-line is the same as the above tutorial. The module only allows you to connect to the node.js server, NOT to remote servers.

Anyone have information on a possible path to this goal?

vinS
  • 1,417
  • 5
  • 24
  • 37
Ethan
  • 787
  • 1
  • 8
  • 28

4 Answers4

58

This is easily doable with modules like ssh2, xterm, and socket.io.

Here's an example:

  1. npm install ssh2 xterm socket.io
  2. Create index.html:
<html>
  <head>
    <title>SSH Terminal</title>
    <link rel="stylesheet" href="/src/xterm.css" />
    <script src="/src/xterm.js"></script>
    <script src="/addons/fit/fit.js"></script>
    <script src="/socket.io/socket.io.js"></script>
    <script>
      window.addEventListener('load', function() {
        var terminalContainer = document.getElementById('terminal-container');
        var term = new Terminal({ cursorBlink: true });
        term.open(terminalContainer);
        term.fit();

        var socket = io.connect();
        socket.on('connect', function() {
          term.write('\r\n*** Connected to backend***\r\n');

          // Browser -> Backend
          term.on('data', function(data) {
            socket.emit('data', data);
          });

          // Backend -> Browser
          socket.on('data', function(data) {
            term.write(data);
          });

          socket.on('disconnect', function() {
            term.write('\r\n*** Disconnected from backend***\r\n');
          });
        });
      }, false);
    </script>
    <style>
      body {
        font-family: helvetica, sans-serif, arial;
        font-size: 1em;
        color: #111;
      }
      h1 {
        text-align: center;
      }
      #terminal-container {
        width: 960px;
        height: 600px;
        margin: 0 auto;
        padding: 2px;
      }
      #terminal-container .terminal {
        background-color: #111;
        color: #fafafa;
        padding: 2px;
      }
      #terminal-container .terminal:focus .terminal-cursor {
        background-color: #fafafa;
      }
    </style>
  </head>
  <body>
    <div id="terminal-container"></div>
  </body>
</html>
  1. Create server.js:
var fs = require('fs');
var path = require('path');
var server = require('http').createServer(onRequest);

var io = require('socket.io')(server);
var SSHClient = require('ssh2').Client;

// Load static files into memory
var staticFiles = {};
var basePath = path.join(require.resolve('xterm'), '..');
[ 'addons/fit/fit.js',
  'src/xterm.css',
  'src/xterm.js'
].forEach(function(f) {
  staticFiles['/' + f] = fs.readFileSync(path.join(basePath, f));
});
staticFiles['/'] = fs.readFileSync('index.html');

// Handle static file serving
function onRequest(req, res) {
  var file;
  if (req.method === 'GET' && (file = staticFiles[req.url])) {
    res.writeHead(200, {
      'Content-Type': 'text/'
                      + (/css$/.test(req.url)
                         ? 'css'
                         : (/js$/.test(req.url) ? 'javascript' : 'html'))
    });
    return res.end(file);
  }
  res.writeHead(404);
  res.end();
}

io.on('connection', function(socket) {
  var conn = new SSHClient();
  conn.on('ready', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION ESTABLISHED ***\r\n');
    conn.shell(function(err, stream) {
      if (err)
        return socket.emit('data', '\r\n*** SSH SHELL ERROR: ' + err.message + ' ***\r\n');
      socket.on('data', function(data) {
        stream.write(data);
      });
      stream.on('data', function(d) {
        socket.emit('data', d.toString('binary'));
      }).on('close', function() {
        conn.end();
      });
    });
  }).on('close', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION CLOSED ***\r\n');
  }).on('error', function(err) {
    socket.emit('data', '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n');
  }).connect({
    host: '192.168.100.105',
    username: 'foo',
    password: 'barbaz'
  });
});

server.listen(8000);
  1. Edit the SSH server configuration passed to .connect() in server.js
  2. node server.js
  3. Visit http://localhost:8000 in your browser
mscdex
  • 104,356
  • 15
  • 192
  • 153
  • 5
    Dude, you absolutely rock! :) – Ethan Aug 02 '16 at 03:19
  • 2
    Don't happen to have something similar for RDP & VNC do ya? :) – Ethan Aug 02 '16 at 03:19
  • A quick search for vnc turns up [this](http://blog.mgechev.com/2013/08/30/vnc-javascript-nodejs/). It's a bit old and could stand to be optimized a bit, but it might still work. As far as RDP goes, I have not seen an RDP implementation/binding for node yet. – mscdex Aug 02 '16 at 05:51
  • Yep, that's the same thing I found for VNC. I'll mess with that eventually. For RDP, I think this'll work https://github.com/citronneur/node-rdpjs but I'm going to have to find some better documentation than what's on the GIT page. – Ethan Aug 02 '16 at 18:06
  • @mscdex When I go to http://localhost:8000 it doesn't allow the passing of GET variables (produces white screen). What modification would need to be made to the answer code above that would allow http://localhost:8000/?foo=bar and then to have access to foo in onRequest(req, res) ? – AndrewVT Dec 13 '16 at 00:35
  • 1
    @user2058037 You'd have to parse [`req.url`](https://nodejs.org/docs/latest/api/http.html#http_message_url) like [`url.parse(req.url, true)`](https://nodejs.org/docs/latest/api/url.html#url_url_parse_urlstring_parsequerystring_slashesdenotehost). Then use `.pathname` from the resulting object instead of `req.url` for the `staticFiles[]` lookup. – mscdex Dec 13 '16 at 01:44
  • hi @mscdex, i need to record this terminal.how do i do that? – mohammad amin Jun 13 '17 at 22:23
  • @mscdex i found some record solution like asciinema, but user can stop recording. i wanna record terminal in backend in main server (the server nodejs run, not remote one). please help. – mohammad amin Jun 13 '17 at 22:26
  • something I am missing. Suppose I have a PC behind a NAT. And I want to access it from Internet. Are these guidelines useful for? – gdm Jun 18 '17 at 13:33
  • Great piece of code. I have to warn others that this (and socket.io in general) **will not work** if you happen to use the **express-status-monitor package**. You will get a "Error during WebSocket handshake: Unexpected response code: 400" and it will drive you crazy. Cheers. – nimi Nov 03 '19 at 16:23
  • Can you please specify the versions used here? With the latest versions as of 2020/04/30 and Node 12, this fails with: `Error: ENOENT: no such file or directory, open '/home/allan.lewis/git/stash-user/yvrc-ssh-term/node_modules/xterm/lib/addons/fit/fit.js'` – Allan Lewis Apr 30 '20 at 10:14
  • I think the root cause of my issue above is that the `fit` addon has moved into a separate package, `xterm-addon-fit`. – Allan Lewis Apr 30 '20 at 10:21
  • This is awesome. Note that the xterm API has changed, so instead of term.on('data'... I used term.onData(... – Little Brain May 05 '20 at 18:14
  • Is there any way to do the same with python as backend? – pab789 Sep 04 '20 at 10:19
  • does this code work if i want to connect with my local machine instead of remote machine, i'm getting ECONNREFUSED error. – Alok Deshwal Jan 26 '21 at 06:24
  • Can this solution be used with Java Spring Boot App? I have an Angular + Spring Boot app where I want to connect using SSH, RDP, and Telnet. – mor222 Apr 11 '21 at 05:33
6

Just adding updated code to @mscdex great answer because the libraries have changed over the years.

Libraries:

npm install express socket.io ssh2 xterm xterm-addon-fit

index.html:

<html>
  <head>
    <title>SSH Terminal</title>
    <link rel="stylesheet" href="/xterm.css" />
    <script src="/xterm.js"></script>
    <script src="/xterm-addon-fit.js"></script>
    <script src="/socket.io/socket.io.js"></script>
    <script>
      window.addEventListener('load', function() {
        var terminalContainer = document.getElementById('terminal-container');
        const term = new Terminal({ cursorBlink: true });        
        const fitAddon = new FitAddon.FitAddon();
        term.loadAddon(fitAddon);
        term.open(terminalContainer);
        fitAddon.fit();

        var socket = io() //.connect();
        socket.on('connect', function() {
          term.write('\r\n*** Connected to backend ***\r\n');
        });

        // Browser -> Backend
        term.onKey(function (ev) {
          socket.emit('data', ev.key);
        });

        // Backend -> Browser
        socket.on('data', function(data) {
          term.write(data);
        });

        socket.on('disconnect', function() {
          term.write('\r\n*** Disconnected from backend ***\r\n');
        });
      }, false);
    </script>
    <style>
      body {
        font-family: helvetica, sans-serif, arial;
        font-size: 1em;
        color: #111;
      }
      h1 {
        text-align: center;
      }
      #terminal-container {
        width: 960px;
        height: 600px;
        margin: 0 auto;
        padding: 2px;
      }
      #terminal-container .terminal {
        background-color: #111;
        color: #fafafa;
        padding: 2px;
      }
      #terminal-container .terminal:focus .terminal-cursor {
        background-color: #fafafa;
      }
    </style>
  </head>
  <body>
    <h3>WebSSH</h3>
    <div id="terminal-container"></div>
  </body>
</html>

server.js:

var fs = require('fs');
var path = require('path');
var server = require('http').createServer(onRequest);

var io = require('socket.io')(server);
var SSHClient = require('ssh2').Client;

// Load static files into memory
var staticFiles = {};
var basePath = path.join(require.resolve('xterm'), '..');
staticFiles['/xterm.css'] = fs.readFileSync(path.join(basePath, '../css/xterm.css'));
staticFiles['/xterm.js'] = fs.readFileSync(path.join(basePath, 'xterm.js'));
basePath = path.join(require.resolve('xterm-addon-fit'), '..');
staticFiles['/xterm-addon-fit.js'] = fs.readFileSync(path.join(basePath, 'xterm-addon-fit.js'));
staticFiles['/'] = fs.readFileSync('index.html');

// Handle static file serving
function onRequest(req, res) {
  var file;
  if (req.method === 'GET' && (file = staticFiles[req.url])) {
    res.writeHead(200, {
      'Content-Type': 'text/'
        + (/css$/.test(req.url)
        ? 'css'
        : (/js$/.test(req.url) ? 'javascript' : 'html'))
    });
    return res.end(file);
  }
  res.writeHead(404);
  res.end();
}

io.on('connection', function(socket) {
  var conn = new SSHClient();
  conn.on('ready', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION ESTABLISHED ***\r\n');
    conn.shell(function(err, stream) {
      if (err)
        return socket.emit('data', '\r\n*** SSH SHELL ERROR: ' + err.message + ' ***\r\n');
      socket.on('data', function(data) {
        stream.write(data);
      });
      stream.on('data', function(d) {
        socket.emit('data', d.toString('binary'));
      }).on('close', function() {
        conn.end();
      });
    });
  }).on('close', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION CLOSED ***\r\n');
  }).on('error', function(err) {
    socket.emit('data', '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n');
  }).connect({
    host: 'domain.tld',
    port: 22,
    username: 'root',
    privateKey: require('fs').readFileSync('path/to/keyfile')
  });
});

let port = 8000;
console.log('Listening on port', port)
server.listen(port);
bluepuma77
  • 143
  • 1
  • 7
  • You should not use the `onKey` event, it is only there for low level event access and rarely needed. Instead use `onData` and `onBinary`, these will nicely wrap everything into bytes meant for the IO sink (incl. mouse reports). – jerch Feb 14 '21 at 22:58
5

Same as the above answer but actually using express and modern syntax and libraries

const express = require('express');
const app = express();
const http = require('http').Server(app);
const io = require('socket.io')(http, {
  cors: {
    origin: "*"
  }
});
app.set('view engine', 'ejs');
app.use(express.urlencoded({
  extended: false,
  limit: '150mb'
}));
app.use(express.static(__dirname + '/public'));
app.use('/xterm.css', express.static(require.resolve('xterm/css/xterm.css')));
app.use('/xterm.js', express.static(require.resolve('xterm')));
app.use('/xterm-addon-fit.js', express.static(require.resolve('xterm-addon-fit')));

const SSHClient = require('ssh2').Client;

app.get('/', (req, res) => {
  // res.sendFile(__dirname + '/index.html');
  res.render('index');
  // I am using ejs as my templating engine but HTML file work just fine.
});

io.on('connection', function(socket) {
  var conn = new SSHClient();
  conn.on('ready', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION ESTABLISHED ***\r\n');
    conn.shell(function(err, stream) {
      if (err)
        return socket.emit('data', '\r\n*** SSH SHELL ERROR: ' + err.message + ' ***\r\n');
      socket.on('data', function(data) {
        stream.write(data);
      });
      stream.on('data', function(d) {
        socket.emit('data', d.toString('binary'));
      }).on('close', function() {
        conn.end();
      });
    });
  }).on('close', function() {
    socket.emit('data', '\r\n*** SSH CONNECTION CLOSED ***\r\n');
  }).on('error', function(err) {
    socket.emit('data', '\r\n*** SSH CONNECTION ERROR: ' + err.message + ' ***\r\n');
  }).connect({
    host: '192.168.0.103',
    port: 22,
    username: 'kali',
    password: 'kali'
  });
});

http.listen(3000, () => {
  console.log('Listening on http://localhost:3000');
});
* {
  padding: 0%;
  margin: 0%;
  box-sizing: border-box;
}

body {
  font-family: Helvetica, sans-serif, arial;
  font-size: 1em;
  color: #111;
}

h1 {
  text-align: center;
}

#terminal-container {
  width: 960px;
  height: 600px;
  margin: 0 auto;
  padding: 2px;
}

#terminal-container .terminal {
  background-color: #111;
  color: #fafafa;
  padding: 2px;
}

#terminal-container .terminal:focus .terminal-cursor {
  background-color: #fafafa;
}
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>SSH SERVER</title>
  <link rel="stylesheet" href="/xterm.css" />
  <script defer src="/xterm.js"></script>
  <script defer src="/xterm-addon-fit.js"></script>
  <script defer src="/socket.io/socket.io.js"></script>
  <script defer src='/js/app.js'></script>
  <link rel='stylesheet' href='/css/main.css'>
</head>

<body>
  <h3>WebSSH</h3>
  <div id="terminal-container"></div>
  <script>
  // PLEASE USE A SEPERATE FILE FOR THE JS and defer it
  // like the above app.js file 
    window.addEventListener('load', function() {
      const terminalContainer = document.getElementById('terminal-container');
      const term = new Terminal({
        cursorBlink: true
      });
      const fitAddon = new FitAddon.FitAddon();
      term.loadAddon(fitAddon);
      term.open(terminalContainer);
      fitAddon.fit();

      const socket = io() //.connect();
      socket.on('connect', function() {
        term.write('\r\n*** Connected to backend ***\r\n');
      });

      // Browser -> Backend
      term.onKey(function(ev) {
        socket.emit('data', ev.key);
      });

      // Backend -> Browser
      socket.on('data', function(data) {
        term.write(data);
      });

      socket.on('disconnect', function() {
        term.write('\r\n*** Disconnected from backend ***\r\n');
      });
    }, false);
  </script>
</body>

</html>
Elliot404
  • 127
  • 2
  • 3
2

Try also noVnc. However, a little dig within the page of xterm.js reveals other solutions, like

WebSSH2

gdm
  • 7,647
  • 3
  • 41
  • 71