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

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


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

    Похожие публикации

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

      0
      Если кому-нибудь нужны скомпилированные бинарники, чтобы попробовать на своём сервере, обращайтесь (если, конечно, не боитесь исполнять чужие бинарники :))
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Очень просто:

          freebsd$ cd /usr/ports
          freebsd$ find .


          После чего по часам (да, именно часам, а не с помощью time) измерил, сколько времени пройдет, пока снова появится приглашение.

          Получилось 180 тысяч строк, которые были отрисованы за 45 сек. То же самое через ssh с помощью Terminal.app заняло около 10 секунд.
          +3
          О, наконец-то пошли посты про Go.
            +2
            Контрольные скрины, которые мы проверяли при работе над pyte:

            aptitude
            mc
            vim
            nano
            adom
            atop
            htop
              0
              Проверил aptitude: он отображается не совсем так, как в консоли linux, почему-то не проставляются правильно фоновые цвета. vim работает без нареканий, mc тоже. apt-get install adom ничего дал, поэтому не тестировал. htop работает, но после выхода весь шрифт почему-то становится жирным. Я портировал последнюю на тот момент версию из ветки master, возможно это и не баги порта.

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

              И кстати, похоже, ваша библиотека неправильно расставляет табстопы — они начинаются с 8, а не с 7, как у вас прописано в коде. Если поставить 8, то вывод ls и некоторых других утилит, которые используют табуляцию, начинает отображаться корректно.
                0
                М… Извиняйте, но именно ловля этих блох и является главной задачей в работе по написанию эмулятора терминала. Написать конечный автомат для трёх-пяти состояний особых усилий не требует. А вот отладить все особые случаи — это да, это сложно.

                adom — это ASCII игрушка, благодаря которой мы поправили баг с «чёрным по чёрному».

                www.adom.de/adom/download.php3

                Насчёт табуляции спасибо, посмотрю внимательнее.
                  0
                  Да, я согласен, что отлов ошибок в данном случае — дело непростое, но поскольку уже есть референсная реализация, и мой порт очень близко повторяет вашу библиотеку, находить ошибки намного проще, чем когда пишешь такую библиотеку «с нуля». Я отловил пару багов буквально за час и не думаю, что оставшиеся 3-4 бага займут сильно больше времени — я просто не очень хочу этим заниматься, как я писал ниже )
                    0
                    У нас есть нерешённая проблема с инсталлером OpenSUSE, который сильно битый по сравнению с VNCterm'ом. (давняя вяловисящая бага, на которую я bobry никак не могу развести).
                      0
                      Я говорил лишь о тех багах, которых нет в реализации на питоне :). При портировании я это делал «втупую», не опираясь ни на какие доки или что-либо ещё, поэтому я не думаю, что я смогу вам как-то очень помочь в решении давно висящих багов в исходной библиотеке ;). К тому же, я это делал для фана, а вы — нет
                • НЛО прилетело и опубликовало эту надпись здесь
                    0
                    Я просто посмотрел, что и в console.log() и в текстовом редакторе вывод «ls» (под Mac OS X) отображался правильно, а с использованием и исходной библиотеки и моего порта — вся табуляция была сдвинута на один пробел, поэтому я поставил начало табуляции с индекса 8, а не 7, как было изначально, и всё стало ровно — никаких проблем с новым положением табстопов я не встречал.

                    На самом деле, я и не стремился к полной совместимости даже с вашей библиотекой, поскольку, как я понял, мне нужно всё же портировать xterm, поскольку ваша либа работает только с консолью линукса, что, к сожалению, дает большие ограничения на других платформах, на которых мой псевдотерминал работает — на Mac OS X и FreeBSD.
                    • НЛО прилетело и опубликовало эту надпись здесь
                        0
                        Наверное, я что-то не так делаю, но когда я ставлю TERM=xterm-color, у меня портится, к примеру, приглашение:

                        • НЛО прилетело и опубликовало эту надпись здесь
                            0
                            PS1='\[\e]0;\u@\h: \w\a\]${debian_chroot:+($debian_chroot)}\[\033[01;33m\]\u\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
                              0
                              Этот PS1 получился из дефолтного bashrc в убунте, кстати говоря. Насколько я понял, оно использует какие-то специфичные именно для xterm esc-последовательности, которые, по идее, должны приводить к смене заголовка или чему-то такому, и ваша библиотека этого не обрабатывает. Ещё один момент заключается в том, что почти все эмуляторы терминала «притворяются» именно xterm, а не linux, и похоже, что многие программы ведут себя сильно по-разному в зависимости от значений этой переменной, и «лучше всего» они себя ведут именно в xterm. Я не могу привести никаких пруфов, это просто моё наблюдение :). Поскольку у вас была задача — сделать доступ к консоли linux, то вашей библиотеке не обязательно решать проблемы совместимости с xterm, но я пишу именно эмулятор терминала, и эти вещи для меня важны :).
                              • НЛО прилетело и опубликовало эту надпись здесь
                                  0
                                  Я лично воспринимаю библиотеку pyte просто как ещё одну реализацию linux консоли, которая является «настоящей» консолью, нежели псевдотерминалом. Я воспринимаю её именно так в том числе потому, что у неё есть проблемы при отрисовке, когда переменная окружения TERM стоит xterm-color, и именно xterm-color является эмулятором псевдотерминала ;).
                                    +1
                                    не путайте, пожалуйста, пседво-терминалы и эмуляторы терминалов. Псевдо-терминал — абстракция создания /dev/pts, к показу буковок не имеет никакого отношения.

                                    А вот эмулятор терминала — это то, что из потока esc-последовательностей делает пользователю картинку (на VGA экране или в окне X-сервера, или даже в виде виндового окошка).

                                    xterm является эмулятором терминала и использует инфраструктуру псевдотерминалов (которую предоставляет ядро) для запуска локального шелла.

                                    2 bobry: pyte не является в чистом виде эмулятором терминала, т.к. не имеет средств отображения. Вот selecon, который pyte для рендеринга использует — является.
                                      0
                                      Да, возможно вы правы
                  0
                  Установил, поигрался — впечатляет :) Буду использовать локально вместо путти :)

                  Как я понял — с раскраской есть небольшие траблы? Например, у меня после смены пользователя и ls цвет по умолчанию становится голубым :)
                    0
                    У вас это происходит после отключения через утилиту ssh? Я тоже видел этот баг, наверное это тоже баг моего порта библиотеки selectel/pyte, хотя надо будет не полениться и проверить, в чём же на самом деле дело :)
                      0
                      Коннекчусь к убунте 11.10. Сразу вижу, что залогинен раскрашенным и жирным www-data. пишу su aleks, ввожу пароль. все не жирно и не раскрашено, пишу ls, и далее начиная с какого-нибудь файла все голубым выводит. Если запустить mc — все нормально становится.
                        0
                        А что у вас в $TERM?
                          0
                          К сожалению, смогу ответить на этот вопрос только после выходных.
                            0
                            Выходные прошли — echo $TERM пишет «linux».
                              0
                              Если можете, пришлите пожалуйста вывод ls (можно получить, к примеру, выполнив «ls --color=force >tmpfile» (или как-то так, чтобы информация о цвете обязательно попала)) мне в личку, я постараюсь посмотреть, в чём дело :)
                                0
                                См. ниже коммент с демо. Там вы сами сможете всё это сделать :)
                                  0
                                  Ага, вижу багу, сохранил файлик к себе, воспроизводится :). Действительно похоже, что проблема с недоочисткой после сброса (возможно даже сброс цвета вообще не работает :)).
                            0
                            Еще было бы круто реализовать поддержку alt+enter. В mc очень удобная штука.
                            • НЛО прилетело и опубликовало эту надпись здесь
                          0
                          классная штука, есть идеи использования
                          Единственное пожелание — использовать socket.io вместо низкоуровневого WS
                          • НЛО прилетело и опубликовало эту надпись здесь
                            0
                            Демо (Ubuntu 64 bit 11.04):

                            176.9.227.21/term-ws/shell-ws.php

                            su habr
                            пароль: 12345678

                            Как оказалось, если смотреть бравзером (firefox/chrome) из винды и запустить mc — всё немного печально когда походишь по дирикториям. Из убунты когда пробовал таких проблем не было.

                            Кроме того, как я заметил, в описании на гитхабе произошли небольшие изменения. Пришлось вспоминать про «go build».
                              0
                              Большое спасибо (хоть и поздновато, конечно :))! Да, я обновил немного описание, по идее достаточно выполнить go get ..., после чего, если бинарь будет в $PATH, то shell-ws.php его сам найдет, если не в $PATH, то достаточно символическую ссылку на него создать. Впрочем, просто «go build» тоже сработает :).

                              Кстати, я починил багу с вводом русских букв.

                              У меня винды нет, поэтому на ней не проверял, наверное дело в том, что на винде может не быть каких-нибудь юникодовых символов, хотя, честно говоря, не знаю :). Если будет возможность — посмотрю, что там. Честно говоря, я взял «Courier New» просто потому, что в нём было меньше всего косяков с отображением и больше всего юникодовых символов — там могут быть проблемы с шириной текста, если каких-то символов нет, и Courier New имеет самую лучшую поддержку юникода в макоси (наверное для других ОС это тоже справедливо).
                                0
                                Кстати, я ещё чуть-чуть обновил код :), ибо в вашей демо я нашел багу с тем, что дескрипторы для общения с псевдотерминалом не высвобождаются, что может привести к тому, что в конечном счёте они закончатся и демо перестанет работать без видимых на то причин.
                                  0
                                  По-поводу винды — там именно всё начинается, когда переходишь на уровень ниже в дириктории (cd ..) надеюсь, почините (ну и альт+ентер добавите :)). Может всё из-за разных символов перехода на новую строку?

                                  По-поводу $PATH — понял — однако, есть такая штука — я когда go устонавливал писал «export PATH=$PATH:/usr/local/go/bin» под рутом. А когда захожу в ваш эмулятор — он то коннектится юзером www-data и там $PATH — другой — т.е. немного законфузило почему же не работает.

                                  Сегодня сутра зашел и терминал не работал — прибил процесс term-ws и всё заработало. Как я понимаю, это и есть бага, которую вы описали?
                                    0
                                    > По-поводу винды — там именно всё начинается, когда переходишь на уровень ниже в дириктории (cd ..)
                                    Я посмотрю, но ничего не обещаю :)

                                    > ну и альт+ентер добавите :)
                                    По идее, мне надо не лениться и добавить все сочетания клавиш с Alt и Ctrl, я только добавил Alt + стрелочки направо и ещё что-то.

                                    Сегодня сутра зашел и терминал не работал — прибил процесс term-ws и всё заработало. Как я понимаю, это и есть бага, которую вы описали?

                                    На самом деле я не тестировал работу демона под большой нагрузкой и в течение продолжительного времени, и, по всей видимости, это не та бага, которую я описал, на самом деле. Оно просто почему-то долго коннектится, но всё же в конце концов «просирается», просто это много времени занимает, не знаю, почему :).
                                  0
                                  Я починил багу с тем, что курсор не полностью сбрасывается, теперь должно быть намного лучше. Впрочем, недочёты в отрисовке всё равно есть, к примеру, белый фон почему-то не рисуется, как надо. Честно говоря, я так понимаю, что библиотека pyte всё же не все случаи охватывает и не полностью задаёт цвет символов. «Черный на черном» я пофиксил именно в коде отрисовке, а не где-то ещё, и хрен его знает, сколько ещё драконов скрывается в этом коде :))

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое