Веб-клиент SSH: пользователь входит в приложение под своей учётной записью и сразу получает полноценный терминал (PTY) к одному общему, заранее настроенному хосту. SSH-логин и приватный ключ задаёт администратор — рядовой пользователь их не вводит и не видит.
Текущее состояние. Работает целиком: вход с двухфакторкой, полноэкранный терминал на xterm.js с темами Catppuccin, контекстное меню, мобильная раскладка со спецклавишами, регулируемый размер шрифта, три языка интерфейса (русский, украинский, английский) и панель администрирования — пользователи, настройка SSH-хоста и журнал действий.
- Docker и Docker Compose v2.
- Доменное имя, чей A/AAAA-запись указывает на этот сервер, и открытые наружу порты 80 и 443. Порт 80 нужен не для самого приложения, а для проверки владения доменом при выпуске сертификата.
Node на хосте не нужен: приложение работает только в контейнерах.
git clone https://github.com/scatari69/webssh.git
cd webssh
cp .env.example .envСгенерировать и вписать в .env два обязательных секрета:
openssl rand -base64 48 # → SESSION_SECRET
openssl rand -base64 32 # → ENCRYPTION_KEY (ровно 32 байта после декодирования)Дальше — домен и почта для Let's Encrypt:
DOMAIN=webssh.example.com
ACME_EMAIL=you@example.com
PUBLIC_ORIGIN=https://webssh.example.comPUBLIC_ORIGIN должен совпадать с адресом, по которому приложение
открывают в браузере, вплоть до схемы и порта: по нему сверяется заголовок
Origin при рукопожатии WebSocket. Ошибка здесь выглядит как «страница
открывается, терминал не подключается».
Приложение проверяет конфигурацию на старте целиком и при неполадке не
поднимается вовсе, перечислив в логе всё, что не так. Полный список
переменных с пояснениями — в .env.example; ниже только те, что нужны для
запуска.
| Переменная | Назначение |
|---|---|
SESSION_SECRET |
подпись cookie сессии, ≥32 символа |
ENCRYPTION_KEY |
мастер-ключ AES-256-GCM для секретов в БД, ровно 32 байта в base64 |
DOMAIN |
имя, на которое Caddy выпустит сертификат; localhost отключает ACME |
ACME_EMAIL |
почта для Let's Encrypt; можно оставить пустой |
PUBLIC_ORIGIN |
внешний адрес приложения целиком, со схемой |
ADMIN_INITIAL_USERNAME |
логин первого администратора, по умолчанию admin |
ADMIN_INITIAL_PASSWORD |
его пароль; пустое значение — сгенерировать |
Секреты можно не держать в переменных окружения: любой из них читается из
файла, если задать <ИМЯ>_FILE (docker secrets). Файл предпочтительнее —
содержимое переменных видно в docker inspect и в /proc/<pid>/environ.
База, приватный SSH-ключ и сертификаты лежат в каталогах проекта — data/,
keys/ и caddy/data/. Их достаточно создать один раз:
mkdir -p data keys
sudo chown -R 10001:10001 data keys
sudo chmod 700 data keyschown здесь не формальность. Приложение работает под непривилегированным
uid 10001 и root'ом права себе не выправит, а подключённый каталог, в
отличие от именованного тома, владельца из образа не наследует: он
остаётся с теми правами, с какими его создали. Забыть этот шаг не страшно —
приложение проверяет доступ на старте и печатает в лог ровно эту команду,
а не падает с ошибкой драйвера БД.
Каталоги caddy/data и caddy/config трогать не нужно: официальный образ
Caddy работает от root и создаст их сам.
Все три каталога перечислены в .gitignore — в репозиторий они не попадут.
docker compose up -d --buildВсё: Caddy сам получит сертификат Let's Encrypt, включит HTTPS и HTTP/3 и
поднимет редирект с http://. Первый выпуск занимает несколько секунд;
следить за ним удобно в логах.
docker compose ps # у app должно быть healthy
docker compose logs -f caddy # выпуск сертификата
curl https://webssh.example.com/api/health # {"status":"ok"}Локальная проверка без домена: поставьте DOMAIN=localhost и
PUBLIC_ORIGIN=https://localhost — Caddy выпустит сертификат своего
внутреннего CA (браузер его не знает, curl -k), в Let's Encrypt не пойдёт.
Учётная запись создаётся при первом старте. Если ADMIN_INITIAL_PASSWORD
пуст, пароль генерируется и один раз печатается в лог:
docker compose logs app | grep -A4 'Создан администратор'Другого способа его узнать нет — в базе только bcrypt-хеш. Если строка
потерялась, задайте новый пароль напрямую в SQLite либо начните с чистого
листа: docker compose down && sudo rm -rf data keys — при этом пропадут
и пользователи, и настройки хоста.
При первом входе администратор обязан привязать двухфакторку: форма
покажет секрет и ссылку otpauth://, дальше — код из приложения-аутен-
тификатора и десять кодов восстановления. Коды показываются один раз;
сохраните их сразу, иначе потеря телефона будет означать потерю доступа.
Администратору TOTP обязателен, поэтому привязка происходит сама при первом
входе. Остальным он доброволен и включается на странице /account — туда
ведут пункт «Двухфакторная аутентификация» в контекстном меню терминала и
кнопка в шапке админки.
Там же перевыпускаются коды восстановления (нужен свежий код из приложения) и отключается второй фактор — кроме случая, когда роль требует его обязательно: такую кнопку страница не показывает вовсе, а сервер отказал бы и так.
Пока хост не настроен, пользователи вместо терминала видят «SSH-хост ещё не
настроен администратором». Настраивается он в панели — /admin, раздел
SSH-хост:
- Хост и Порт — адрес целевой машины (имя или IP).
- Пользователь SSH — системная учётная запись на ней. Она общая для всех пользователей приложения, поэтому берите минимально привилегированную и без passwordless sudo.
- Приватный ключ — вставьте текстом или выберите файлом. Подходит
формат OpenSSH (
BEGIN OPENSSH PRIVATE KEY) и классический PEM (BEGIN RSA PRIVATE KEY); PKCS#8 (BEGIN PRIVATE KEY) библиотека ssh2 не читает — такой ключ конвертируйте:ssh-keygen -p -f ключ -m PEM. - Passphrase — если ключ зашифрован. Хранится в БД в зашифрованном виде, наружу не отдаётся никогда.
Публичная часть ключа должна лежать в ~/.ssh/authorized_keys целевого
пользователя — приложение туда её не добавляет.
Кнопка «Проверить подключение» в этом же разделе пробует подключиться тем же ключом и тем же кодом, которым пойдёт терминал, и называет точную причину отказа. Пользоваться ею стоит вместо проверки из консоли сервера: это разные вещи.
sshиз консоли сервера идёт из сетевой области хоста и вашим ключом из~/.ssh. Приложение идёт из контейнера и ключом, загруженным в панель. Поэтому «из консоли подключается, из приложения нет» — обычное дело, и причин у него две типовые:
- Адрес петлевой.
127.0.0.1иlocalhostвнутри контейнера указывают на сам контейнер, а не на машину. Если SSH-хост — та же машина, где работает webssh, укажите её адрес в сети или адрес шлюза docker (ip -4 addr show docker0), а не петлевой. Проба говорит об этом отдельной строкой.- Ключ не авторизован. Из консоли вы входите своим ключом; публичная часть того ключа, который загружен в панель, должна отдельно лежать в
~/.ssh/authorized_keysцелевого пользователя. Проба в этом случае отвечает «хост отверг ключ».
После сохранения панель покажет отпечаток ключа и адрес; можно открывать
/ и работать. Ключ хоста запоминается при первом подключении (TOFU) и
дальше сверяется строго: если он сменится, подключение прервётся с явной
ошибкой, а не молча.
Пользователей заводят там же, в разделе Пользователи: логин, роль и пароль (или «сгенерировать» — тогда пароль покажут один раз).
Проба проверяет TCP и аутентификацию — и на этом останавливается. Всё, что
происходит дальше (запрос PTY, запуск шелла) и всё, что относится не к
SSH, а к самому веб-каналу, она увидеть не может. Поэтому «в админке
Connected, а в терминале бесконечное reconnecting» — не противоречие, а
указание, что причина лежит за пределами пробы. Их две, и различает их
журнал /admin → Журнал:
- Есть записи
terminal.rejected. Дошло до SSH, и причина в записи. Чаще всегоssh_pty_failed: хост пускает по ключу, но не выдаёт терминал. Смотреть на целевой машинеPermitTTYв/etc/ssh/sshd_config(в том числе внутри блоковMatch) иcommand=/restrictв строке этого ключа вauthorized_keys— принудительная команда тоже отменяет PTY. Проверяется в одну строку:ssh -tt -i ключ пользователь@хост 'tty'— рабочий хост печатает/dev/pts/N, сломанный ругается на отсутствие tty. - Записей
terminal.rejectedнет, журнал молчит. До приложения запрос не дошёл или был отклонён на рукопожатии. ПроверятьPUBLIC_ORIGIN(должен совпадать с адресом в браузере ровно, включая схему и порт) и проксирование/ws/*обратным прокси. ЕслиOriginне совпал, отказ всё-таки попадёт в журнал — с полученным и ожидаемым адресом рядом.
Страница со своей стороны тоже перестала молчать: после трёх подряд неудавшихся рукопожатий она прямо называет обе эти причины вместо отсчёта до следующей попытки.
git pull
docker compose up -d --build # пересобрать образ и поднять новый контейнер
docker image prune -f # убрать образ прошлой версииdocker compose pull здесь не нужен и выдаст ошибку: образ приложения
собирается на месте, а не тянется из реестра.
Каталог репозитория внутрь контейнера не монтируется, поэтому один
git pull ничего не меняет — до пересборки работает прежний код. Это
касается и фронтенда: статика лежит в образе.
Единственное исключение — caddy/Caddyfile: он подключён bind-mount'ом,
и правка на диске сама по себе до Caddy не доедет, а up -d не тронет
контейнер, у которого не изменилось описание в compose. После правки
конфига прокси нужен явный docker compose restart caddy.
Пересборка занимает минуту-другую, во время неё контейнер приложения
пересоздаётся: открытые терминалы обрываются, браузер переподключается сам
и открывает новую сессию — незаписанное в файл содержимое шелла теряется.
На целевом хосте от этого спасает tmux.
Жёсткое обновление страницы больше не нужно: статика отдаётся с
Cache-Control: no-cache и ETag, то есть браузер каждый раз переспрашивает
и получает либо новый файл, либо пустой ответ 304. Раньше здесь стоял
max-age=1h, и это давало трудноуловимую поломку — страницы приходили
свежими, а модули к ним оставались из кеша, так что обновление выглядело
неприменившимся.
Остановка:
docker compose down # остановить-v тут больше ничего не удаляет: именованных томов не осталось, а
подключённые каталоги docker не трогает. Чтобы начать с чистого листа,
каталоги удаляются руками:
docker compose down && sudo rm -rf data keysДанные живут в каталогах проекта и переживают и пересборку, и
docker compose down:
| Каталог | Что внутри |
|---|---|
data/ |
SQLite: пользователи, настройки хоста, журнал |
keys/ |
приватный SSH-ключ, файл с правами 0600 |
caddy/data/ |
сертификаты Let's Encrypt и ключ ACME-аккаунта |
caddy/config/ |
служебное состояние Caddy |
Все они в .gitignore, так что git pull их не трогает, а git status
не показывает.
Бэкап — обычное копирование, приложение при этом лучше остановить, чтобы SQLite не скопировался в середине записи:
docker compose stop app
tar czf webssh-backup-$(date +%F).tar.gz data keys caddy/data .env
docker compose start appcaddy/data стоит беречь отдельно: у Let's Encrypt недельные лимиты на
повторный выпуск, и потеря этого каталога означает выпуск заново.
.env в бэкапе обязателен: без ENCRYPTION_KEY база бесполезна —
passphrase и TOTP-секреты из неё не расшифровать.
Надстройка для разработки подключается явно и монтирует исходники внутрь
контейнера с node --watch:
docker compose -f docker-compose.yml -f docker-compose.dev.yml upФайл называется docker-compose.dev.yml, а не docker-compose.override.yml
намеренно: второй compose подхватывает сам, без флагов, и обычный
docker compose up -d в бою молча получал бы оттуда NODE_ENV=development
— а с ним cookie сессии выдаётся без флага Secure и без префикса
__Host-.
docker compose run --rm app npm testИли на хосте, из каталога server/: npm ci && npm test.
Часть тестов требует внешних программ и без них помечается SKIP с
указанием причины:
- зашифрованные SSH-ключи — нужен
ssh-keygen(собрать такой ключ иначе нельзя, требуется bcrypt_pbkdf); - набор про WebSocket-терминал — нужен
sshd: он поднимает одноразовый SSH-сервер и проверяет передачу данных против настоящего демона, а не против заглушки.
В alpine доустанавливаются как apk add openssh-keygen openssh-server.
Остальные ключи тесты генерируют сами, чтобы не держать приватный ключ
фикстурой в репозитории.
cd server
npm i -D playwright && npx playwright install chromium # один раз
npm run test:e2e # все наборы
npm run test:e2e admin origin # только совпадающие по имениНаборы лежат в server/test/e2e/ и запускают настоящий Chromium против
настоящего backend и настоящего sshd. Они проверяют то, чего серверные
тесты увидеть не могут: что показано человеку на экране и что страница
делает с ответом сервера. Это не перестраховка — оба последних дефекта
жили ровно там. При самосбросе пароля сервер возвращал новый пароль
правильно, а страница успевала уйти на форму входа до того, как его
показать. При отказе в PTY сервер закрывал соединение с точной причиной,
а страница вместо неё показывала «переподключение» и повторяла попытку
до бесконечности. Серверные тесты в обоих случаях были зелёными.
| набор | что проверяет |
|---|---|
terminal.e2e.js |
терминал на десктопе и на мобильном: ввод-вывод, панель спецклавиш, размеры, контекстное меню |
admin.e2e.js |
админка: пользователи, SSH-хост, журнал, схлопывание таблиц в карточки, отсутствие секретов в ответах |
changes.e2e.js |
три языка, размер шрифта, состав контекстного меню |
account.e2e.js |
страница /account: включение и отключение второго фактора |
selfreset.e2e.js |
единственный администратор меняет пароль себе и видит новый |
reconnect.e2e.js |
постоянная ошибка SSH закрывает сессию, а не запускает цикл переподключений |
origin.e2e.js |
несовпадение PUBLIC_ORIGIN называется причиной, а не уводит в цикл входов |
caddy.e2e.js |
боевой Caddyfile настоящим Caddy: заголовки, проксирование /ws/*, X-Forwarded-For |
Playwright намеренно не записан в зависимости: он тянет браузерные
бинарники в сотни мегабайт, а сборке образа они не нужны. Набор без него
не падает, а помечается пропущенным — как и caddy.e2e.js без самого
Caddy (путь к нему можно задать в CADDY_BIN). Каждый набор идёт
отдельным процессом: конфигурация приложения читается из окружения один
раз при загрузке, а наборам нужны разные PUBLIC_ORIGIN и разные порты.
caddy/Caddyfile reverse proxy, TLS, отдельный матчер для /ws/*
docker-compose.yml app + caddy, тома, внутренняя сеть
server/src/config.js чтение и валидация окружения (fail-fast при старте)
server/src/crypto/ AES-256-GCM для секретов в БД
server/src/db/ подключение, миграции, сидинг
server/src/auth/ пароли, сессии, RBAC
server/src/services/ пользователи, журнал аудита
server/src/ssh/ хранилище конфигурации хоста и приватного ключа
server/src/routes/ REST API, страницы, раздача xterm.js
server/test/ supertest-тесты
server/test/e2e/ сквозные проверки в настоящем браузере
web/ фронтенд: страницы, стили, модули
Сборщика нет, и он не нужен. Все пакеты xterm.js поставляют
самодостаточные ESM-сборки без «голых» импортов — браузер грузит их как
есть через <script type="module">. Собственный код — обычный ES2020,
преобразовывать в нём нечего. Добавить esbuild значило бы завести шаг
сборки внутри образа ради нулевого выигрыша: Caddy отдаёт по HTTP/2, где
несколько файлов не дороже одного, а раздельные файлы кешируются лучше —
xterm на 340 КБ меняется вместе с образом, свой код правится постоянно, и
при склейке они инвалидировались бы вместе.
Файлы xterm раздаются прямо из node_modules по закрытому списку имён
(src/routes/vendor.js), поэтому копировать их в web/ при сборке не
нужно.
| Страница | Доступ |
|---|---|
/login |
форма входа, включая привязку и подтверждение TOTP |
/ |
полноэкранный терминал, подключается сразу |
/admin |
только role=admin; остальных уводит редиректом в терминал |
Темы — четыре официальные палитры Catppuccin, по умолчанию Mocha, выбор
сохраняется в localStorage. Палитра задана один раз в
web/js/common/catppuccin.js: оттуда выводятся и тема xterm, и
CSS-переменные интерфейса, иначе терминал и рамка вокруг него со временем
разошлись бы по цветам.
Тему переключает кнопка в шапке терминала (перебирает палитры по кругу) и чипы на форме входа и в админке. В контекстном меню выбора темы нет.
Русский, украинский и английский. Язык определяется по navigator.languages
при первом открытии, дальше берётся из localStorage; незнакомая локаль
даёт английский. Переключается чипами на форме входа и в админке, а в
терминале — строкой «Язык» в контекстном меню.
Весь словарь лежит в web/js/common/i18n.js и раздаётся статикой: за
переводом нет отдельного запроса, поэтому язык применяется до первой
отрисовки и страница не мигает чужим.
Тексты ошибок сервера переводятся по коду, а не берутся из поля
message. Сервер не знает, на каком языке смотрит человек, а код
(ssh_auth_failed, last_admin, csrf_token_invalid) однозначен.
Серверная строка остаётся запасной для кодов, которых в словаре ещё нет, —
так новый код на сервере не превращается в пустое место на экране.
Открывается правым кликом на десктопе и долгим тапом (500 мс, с отменой при сдвиге пальца) на мобильном: копирование, вставка, очистка, сброс, поиск, размер шрифта, язык, скачивание журнала сессии.
Шаг размера и переключение языка меню не закрывают: их нажимают подряд, сверяясь с результатом на экране, и закрытие после каждого нажатия заставляло бы открывать меню заново на каждый шаг.
Регулируется от 10 до 24 пунктов — из меню, а на десктопе ещё и
Ctrl+Shift+= / Ctrl+Shift+-. Выбор сохраняется в localStorage.
Сочетание с Shift, а не привычный по редакторам Ctrl +/−: голый Ctrl
с плюсом и минусом браузер забирает под масштаб страницы. Сверяется
event.code, поэтому оно работает и в кириллической раскладке. Обработчик
висит на attachCustomKeyEventHandler самого xterm — до document событие
не доходит, пока фокус в терминале.
По умолчанию на устройстве с касанием 15 пунктов, а на десктопе 14. Раньше было наоборот, 13 против 14, и на телефоне текст выходил мельче настольного — при том что телефон держат дальше от глаз. Ширину это почти не задевает: 15 пунктов на экране 390px дают около 43 колонок, и восьмидесяти на телефоне не будет ни при каком размере.
Смена размера меняет число колонок, поэтому за ней всегда идут подгонка и отправка новых размеров на хост: иначе программы на той стороне продолжают рисовать по старой ширине.
На мобильном высота приложения привязана к visualViewport, а не к
100vh: последний не учитывает ни сворачивающуюся адресную строку, ни
виртуальную клавиатуру. Благодаря этому панель спецклавиш (Esc, Tab,
залипающий Ctrl, стрелки, ^C) оказывается над клавиатурой, а не под ней.
overscroll-behavior: none отключает «потяни, чтобы обновить» —
иначе прокрутка терминала до края перезагружала бы страницу.
Прокрутка пальцем разбирается вручную. В xterm.js 6 полоса прокрутки
синтетическая, как в VS Code: слой .xterm-viewport ровно той же высоты,
что и содержимое, а в DOM живёт только видимый экран. Прокручивать
браузеру нечего, и палец не делал ничего — при том что колесо мыши
работало. Обработчик касаний в сборке xterm лежит, но ни к чему не
подключён (addTarget не вызывается ни разу), поэтому жест разбирается
в web/js/terminal/touchScroll.js и переводится в scrollLines —
публичный метод, не зависящий от того, чем xterm рисует полосу.
Пиксели переводятся в строки с переносом остатка: без него медленное
движение пальцем не набирает целой строки и не прокручивает вовсе.
Протяжка от 8 пикселей считается жестом, и только тогда вызывается
preventDefault — иначе тап перестал бы ставить курсор. Отпускание
запускает инерцию с затуханием.
По той же причине фокус на касании берётся по отпусканию, а не по нажатию: фокус на служебном поле xterm открывает системную клавиатуру, и при прокрутке она выскакивала бы на каждое движение пальцем. Если палец сдвинулся больше чем на 10 пикселей или открыто контекстное меню, фокус не забирается.
Журнал сессии копится только в памяти вкладки и никуда не отправляется:
серверная запись неизбежно захватила бы то, что человек набирает в
приглашениях sudo.
/admin собран из трёх разделов: пользователи (список, создание, сброс
пароля, сброс 2FA, отключение и обратное включение), единственный SSH-хост
и последние 100 записей журнала.
Таблицы на узком экране превращаются в карточки. Строка становится
блоком, заголовки колонок переезжают в подписи ячеек через
td::before { content: attr(data-label) }, и горизонтальная прокрутка не
появляется вовсе — вместо того чтобы сжимать колонки или уводить таблицу
за край. Роли (role="table", role="row", role="cell") проставлены
явно: смена display на block выбивает элементы из дерева доступности,
и без ролей экранный диктор увидел бы набор безымянных блоков.
Разрушительные действия подтверждаются диалогом, и текст подтверждения строится по тому, что реально меняется: замена ключа, сброс запомненного ключа хоста при смене адреса, удаление passphrase — каждое своим предложением. Подтверждение, которое всегда говорит одно и то же, перестают читать.
Форма ключа трёхзначна, потому что API его не отдаёт: пустое поле —
«не менять», заполненное — «заменить», галочка — «убрать passphrase».
Ключ можно вставить текстом или выбрать файлом (у input[type=file] нет
accept: у ключей обычно нет расширения, и фильтр по типам сделал бы
id_ed25519 невыбираемым).
Учётные записи не удаляются, а отключаются — на них ссылается журнал. Поэтому рядом с «Отключить» всегда стоит обратная кнопка: односторонняя операция без пути назад была бы ловушкой. Последнего активного администратора сервер отключить не даёт.
Аутентификация — cookie сессии (httpOnly, sameSite=lax, в production
Secure и префикс __Host-, который браузер принимает только вместе с
Secure и Path=/).
Мутирующие запросы требуют заголовка X-CSRF-Token. Токен выдаётся
вместе с сессией и приходит в теле ответа /api/login, /api/totp/confirm,
/api/totp/verify и /api/me; при перевыпуске сессии он меняется. Вход
(POST /api/login) исключён: сессии в этот момент ещё нет, а значит неоткуда
взяться и токену. Без заголовка — 403 csrf_token_invalid.
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
POST |
/api/login |
все | проверка пароля; см. «Вход» ниже |
POST |
/api/logout |
все | завершение сессии |
GET |
/api/me |
сессия | текущий пользователь |
POST |
/api/totp/enroll |
вход не завершён либо сессия | начало привязки: секрет и otpauth:// |
POST |
/api/totp/confirm |
то же | подтверждение кодом; выдаёт коды восстановления |
POST |
/api/totp/verify |
вход не завершён | второй фактор: код приложения или код восстановления |
POST |
/api/totp/recovery-codes |
сессия + код | перевыпуск кодов восстановления |
DELETE |
/api/totp |
сессия + код | отключить свою двухфакторку |
DELETE |
/api/admin/users/:id/totp |
admin | сброс чужой привязки |
GET |
/api/admin/users |
admin | список без секретов |
POST |
/api/admin/users |
admin | создание; пароль задаётся или генерируется |
PATCH |
/api/admin/users/:id |
admin | включение/отключение учётной записи |
PATCH |
/api/admin/users/:id/password |
admin | сброс пароля |
DELETE |
/api/admin/users/:id |
admin | деактивация (строка сохраняется) |
GET |
/api/admin/ssh-config |
admin | конфигурация хоста без ключа и passphrase |
PUT |
/api/admin/ssh-config |
admin | обновление, загрузка ключа и passphrase |
GET |
/api/admin/audit |
admin | журнал: limit (по умолчанию 100, потолок 500), offset |
GET |
/api/health |
все | liveness-проба, без подробностей |
Аутентификация — та же cookie сессии, проверяется на этапе upgrade.
Заголовок Origin, если он есть, обязан совпадать с PUBLIC_ORIGIN:
на рукопожатие WebSocket не распространяется ни SameSite, ни политика
общего происхождения, поэтому проверка ручная.
Отказ на этапе upgrade завершает рукопожатие и закрывается кодом, а не
отвечает статусом HTTP. Разница не косметическая: браузер не показывает
странице статус неудавшегося рукопожатия — до JavaScript доезжает только
1006 без всякой причины. Пока отказ отдавался статусом, несовпадение
PUBLIC_ORIGIN выглядело как бесконечное «переподключение» без единой
строчки в журнале. Теперь и код, и причина доходят до страницы, и отказ
пишется в audit_log как terminal.rejected.
Хост берётся из единственной строки ssh_config — выбора на клиенте нет.
Клиент → сервер (JSON, плюс принимаются сырые двоичные кадры как ввод):
{ "type": "data", "data": "ls -la\n" }
{ "type": "resize", "cols": 100, "rows": 30 }
{ "type": "ping" }
Сервер → клиент: двоичные кадры — байты вывода PTY как есть; текстовые — управляющие:
{ "type": "ready", "session_id": "...", "cols": 100, "rows": 30 }
{ "type": "exit", "code": 0, "signal": null }
{ "type": "error", "error": "ssh_auth_failed", "message": "…" }
{ "type": "pong" }
Вывод намеренно не заворачивается в JSON. Поток PTY — это байты, а не
текст: SSH отдаёт их произвольными порциями, и граница порции регулярно
рассекает многобайтовый символ UTF-8. Превращение такой порции в строку
подменило бы разрезанный символ на U+FFFD и необратимо испортило бы
вывод. Двоичный кадр этой проблемы не имеет вовсе.
Коды закрытия: 4401 не аутентифицирован · 4403 учётка отключена или
сессия отозвана · 4404 SSH не настроен · 4406 Origin не совпадает с
PUBLIC_ORIGIN · 4408 простой · 4409 предел одновременных сессий ·
4410 ключ хоста не совпал · 4413 кадр сверх предела · 4500
временная ошибка SSH · 4501 постоянная ошибка SSH.
Ошибки подключения разделены по причинам (ssh_auth_failed,
ssh_connection_refused, ssh_host_unresolved, ssh_timeout, …) — иначе
администратор не поймёт, чинить ключ, сеть или конфигурацию.
Временные и постоянные ошибки разделены намеренно. По 4500 клиент
повторяет попытку с растущей задержкой, по 4501 — не повторяет вовсе и
показывает причину. Отвергнутый ключ, отказ в PTY и неразрешимое имя сами
не пройдут: повтор по таймеру только жжёт батарею и прячет от человека
настоящую причину за словом «переподключение». По той же логике 4406
получил отдельный код, а не 4403: несовпадение Origin — ошибка
настройки, а не проблема сессии, и увод на форму входа загонял бы в цикл
«войти → тот же отказ → войти».
Ключ хоста проверяется всегда. Без этого ssh2 принимает любой
предъявленный сервером ключ, то есть соединение открыто для подмены
посредником. По умолчанию TOFU: первый ключ запоминается, дальше сверяется
строго, несовпадение обрывает подключение и попадает в журнал. Смена
host или port сбрасывает запомненный ключ — иначе переезд на другой
сервер навсегда упирался бы в несовпадение.
В журнал пишутся открытие и закрытие сессии с объёмом переданного,
без содержимого терминала: там пароли, которые человек набирает в
приглашениях sudo и ssh.
Двухфакторка обязательна для роли admin (TOTP_REQUIRED_FOR_ADMIN) и
добровольна для роли user.
POST /api/login с верным паролем отвечает 200 в двух разных смыслах,
и различать их нужно по полю mfa:
{ "user": {...}, "mfa": { "required": false } } вход завершён
{ "mfa": { "required": true, "enrolled": false } } дальше — /api/totp/enroll
{ "mfa": { "required": true, "enrolled": true } } дальше — /api/totp/verify
Во втором и третьем случае полноценной сессии ещё нет: GET /api/me и
админские маршруты ответят 401. Промежуточное состояние живёт 5 минут,
после чего — mfa_challenge_expired и вход начинается сначала.
Привязка при первом входе администратора: enroll возвращает секрет и
otpauth://-ссылку для QR-кода, confirm с кодом из приложения включает
TOTP, завершает вход и один раз показывает 10 кодов восстановления.
Отдельно вызывать verify после confirm не нужно — код только что
предъявлен.
Потеря устройства вместе с кодами восстановления чинится администратором:
DELETE /api/admin/users/:id/totp. Без этого доступ восстанавливался бы
только правкой БД.
Несколько неочевидных решений:
DELETEдеактивирует, а не удаляет. Записи вaudit_logссылаются на пользователя, а журнал здесь — единственный способ связать действие на общем SSH-хосте с конкретным человеком. Побочный эффект: имя остаётся занятым навсегда. Вернуть учётку в строй —PATCHсis_active: true.- Последнего активного администратора отключить нельзя (
409 last_admin): иначе управление приложением восстанавливалось бы только правкой БД. - Поля ключа в
PUT /api/admin/ssh-configтрёхзначны: поле отсутствует — не менять, строка — установить,passphrase: null— убрать. Форма админки не показывает текущий ключ и иначе не смогла бы отправить «оставить как есть». - Сброс пароля обрывает открытые сессии пользователя — по отметке
password_changed_at. Деактивация действует так же немедленно: состояние перечитывается из БД на каждом запросе, а не берётся из cookie. - Вход отвечает одинаково (
401 invalid_credentials) и на неверный пароль, и на несуществующее имя, и тратит на это одинаковое время — иначе по ответу перебирается список пользователей. - Один код TOTP принимается один раз (
totp_code_reused). Код живёт около 90 секунд, и без этого подсмотренный код срабатывал бы повторно. Практическое следствие: чтобы войти второй раз подряд, нужно дождаться смены кода на экране. - Коды восстановления хешируются SHA-256, а не bcrypt. bcrypt медленный затем, чтобы защищать пароли, выбранные человеком, — там низкая энтропия и перебор реалистичен. Здесь код случайный и содержит 80 бит, перебор невозможен при любой скорости хеша; зато медленный хеш пришлось бы прогонять против каждого неиспользованного кода, превращая проверку в рычаг исчерпания процессора.
Наружу смотрит только Caddy (80/443). Контейнер приложения портов не публикует и доступен исключительно изнутри сети compose.
Данные лежат в именованных томах: app_data (файл SQLite), ssh_keys
(приватный ключ), caddy_data (сертификаты и ключ ACME-аккаунта — том
обязан переживать пересоздание контейнеров, у Let's Encrypt недельные
лимиты на выпуск).
| Таблица | Назначение |
|---|---|
users |
учётные записи приложения, роли admin/user, TOTP, счётчики блокировки |
recovery_codes |
одноразовые коды восстановления TOTP (bcrypt-хеши) |
ssh_config |
ровно одна строка (CHECK (id = 1)): хост, порт, SSH-логин, путь к ключу, зашифрованная passphrase, политика проверки ключа хоста |
audit_log |
журнал действий |
Миграции — нумерованные .sql в server/src/db/migrations/, применяются на
старте по PRAGMA user_version, каждая в своей транзакции.
- Пароли пользователей — bcrypt (cost 12).
- Passphrase от SSH-ключа и TOTP-секреты — AES-256-GCM, ключ шифрования
(
ENCRYPTION_KEY) в базу не попадает. AAD привязывает шифротекст к конкретному полю конкретной строки, поэтому переставить блоб из одного столбца в другой нельзя. Формат блоба содержитkeyId, что позволяет ротацию ключа без единовременной перешифровки всей базы (ENCRYPTION_KEY_PREVIOUS). - Приватный SSH-ключ — файлом в томе
ssh_keysс правами 0600; в БД только путь. Наружу через API не отдаётся: админка показывает лишь отпечаток публичной части и тип ключа, поле ввода ключа — write-only. - Секреты предпочтительно подавать не переменными окружения, а файлами
(
SESSION_SECRET_FILE,ENCRYPTION_KEY_FILE, docker secrets): содержимое переменных видно вdocker inspectи в/proc/<pid>/environ.
Потеря ENCRYPTION_KEY означает, что passphrase и TOTP-секреты не
расшифровать. Восстановление — ввести их заново в админке; пароли
пользователей при этом не затрагиваются.
Два свойства этой схемы стоит держать в голове при эксплуатации.
SSH-идентичность на целевом хосте одна на всех. На уровне ОС между
пользователями приложения нет изоляции: любой может видеть файлы и процессы
остальных. Единственный источник ответа «кто именно это сделал» — таблица
audit_log. Отсюда: целевой Unix-аккаунт должен быть минимально
привилегированным, без passwordless sudo.
Вход в приложение равносилен выдаче шелла, а интерфейс доступен из
интернета. Поэтому: TOTP обязателен для роли admin и доступен роли user,
логин ограничен заградительным лимитом по IP (до обращения к bcrypt, иначе
перебор превращается в DoS) и блокировкой учётной записи, есть опциональный
IP_ALLOWLIST.
Приложение всегда работает за Caddy и доверяет X-Forwarded-For ровно от
одного прокси. Доверять произвольной цепочке нельзя: подделав заголовок,
клиент обошёл бы и лимиты, и блокировки.
| Угроза | Чем закрыта |
|---|---|
| Перехват cookie | httpOnly + Secure + префикс __Host-, HSTS от Caddy |
| Подделка межсайтового запроса | токен X-CSRF-Token в сессии, плюс sameSite=lax |
| Перехват WebSocket с чужой страницы (CSWSH) | сверка Origin на этапе upgrade — sameSite на рукопожатие не распространяется |
| XSS | CSP default-src 'none', весь код свой, внешних источников нет; разметка собирается узлами DOM, а не склейкой строк |
| Кликджекинг | frame-ancestors 'none' и X-Frame-Options: DENY |
| Подбор пароля | лимит по IP до обращения к bcrypt, блокировка учётной записи с растущей задержкой, обязательный TOTP для админа |
| Перебор кода TOTP | отдельный лимит, защита от повторного использования кода |
| Перечисление логинов | одинаковый ответ и одинаковое время для неизвестного логина (сравнение с фиктивным хешем) |
| Подмена SSH-хоста (MITM) | проверка ключа хоста, TOFU с последующей строгой сверкой |
| Утечка приватного ключа | файл 0600 в отдельном томе, наружу не отдаётся никогда — только отпечаток |
| Утечка passphrase и TOTP-секретов | AES-256-GCM с привязкой к полю и строке; в журнал не попадают |
| Исчерпание памяти через WebSocket | maxPayload рвёт кадр сверх предела до приёма, обратное давление на выводе PTY |
| Забытый открытый шелл | закрытие по простою (30 минут), лимиты одновременных сессий |
| Доступ после увольнения | отключение учётной записи и сброс пароля рвут и веб-сессии, и живые терминалы немедленно |
Чего эта схема не закрывает: разделения прав между пользователями на целевом хосте (его нет по устройству) и злоупотребления со стороны того, кому доступ выдан. Журнал отвечает на вопрос «кто», но не мешает.
- Переподключение после обрыва. Сейчас закрытие WebSocket убивает
PTY сразу. На мобильном соединение рвётся постоянно — свернул браузер,
сменил сеть, — поэтому планируется удерживать PTY ещё ~120 секунд и при
возврате проигрывать накопленный буфер (
DETACH_GRACE_MS,REPLAY_BUFFER_BYTESуже заведены). До этого момента устойчивость к обрывам даётtmuxна целевом хосте. - QR-код для привязки TOTP. Сейчас показываются секрет и ссылка
otpauth://(на мобильном она открывает аутентификатор по нажатию), на десктопе секрет вводится вручную. Нужна одна зависимость и один эндпоинт, отдающий SVG. - Список живых терминальных сессий в админке с возможностью закрыть
чужую, фильтры и постраничная листалка журнала (сервер
offsetуже принимает), ротация поAUDIT_RETENTION_DAYS. - Ротация
ENCRYPTION_KEYбез простоя: формат зашифрованного блоба уже версионирован и хранит идентификатор ключа,ENCRYPTION_KEY_PREVIOUSчитается — не хватает только команды перешифровки.