// **BEFORE RUNNING THIS SCRIPT:** // 1. The server portion is best run on non-Windows systems because they have // terminfo databases which are needed to properly work with different // terminal types of client connections // 2. Install `blessed`: `npm install blessed` // 3. Create a server host key in this same directory and name it `host.key` var fs = require('fs'); var blessed = require('blessed'); var Server = require('ssh2').Server; var RE_SPECIAL = /[\x00-\x1F\x7F]+|(?:\x1B\[([0-9]{1,2}(;[0-9]{1,2})?)?[m|K])/g; var MAX_MSG_LEN = 128; var MAX_NAME_LEN = 10; var PROMPT_NAME = 'Enter a nickname to use (max ' + MAX_NAME_LEN + ' chars): '; var users = []; function formatMessage(msg, output) { var output = output; output.parseTags = true; msg = output._parseTags(msg); output.parseTags = false; return msg; } function userBroadcast(msg, source) { var sourceMsg = '> ' + msg; var name = '{cyan-fg}{bold}' + source.name + '{/}'; msg = ': ' + msg; for (var i = 0; i < users.length; ++i) { var user = users[i]; var output = user.output; if (source === user) output.add(sourceMsg); else output.add(formatMessage(name, output) + msg); } } function localMessage(msg, source) { var output = source.output; output.add(formatMessage(msg, output)); } function noop(v) {} new Server({ hostKeys: [fs.readFileSync('host.key')], }, function(client) { var stream; var name; client.on('authentication', function(ctx) { var nick = ctx.username; var prompt = PROMPT_NAME; var lowered; // Try to use username as nickname if (nick.length > 0 && nick.length <= MAX_NAME_LEN) { lowered = nick.toLowerCase(); var ok = true; for (var i = 0; i < users.length; ++i) { if (users[i].name.toLowerCase() === lowered) { ok = false; prompt = 'That nickname is already in use.\n' + PROMPT_NAME; break; } } if (ok) { name = nick; return ctx.accept(); } } else if (nick.length === 0) prompt = 'A nickname is required.\n' + PROMPT_NAME; else prompt = 'That nickname is too long.\n' + PROMPT_NAME; if (ctx.method !== 'keyboard-interactive') return ctx.reject(['keyboard-interactive']); ctx.prompt(prompt, function retryPrompt(answers) { if (answers.length === 0) return ctx.reject(['keyboard-interactive']); nick = answers[0]; if (nick.length > MAX_NAME_LEN) { return ctx.prompt('That nickname is too long.\n' + PROMPT_NAME, retryPrompt); } else if (nick.length === 0) { return ctx.prompt('A nickname is required.\n' + PROMPT_NAME, retryPrompt); } lowered = nick.toLowerCase(); for (var i = 0; i < users.length; ++i) { if (users[i].name.toLowerCase() === lowered) { return ctx.prompt('That nickname is already in use.\n' + PROMPT_NAME, retryPrompt); } } name = nick; ctx.accept(); }); }).on('ready', function() { var rows; var cols; var term; client.once('session', function(accept, reject) { accept().once('pty', function(accept, reject, info) { rows = info.rows; cols = info.cols; term = info.term; accept && accept(); }).on('window-change', function(accept, reject, info) { rows = info.rows; cols = info.cols; if (stream) { stream.rows = rows; stream.columns = cols; stream.emit('resize'); } accept && accept(); }).once('shell', function(accept, reject) { stream = accept(); users.push(stream); stream.name = name; stream.rows = rows || 24; stream.columns = cols || 80; stream.isTTY = true; stream.setRawMode = noop; stream.on('error', noop); var screen = new blessed.screen({ autoPadding: true, smartCSR: true, program: new blessed.program({ input: stream, output: stream }), terminal: term || 'ansi' }); screen.title = 'SSH Chatting as ' + name; // Disable local echo screen.program.attr('invisible', true); var output = stream.output = new blessed.log({ screen: screen, top: 0, left: 0, width: '100%', bottom: 2, scrollOnInput: true }) screen.append(output); screen.append(new blessed.box({ screen: screen, height: 1, bottom: 1, left: 0, width: '100%', type: 'line', ch: '=' })); var input = new blessed.textbox({ screen: screen, bottom: 0, height: 1, width: '100%', inputOnFocus: true }); screen.append(input); input.focus(); // Local greetings localMessage('{blue-bg}{white-fg}{bold}Welcome to SSH Chat!{/}\n' + 'There are {bold}' + (users.length - 1) + '{/} other user(s) connected.\n' + 'Type /quit or /exit to exit the chat.', stream); // Let everyone else know that this user just joined for (var i = 0; i < users.length; ++i) { var user = users[i]; var output = user.output; if (user === stream) continue; output.add(formatMessage('{green-fg}*** {bold}', output) + name + formatMessage('{/bold} has joined the chat{/}', output)); } screen.render(); // XXX This fake resize event is needed for some terminals in order to // have everything display correctly screen.program.emit('resize'); // Read a line of input from the user input.on('submit', function(line) { input.clearValue(); screen.render(); if (!input.focused) input.focus(); line = line.replace(RE_SPECIAL, '').trim(); if (line.length > MAX_MSG_LEN) line = line.substring(0, MAX_MSG_LEN); if (line.length > 0) { if (line === '/quit' || line === '/exit') stream.end(); else userBroadcast(line, stream); } }); }); }); }).on('end', function() { if (stream !== undefined) { spliceOne(users, users.indexOf(stream)); // Let everyone else know that this user just left for (var i = 0; i < users.length; ++i) { var user = users[i]; var output = user.output; output.add(formatMessage('{magenta-fg}*** {bold}', output) + name + formatMessage('{/bold} has left the chat{/}', output)); } } }).on('error', function(err) { // Ignore errors }); }).listen(0, function() { console.log('Listening on port ' + this.address().port); }); function spliceOne(list, index) { for (var i = index, k = i + 1, n = list.length; k < n; i += 1, k += 1) list[i] = list[k]; list.pop(); }