Привет, Хабр. Console chat отличная вещь, но для фронтендеров, а что если вы хотите такой же, но для бэкэнда. Если да, то эта статья для вас. Но какой инструмент часто используют в бэкенде? Правильно ssh, так что представляю sshchat.


Как это будет выглядеть


Где-то на сервере крутится программа на ноде.
Как только кто-то хочет, подключится к чату он вводит:


ssh server -p 8022

После этого система спрашивает пароль и сверяет его с паролем в специальном файле. Если пароль совпал, то подключаем к чату(юзер получает 100 предыдущих сообщений и все остальные видят что он подключился).


Дальше он принимает сообщения других, и может написать своё.


Вот с сообщениями поинтереснее:


@box{@color(red){Red text in box}}

Отправит красный текст в коробке.


Приступим


Для работы с ssh мы будем использовать https://www.npmjs.com/package/ssh2.
Для форматирования используем chalk и boxen.
Так что установим их:


npm i ssh2 chalk boxen

Теперь сам код одна из самых важных частей это парсер сообщений (GitHub):


// Подключаем chalk и boxen
const chalk = require('chalk');
const boxen = require('boxen');

// Здесь прописаны методы которые мы сможем использовать через @
// Функции принимают 2 аргумента то что в скобках и текс в фигурных скобках
let methods = {
  color: function(args, text) {
    return chalk.keyword(args)(text);
  },

  bold: function(args, text) {
    return chalk.bold(text);
  },

  underline: function(args, text) {
    return chalk.underline(text);
  },

  hex: function(args, text) {
    return chalk.hex(args)(text);
  },

  box: function(args, text) {
    return boxen(text, {
      borderStyle: 'round',
      padding: 1,
      borderColor: 'blueBright'
    });
  }
};

// Сам парсер 
function parseAndExecute(str) {
  let pos = 0;
  let stage = 0;
  let nS = '';
  let bufs = ['', '', '', ''];
  let level = 0;

  while (pos < str.length) {
    let symbol = str[pos];
    pos++;

    if (symbol == '\\' && '(){}@'.indexOf(str[pos]) !== -1) {
      bufs[stage] += str[pos];
      pos++;
      continue;
    }

    if (stage == 0 && symbol == '@') {
      stage++;
      nS += bufs[0];
      bufs[0] = '';
      continue;
    } else if (stage >= 1) {
      if (symbol == '(')
        if (stage < 2) {
          stage = 2;
        } else {
          level++;
        }

      if (symbol == ')' && stage >= 2 && level > 0) level--;

      if (symbol == '{')
        if (stage != 3) {
          stage = 3;
        } else {
          level++;
        }

      if (symbol == '}') {
        if (level == 0) {
          bufs[3] += '}';

          nS += methods[bufs[1]](bufs[2].slice(1, -1), parseAndExecute(bufs[3].slice(1, -1)));

          bufs = ['', '', '', ''];
          stage = 0;
          continue;
        } else {
          level--;
        }
      }
    }
    bufs[stage] += symbol;
  }
  return nS + bufs[0];
}

module.exports.parseAndExecute = parseAndExecute;

Форматирование (GitHub):


const chalk = require('chalk');
const { parseAndExecute } = require('./parserExec')

// Стилизуем ник(Генерируем цвет и делаем жирным)
function getNick(nick) {
  let hash = 0;
  for (var i = 0; i < nick.length; i++) hash += nick.charCodeAt(i) - 32;

  return chalk.hsv((hash + 160) % 360, 90, 90)(chalk.bold(nick));
}

module.exports.format = function(nick, message) {
  const nickSpace = '\r  ' + ' '.repeat(nick.length);
  nick = getNick(nick) + ': ';

  message = message.replace(/\\n/gm, '\n'); // Заменяем \n новыми строками
  message = parseAndExecute(message) // Парсим

  // Добавлям к каждой новой строке отступ
  message = message
    .split('\n')
    .map((e, i) => '' + (i !== 0 ? nickSpace : '') + e)
    .join('\n');

  return nick + message;
};

Методы для отправки сообщения всем пользователям и сохранения 100 сообщений (GitHub):


let listeners = []; // Все пользователи
let cache = new Array(100).fill('') // Кэш 

// Добавления и удаление подписчиков
module.exports.addListener = write => listeners.push(write) - 1;
module.exports.delListener = id => listeners.splice(id, 1);

