Как стать автором
Обновить

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

Время на прочтение5 мин
Количество просмотров7.1K

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


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

Демо


Спасибо пользователю Aleks_ja за возможность посмотреть эмулятор терминала в действии (нужен браузер с последней версией вебсокетов — например это Firefox 11 или последний Chrome). С первого раза оно может не подключиться, если демон не успеет стартовать за 100 мс — попробуйте сначала обновить страницу.



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

Ингредиенты


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

  • Установленный компилятор языка 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 // множественное присваивание с авто-типом int
	var winsz = new(C.struct_winsize) // ещё можно написать "var name = ..." — это то же самое
	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-- { // синтаксис циклов в Go не требует скобок
	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, пропускная способность составляет несколько тысяч строк текста в секунду
  • при закрытии окна браузера сессия корректно завершается
  • один демон может обслуживать много клиентов одновременно без проблем
  • за счёт использования тега <audio> и звуков из ubuntu, терминал умеет «бибикать» :)


Проект на github


Для интересующихся, повторю ссылки на гитхаб:
github.com/YuriyNasretdinov/WebTerm — эмулятор терминала, о котором я рассказал в статье
github.com/YuriyNasretdinov/pyte — моя реализация библиотеки selectel/pyte на Javascript (не принятая разработчиками, к сожалению)
Теги:
Хабы:
Всего голосов 38: ↑30 и ↓8+22
Комментарии40

Публикации

Истории

Работа

PHP программист
192 вакансии
React разработчик
84 вакансии

Ближайшие события

19 августа – 20 октября
RuCode.Финал. Чемпионат по алгоритмическому программированию и ИИ
МоскваНижний НовгородЕкатеринбургСтавропольНовосибрискКалининградПермьВладивостокЧитаКраснорскТомскИжевскПетрозаводскКазаньКурскТюменьВолгоградУфаМурманскБишкекСочиУльяновскСаратовИркутскДолгопрудныйОнлайн
3 – 18 октября
Kokoc Hackathon 2024
Онлайн
10 – 11 октября
HR IT & Team Lead конференция «Битва за IT-таланты»
МоскваОнлайн
25 октября
Конференция по росту продуктов EGC’24
МоскваОнлайн
7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн