Пишем веб-эмулятор терминала на Go, используя Websocket

в 11:16, , рубрики: javascript, php, pyte, WebSocket, Веб-разработка, метки: , , ,

Что будем писать

В моей прошлой статье мы писали простенький эмулятор терминала на PHP. Я думаю, теперь время написать что-нибудь более серьезное, на вебсокетах. Какой язык использовать для работы с вебсокетами..? Питон..? Руби..? JavaScript..? Нет! Раз уж зарелизился Go 1, давайте на нём и напишем ;). Я постараюсь не повторяться и не писать сюда целиком код. Я приведу лишь интересные, на моей взгляд, фрагменты.

Демо

Как и в прошлый раз, я не готов вам предоставить доступ к какому-либо серверу, поэтому я ограничусь видео (хостовая ОС — FreeBSD):

Исходный код веб-терминала доступен на гитхабе. Компилировать вебсокет-демон необходимо самостоятельно (командой go build -o ws) — это сделано в качестве небольшой защиты от «скрипт киддисов». Если кто-нибудь захочет выложить интерактивное демо терминала, буду очень признателен, бинарник для демона могу прислать отдельно.

Ингредиенты

Итак, нам понадобятся:

  • Установленный компилятор языка Go 1
  • Библиотека websocket (go get code.google.com/p/go.net/websocket)
  • Браузер, поддерживающий последнюю спецификацию Websocket (например последние Firefox и Chrome)
  • Любой веб-сервер с PHP (для автоматического запуска демона)

Пишем вебсокет-демон

В нашем демоне будет объединен вебсокет-сервер и эмулятор псевдотерминала. Несмотря на то, что нативной поддержки работы с псевдотерминалами в Go нет, этот язык легко интегрируется с Си, поэтому мы будем использовать соответствующие вызовы на Си для непосредственной работы с псевдотерминалом.

Работа с вебсокетами

package main
import (
	"code.google.com/p/go.net/websocket"
	"http"
	"log"
)
// наша функция-обработчик соединений
func PtyServer(ws *websocket.Conn) {
// ws — это название переменной типа *websocket.Conn, поддерживает
// простые вызовы Read() и Write() для чтения/записи в сокет
}

func main() {
	http.Handle("/ws", websocket.Handler(PtyServer)) // обрабатываем запросы на "/ws" как вебсокет
	log.Fatal(http.ListenAndServe(":12345", nil)) // слушаем на порту 12345
}
Работа с псевдотерминалом

Напишем обвязку для forkpty() и ioctl() (в ioctl() мы будем менять размеры «окна» терминала): Go хоть и неплохо интегрируется с Си, но не понимает, что pid_t и int — это одно и то же, а также не умеет работать с переменным количеством параметров в функциях на Си.

package main
/*
#cgo LDFLAGS: -lutil
#include <stdlib.h>
#include <sys/ioctl.h>
#...зависимые от системы заголовки и флаги...
int goForkpty(int *amaster, struct winsize *winp) {
	return forkpty(amaster, NULL, NULL, winp);
}
int goChangeWinsz(int fd, struct winsize *winp) {
	return ioctl(fd, TIOCSWINSZ, winp);
}
*/
import "C"

В обработчике это используем:

func PtyServer(ws *websocket.Conn) {
	cols, rows := 80, 24
	var winsz = new(C.struct_winsize)
	winsz.ws_row = C.ushort(rows);
	winsz.ws_col = C.ushort(cols);
	winsz.ws_xpixel = C.ushort(cols * 9);
	winsz.ws_ypixel = C.ushort(rows * 16);
	cpttyno := C.int(-1)
	pid := int(C.goForkpty(&cpttyno, winsz))
	pttyno := int(cpttyno)
	// ...
}
Общение между вебсокетом и псевдотерминалом