// Отправляем сообщение
module.exports.broadcast = msg => {

  cache.shift()
  cache.push(msg)
  process.stdout.write(msg)
  listeners.forEach(wr => wr(msg));
}

// Получаем кэш
module.exports.getCache = ()=>cache.join('\r\033[1K')

Лобби, создание сервера и авторизация (GitHub):


const { Server } = require('ssh2');
const { readFileSync } = require('fs');

const hostKey = readFileSync('./ssh'); // Читаем ключ
const users = JSON.parse(readFileSync('./users.json')); // Юзеры

let connectionCallback = () => {};

module.exports.createServer = function createServer({ lobby }) {
  // Создаём сервер
  const server = new Server(
    {
      banner: lobby, // Баннер встречает до ввода пароля
      hostKeys: [hostKey]
    },
    function(client) {
      nick = '';
      client
        .on('authentication', ctx => {  // Авторизация
          if (ctx.method !== 'password') return ctx.reject();
          if (ctx.password !== users[ctx.username]) ctx.reject();
          nick = ctx.username;
          ctx.accept();
        })
        .on('ready', function() {
          connectionCallback(client, nick);
        });
    }
  );

  return server
};

module.exports.setConnectCallback = callback => { // Устанавливает колбэк при подключении
  connectionCallback = callback;
};

Различные методы (GitHub):


const { createInterface } = require('readline');

module.exports.getStream = function(client, onStream, onEnd){
  client  // Получает стрим и клиента
    .on('session', function(accept, reject) {
      accept()
        .on('pty', accept => accept & accept())
        .on('shell', accept => onStream(accept()));
    })
    .on('end', () => onEnd());
}

// Создаём коммуникатор 
module.exports.getCommunicator = function(stream, onMessage, onEnd){

  let readline = createInterface({ // Интерфейс для считывания строк
    input: stream,
    output: stream,
    prompt: '> ',
    historySize: 0,
    terminal: true
  })
  readline.prompt()

  readline.on('close', ()=>{
    radline = null;
    onEnd()
    stream.end()
  })

  readline.on('line', (msg)=>{
    stream.write('\033[s\033[1A\033[1K\r')
    onMessage(msg)
    readline.prompt()
  })

  // Метод для записи сообщения
  return msg=>{
    stream.write('\033[1K\r' + msg)
    readline.prompt()
  }
}

А теперь объединим (GitHub):


const { createServer, setConnectCallback } = require('./lobby');
const { getStream, getCommunicator } = require('./utils');
const { addListener, delListener, broadcast, getCache } = require('./broadcaster');
const { format, getNick } = require('./format');

//  Функция создания сервера 
module.exports = function({ lobby = 'Hi' } = {}) {
  const server = createServer({
    lobby
  });

  setConnectCallback((client, nick) => { // Ожидание соединения
    console.log('Client authenticated!');
    let id = null;
    getStream( // Получаем стрим
      client,
      stream => {
        const write = getCommunicator( // И интерфейс
          stream,
          msg => {
            if (msg == '') return;
            try {
              broadcast(format(nick, msg) + '\n'); // Как только получим сообщение, отправим его всем
            } catch (e) {}
          },
          () => {}
        );

        id = addListener(write); // Слушаем сообщения
        write('\033c' + getCache()); // Отправляем кэш
        broadcast(getNick(nick) + ' connected\n'); // Сообщаем о подключении
      },
      () => {

        delListener(id);
        broadcast(getNick(nick) + ' disconnected\n') // Сообщаем об отключении
      }
    );
  });

  server.listen(8022);
};

И финальный этап пример сервера:


const chat = require('.')

chat({})

Так же в файле users.json описаны юзеры и их пароли.


Выводы


Вот так можно написать не самый простой чат в ssh.
Для такого чата не нужно писать клиент, он обладает возможностями оформления, и его может развернуть любой желающий.


Что ещё можно сделать:


  • Добавить возможность создания своих функций оформления
  • Добавить поддержку markdown
  • Добавить поддержку ботов
  • Отправка файлов по scp
  • Увеличим безопасность паролей(хэш и соль)

Финальный репозиторий

Комментарии (40)


  1. apro
    08.09.2019 15:37

    Такую же функциональность наверное можно получить с помощью мультиплексора терминала и wall(1) ?


    1. maximmasterr Автор
      08.09.2019 15:52

      Может быть, но статья про возможности ssh в nodejs


      1. DjPhoeniX
        08.09.2019 16:18

        Возможности не раскрыты. Например, где обмен файлами между юзерами через SCP? :)


        1. maximmasterr Автор
          08.09.2019 16:21
          +1

          Ок, во второй части (если она будет) добавлю возможность отправки файлов


    1. MasMaX
      09.09.2019 10:21

      Да банально зайти в одну сессию в tmux. будете видеть вводимые команды друг друга


  1. apaskal
    08.09.2019 20:50
    +1

    Во введении имело смысл сказать о существовании команды write в unix/linux
    man write
    write — send a message to another user…


    1. maximmasterr Автор
      08.09.2019 21:19

      Ок, исправил


  1. Gurturok
    08.09.2019 21:19
    +3

    Опять js… фу.
    Вот уже есть на go: github.com/shazow/ssh-chat пользуйтесь.


    1. maximmasterr Автор
      08.09.2019 21:21
      +1

      Язык программирования всего лишь инструмент, так что писать нужно на том на чём умеешь лучше всего


      1. WGH
        10.09.2019 14:05

        Не согласен.

        Некоторые языки лучше годятся для определенных задач. У Go горутины и «автоматический» M:N, что делает его подходящим для написания сетевых программ. В тех же условиях, например, Python будет сильно проигрывать, потому что из-за проблемы раскраски функций далеко не все протоколы реализованы в asyncio-варианте. А на тредах/форках хорошо масштабирующееся приложение не напишешь.


  1. staticmain
    08.09.2019 21:25

    После этого система спрашивает пароль и сверяет его с паролем в специальном файле.
    Звучит небезопасно.


    1. maximmasterr Автор
      08.09.2019 21:29
      -1

      Может быть, зато просто. Конечно можно прикрепить БД но код увеличиться.


      1. staticmain
        08.09.2019 21:31
        -1

        Может быть, зато просто

        Т.е. вы делаете защищенный ssh-чат, а потом говорите, что надо делать не безопасно, а просто? И база вам не поможет, если там как, например, на cyberforum пароле в плейнтексте.


        1. maximmasterr Автор
          08.09.2019 21:35
          +1

          Я ничего не говорил про безопасность, я говорил про чат в терминале не требующий инсталяции.
          Код открытый, если вам нужна безопасность сделайте форк и увеличьте безопасность


          1. staticmain
            08.09.2019 21:36
            -1

            Безопасность у вас в заголовке:

            SSH provides a secure channel over an unsecured network in a client–server architecture, connecting an SSH client application with an SSH server.


            1. maximmasterr Автор
              08.09.2019 21:37

              Secure channel


              1. staticmain
                08.09.2019 21:39
                -1

                И? SSH используется для скрытия данных от посторонних глаз. Если вы что-то основываете на ssh то вы автоматически обосновываете чат как безопасный.
                Если и в мыслях не было, то почему, например, клиентом не было сделать telnet? Тоже не требует установки и в каждой кофеварке есть.


                1. maximmasterr Автор
                  08.09.2019 21:43

                  Ssh первое что пришло в голову, но если хотите то в продолжение статьи увеличу безопасность по вашим советам, предлагайте


                  1. staticmain
                    08.09.2019 21:46

                    Безопасность нужно увеличивать не по советам анонима с хабра, а по умным статьям, книгам и гайдам. А то выглядит как будто вы вообще ничего не зная про сетевую безопасность пошли писать чат.

                    В первом приближении: nakedsecurity.sophos.com/2013/11/20/serious-security-how-to-store-your-users-passwords-safely


                    1. maximmasterr Автор
                      08.09.2019 21:52
                      -1

                      Ок, добавил в список планов хэш и соль


                    1. titulusdesiderio
                      09.09.2019 09:59
                      +1

                      В ssh есть своя надёжная система авторизации по ключам. Зачем городить велосипеды?


                      1. trapwalker
                        09.09.2019 14:50
                        -1

                        Именно! А ещё можно развертывать сервер в докер-контейнере и дать возможность регистрироваться пользователям из-под гостевого аккаунта. Предусмотреть уничтожение аккаунтов, которые очень давно не заходили.


                    1. qWici
                      11.09.2019 17:16

                      Вы не думали что цель статьи рассказать об самой идеи и простой реализации? Он же не enterprise решение продает вам.


                1. AC130
                  09.09.2019 00:27

                  Если вы что-то основываете на ssh то вы автоматически обосновываете чат как безопасный.

                  В какой модели угроз?


      1. salas
        09.09.2019 08:54

        СУБД — ну вообще не про ту безопасность, которую обычно называют этим словом без пояснений. Пока у вас таблица умеренных размеров и постоянная на всё время работы приложения — всё правильно делаете, записать в json и не усложнять. Как только в приложении появляется кнопка добавления пользователя — надо не изобретать велосипед, а подключать обычную СУБД.


      1. mayorovp
        09.09.2019 10:48

        Добавьте возможность в том же самом файле хранить ключи вместо паролей — это будет всё так же просто, но безопасность значительно увеличится.


    1. eigrad
      10.09.2019 11:24
      -1

      Ой да ладно, пароль в текстовом файле, я как представлю сколько там RCE в любую сторону...


      1. mayorovp
        10.09.2019 11:30

        А RCE-то откуда? Код слишком простой, так накосячить тут попросту негде.


        1. eigrad
          10.09.2019 13:20

          Криптография, левые библиотеки nodejs с кривыми биндингами, сам nodejs, управляющие последовательности в терминале, люди которые ходят с ssh-agent по-дефолту.


          1. mayorovp
            10.09.2019 13:29

            Криптография

            OpenSSL может быть сколько угодно кривым, но лучше всё равно не придумано.


            левые библиотеки nodejs с кривыми биндингами

            Найдите хоть одну библиотеку с биндингами в предложенном решении.


            сам nodejs

            И много RCE вы в ноде знаете?


            управляющие последовательности в терминале

            А они-то каким боком могут RCE устроить?


            люди которые ходят с ssh-agent по-дефолту

            И что дальше?


            1. eigrad
              10.09.2019 14:03
              -1

              OpenSSL

              И что дальше? Зачем вообще тащить криптографию в чат сервер написанный на коленке?
              Найдите хоть одну библиотеку с биндингами в предложенном решении.

              Лучше бы она была, жирная реализация ssh на pure js — ещё больший скоуп для уязвимостей :).
              И много RCE вы в ноде знаете?

              Правильный вопрос будет — о скольких RCE в ноде я не знаю.
              А они-то каким боком могут RCE устроить?

              Ну вот тем, что могут :). Их можно использовать для формирования произвольного текста на ввод в терминале. Не везде это отключено/исправлено.
              И что дальше?

              RCE на серверах куда у этих людей есть доступ.


          1. maximmasterr Автор
            10.09.2019 15:39

            Ssh2 не такая уж и левая библиотека, nodejs используется даже крупные компании, так что rce будут находиться и патчиться


  1. gavk
    09.09.2019 05:16

    Старая добрая ирка (irc) на новый лад.


  1. orion76
    09.09.2019 06:09

    Хм… продумать архитектуру, наверное «модульную»,
    разработать-стандартизировать-описать протокол обмена данными…
    перевести на TypeScript…
    и (имхо) может получиться очень интересно и с «низким порогом» входа в совместную разработку.


    1. maximmasterr Автор
      09.09.2019 07:12

      Я вот думаю ещё одну статью сделать 'hello world' по максимуму. Там будут все инструменты которые я использую при разработке


      1. trapwalker
        09.09.2019 14:52
        -1

        И докер-контейнер нужен обязательно для сервака на случай если кто-то захочет поиграться.


        1. maximmasterr Автор
          09.09.2019 15:37

          Зачем? Года очень просто развёртывается, да и можно собрать бинарник


  1. trapwalker
    09.09.2019 15:03
    -1

    А что если правда сделать докер-файл с настроенным sshd и пользователем guest:guest. У этого пользователя зарублены все права кроме возможности выполнения одного единственного скрипта сразу при входе — скрипта регистрации нового пользователя. Этот скрипт в консольном режиме принимает желаемое имя пользователя и пароль (или ключ). После регистрации соединение рвётся. Далее происходит коннект на тот же сервер но уже по зарегистрированным реквизитам.
    При коннекте происходит подключение к буферизированному пайпу, а весь ввод построчно прогоняется через парсилку и оформлялку, которая кроме прочего дописывает юзернейм и дату-время в каждый пост.
    Можно даже чуточку отступить от идеала и сделать в тот же контейнер еще и крон-скрипт с вытесняющим автоудалением сильно старых пользователей. Грубо говоря, если пользователей, скажем, больше 1000, то удаляем всех самых наиболее давно не заходивших, пока не останется 1000.


    1. maximmasterr Автор
      09.09.2019 15:36
      +1

      Конечно, можно написать на чём угодно и как угодно


  1. frankmasonus
    10.09.2019 03:12

    Йииии-хааа… изысканый способ побольней наступить на грабли