Skip to content

Repository files navigation

webssh

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

Текущее состояние. Работает целиком: вход с двухфакторкой, полноэкранный терминал на xterm.js с темами Catppuccin, контекстное меню, мобильная раскладка со спецклавишами, регулируемый размер шрифта, три языка интерфейса (русский, украинский, английский) и панель администрирования — пользователи, настройка SSH-хоста и журнал действий.

Требования

  • Docker и Docker Compose v2.
  • Доменное имя, чей A/AAAA-запись указывает на этот сервер, и открытые наружу порты 80 и 443. Порт 80 нужен не для самого приложения, а для проверки владения доменом при выпуске сертификата.

Node на хосте не нужен: приложение работает только в контейнерах.

Установка

1. Настроить окружение

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.com

PUBLIC_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.

2. Подготовить каталоги данных

База, приватный SSH-ключ и сертификаты лежат в каталогах проекта — data/, keys/ и caddy/data/. Их достаточно создать один раз:

mkdir -p data keys
sudo chown -R 10001:10001 data keys
sudo chmod 700 data keys

chown здесь не формальность. Приложение работает под непривилегированным uid 10001 и root'ом права себе не выправит, а подключённый каталог, в отличие от именованного тома, владельца из образа не наследует: он остаётся с теми правами, с какими его создали. Забыть этот шаг не страшно — приложение проверяет доступ на старте и печатает в лог ровно эту команду, а не падает с ошибкой драйвера БД.

Каталоги caddy/data и caddy/config трогать не нужно: официальный образ Caddy работает от root и создаст их сам.

Все три каталога перечислены в .gitignore — в репозиторий они не попадут.

3. Запустить

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 не пойдёт.

4. Войти первым администратором

Учётная запись создаётся при первом старте. Если ADMIN_INITIAL_PASSWORD пуст, пароль генерируется и один раз печатается в лог:

docker compose logs app | grep -A4 'Создан администратор'

Другого способа его узнать нет — в базе только bcrypt-хеш. Если строка потерялась, задайте новый пароль напрямую в SQLite либо начните с чистого листа: docker compose down && sudo rm -rf data keys — при этом пропадут и пользователи, и настройки хоста.

При первом входе администратор обязан привязать двухфакторку: форма покажет секрет и ссылку otpauth://, дальше — код из приложения-аутен- тификатора и десять кодов восстановления. Коды показываются один раз; сохраните их сразу, иначе потеря телефона будет означать потерю доступа.

5. Второй фактор

Администратору TOTP обязателен, поэтому привязка происходит сама при первом входе. Остальным он доброволен и включается на странице /account — туда ведут пункт «Двухфакторная аутентификация» в контекстном меню терминала и кнопка в шапке админки.

Там же перевыпускаются коды восстановления (нужен свежий код из приложения) и отключается второй фактор — кроме случая, когда роль требует его обязательно: такую кнопку страница не показывает вовсе, а сервер отказал бы и так.

6. Задать SSH-хост

Пока хост не настроен, пользователи вместо терминала видят «SSH-хост ещё не настроен администратором». Настраивается он в панели — /admin, раздел SSH-хост:

  1. Хост и Порт — адрес целевой машины (имя или IP).
  2. Пользователь SSH — системная учётная запись на ней. Она общая для всех пользователей приложения, поэтому берите минимально привилегированную и без passwordless sudo.
  3. Приватный ключ — вставьте текстом или выберите файлом. Подходит формат OpenSSH (BEGIN OPENSSH PRIVATE KEY) и классический PEM (BEGIN RSA PRIVATE KEY); PKCS#8 (BEGIN PRIVATE KEY) библиотека ssh2 не читает — такой ключ конвертируйте: ssh-keygen -p -f ключ -m PEM.
  4. 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 app

caddy/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 невыбираемым).

Учётные записи не удаляются, а отключаются — на них ссылается журнал. Поэтому рядом с «Отключить» всегда стоит обратная кнопка: односторонняя операция без пути назад была бы ловушкой. Последнего активного администратора сервер отключить не даёт.

API

Аутентификация — 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-проба, без подробностей

Терминал: GET /ws/terminal (WebSocket)

Аутентификация — та же 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 минут), лимиты одновременных сессий
Доступ после увольнения отключение учётной записи и сброс пароля рвут и веб-сессии, и живые терминалы немедленно

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

Что дальше

  1. Переподключение после обрыва. Сейчас закрытие WebSocket убивает PTY сразу. На мобильном соединение рвётся постоянно — свернул браузер, сменил сеть, — поэтому планируется удерживать PTY ещё ~120 секунд и при возврате проигрывать накопленный буфер (DETACH_GRACE_MS, REPLAY_BUFFER_BYTES уже заведены). До этого момента устойчивость к обрывам даёт tmux на целевом хосте.
  2. QR-код для привязки TOTP. Сейчас показываются секрет и ссылка otpauth:// (на мобильном она открывает аутентификатор по нажатию), на десктопе секрет вводится вручную. Нужна одна зависимость и один эндпоинт, отдающий SVG.
  3. Список живых терминальных сессий в админке с возможностью закрыть чужую, фильтры и постраничная листалка журнала (сервер offset уже принимает), ротация по AUDIT_RETENTION_DAYS.
  4. Ротация ENCRYPTION_KEY без простоя: формат зашифрованного блоба уже версионирован и хранит идентификатор ключа, ENCRYPTION_KEY_PREVIOUS читается — не хватает только команды перешифровки.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages