Skip to content

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

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. Запустить

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

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

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

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

Другого способа его узнать нет — в базе только bcrypt-хеш. Если строка потерялась, удалите том с базой (docker compose down -v, при этом пропадут и пользователи, и настройки хоста) или задайте новый пароль напрямую в SQLite.

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

4. Задать 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 целевого пользователя — приложение туда её не добавляет.

После сохранения панель покажет отпечаток ключа и адрес; можно открывать / и работать. Ключ хоста запоминается при первом подключении (TOFU) и дальше сверяется строго: если он сменится, подключение прервётся с явной ошибкой, а не молча.

Пользователей заводят там же, в разделе Пользователи: логин, роль и пароль (или «сгенерировать» — тогда пароль покажут один раз).

Обновление и остановка

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.

После обновления фронтенда сделайте жёсткое обновление страницы (Ctrl+Shift+R, на мобильном — перезагрузка вкладки). Страницы отдаются с no-store и приходят новыми сразу, а /js и /css кешируются на час: браузер может подставить свежий HTML к вчерашнему JavaScript. Имена файлов не содержат хеша версии, поэтому автоматически это не разрешается — если интерфейс после обновления ведёт себя странно, дело почти наверняка в этом.

Остановка:

docker compose down       # остановить, данные сохранить
docker compose down -v    # остановить и стереть тома вместе с данными

Данные живут в именованных томах app_data (SQLite), ssh_keys (приватный ключ) и caddy_data (сертификаты). Каталог репозитория внутрь контейнера не монтируется, поэтому git pull данных не трогает. Том caddy_data стоит беречь отдельно: в нём ключ ACME-аккаунта и выпущенные сертификаты, а у Let's Encrypt недельные лимиты на повторный выпуск.

Разработка

Надстройка для разработки подключается явно и монтирует исходники внутрь контейнера с 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. Остальные ключи тесты генерируют сами, чтобы не держать приватный ключ фикстурой в репозитории.

Устройство

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-тесты
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 отключает «потяни, чтобы обновить» — иначе прокрутка терминала до края перезагружала бы страницу.

Журнал сессии копится только в памяти вкладки и никуда не отправляется: серверная запись неизбежно захватила бы то, что человек набирает в приглашениях 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. Отказ приходит кодом HTTP (401/403) ещё до рукопожатия, а не кадром закрытия. Заголовок Origin, если он есть, обязан совпадать с PUBLIC_ORIGIN: на рукопожатие WebSocket не распространяется ни SameSite, ни политика общего происхождения, поэтому проверка ручная.

Хост берётся из единственной строки 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 не настроен · 4408 простой · 4409 предел одновременных сессий · 4410 ключ хоста не совпал · 4500 ошибка SSH.

Ошибки подключения разделены по причинам (ssh_auth_failed, ssh_connection_refused, ssh_host_unresolved, ssh_timeout, …) — иначе администратор не поймёт, чинить ключ, сеть или конфигурацию.

Ключ хоста проверяется всегда. Без этого 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