Что будем писать
В прошлой статье мы писали простенький эмулятор терминала на 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 (не принятая разработчиками, к сожалению)