Дальше мы должны запустить, к примеру, bash и посылать вывод с соответствующего дескриптора (pttyno) в вебсокет, и наоборот, ввод с вебсокета посылать на вход pttyno — это просто. Проблема возникает тогда, когда к нам от псевдотерминала приходит незавершенная последовательность UTF-8. Мы можем читать с псевдотерминала только блоками (скажем, по 2 Кб) и конец блока может «разрезать» UTF-8 символ на 2 части — этот «обрезок» не должны посылать браузеру, иначе он просто проигнорирует этот фрагмент. Вот небольшой фрагмент кода, который корректно обрабатывает эту ситуацию:

for end = buflen - 1; end >= 0; end-- {
	if utf8.RuneStart(buf[end]) {
		ch, width := utf8.DecodeRune(buf[end:buflen])
		if ch != utf8.RuneError {
			end += width
		}
		break
	}
}

Мы должны найти в конце буфера (buf) байт, который может служить началом UTF-8 символа (в терминологии Go — rune), после чего посмотреть, цел ли этот символ. Если с последним символом всё хорошо, то возвращаем «конец» буфера обратно, иначе — уменьшаем размер буфера так, чтобы там остались только целые символы.

Отображаем вывод с псевдотерминала в браузер

Сначала для отображения вывода я использовал JSLinux, но его автор не разрешает модификацию и распространение кода своих библиотек, поэтому давайте возьмем библиотеку selectel/pyte, написанную товарищами из Селектела… Погодите, она на питоне :(! Ещё одна зависимость нам ни к чему, давайте перепишем её на Javascript :)! Порт с питона не идеален, к тому же я не особый знаток питона, но свою работу оно выполняет — Midnight Commander запускается и работает без проблем.

Принимаем ввод пользователя

Для того, чтобы принимать пользовательский ввод, я всё же заимствовал некоторое количество кода у автора JSLinux, основные принципы описаны здесь. Я также добавил возможность вставить какой-нибудь текст в поле ввода внизу (например, пароли) и добавил маппинги для клавиш F1 — F12, а также для Alt + (стрелочка налево/направо). Как оказалось, значения вводимых символов для F-клавиш зависят от переменной окружения $TERM и для vt100 вообще не определены, поскольку в VT100 их не было на клавиатуре :). Поскольку для вывода используется pyte, переменная окружения $TERM должна быть равна linux, поэтому и маппинги этих клавиш мы будем использовать для этого терминала.

Запускаем демона «по требованию»

Я реализовал вебсокет-демон таким образом, что он выходит через минуту после последнего коннекта, поэтому было бы удобно, если бы скрипт сам запускал вебсокет-демона, когда мы открываем страничку с терминалом. Код на PHP для этого очень простой:

<?php
$PORT = 13923; // port terminal daemon will be run at
system('exec nohup ./ws '.$PORT.' </dev/null >>ws.log 2>&1 &');

Если вы не знаете, что такое exec, я объясню: это специальная builtin-команда в любых UNIX шеллах, которая заставляет shell заменить себя на вызываемый процесс. То есть, у нас не будет висеть «лишний» процесс sh -c ./ws ....

Моменты, о которых я не рассказал

Я не рассказал о следующих деталях в реализации:

  • протокол общения с клиентом был немного усложнен для поддержки ресайза окна, но появился баг с вводом русских букв
  • вебсокет-демон защищен паролем, который генерируется автоматически при запуске и используется при соединении
  • используется свой bashrc, чтобы установить нужные настройки терминала
  • поскольку на сервере не происходит никакой отрисовки, а только посылаются байты, демон нагружает сервер сравнимо с sshd (т.е. загрузка CPU близка к нулю)
  • реализация pyte на javascript работает исключительно быстро: нет видимой задержки при старте Midnight Commander, пропускная способность составляет несколько тысяч строк текста в секунду
  • при закрытии окна браузера сессия корректно завершается
  • один демон может обслуживать много клиентов одновременно без проблем

Проект на github

Для интересующихся, повторю ссылки на гитхаб:
github.com/YuriyNasretdinov/WebTerm — эмулятор терминала, о котором я рассказал в статье
github.com/YuriyNasretdinov/pyte — моя реализация библиотеки selectel/pyte на Javascript (не принятая разработчиками, к сожалению)

Автор: youROCK


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js