komiterm/README.md
2026-07-17 09:35:38 +03:00

26 KiB
Raw Permalink Blame History

KomiTerm — SSH/Telnet клиент на Rust

Минималистичная альтернатива MobaXterm под Linux: session manager + SSH/Telnet в терминале, без X11-сервера и RDP/VNC (сознательно вырезано ради MVP).

Архитектура

main.rs        — CLI (clap): запуск сессии + subcommand `profile add/list/remove`
session.rs     — конфиг сессий (~/.config/komiterm/sessions.toml), AuthMethod/PassphraseSource
profiles.rs    — именованные credential-профили (~/.config/komiterm/credentials.toml)
hotkeys.rs     — конфигурируемые хоткеи табов (~/.config/komiterm/hotkeys.toml)
sftp.rs        — обёртка над russh_sftp::client::SftpSession (листинг/cd/download/upload)
localfs.rs     — локальный файловый браузер для выбора файла при закачке (u в SFTP-режиме)
hoststats.rs   — парсинг CPU/памяти/load average из /proc для строки состояния
sshconfig.rs   — парсер ~/.ssh/config, включая Include (см. ниже)
resolve.rs     — слияние приоритетов: session > credential-профиль > ssh_config > дефолты
transport/
  mod.rs       — трейт Transport (write/resize/close/open_sftp/run_command) — общий интерфейс
  ssh.rs       — реализация через russh, поддерживает Password/Key/Agent + open_sftp/run_command
  telnet.rs    — реализация telnet с ответом на DO/WILL (не просто дроп)
  serial.rs    — последовательный порт (COM/tty) через tokio-serial
term.rs        — обёртка над alacritty_terminal (парсинг escape-последовательностей)
ui.rs          — рендер сетки терминала + боковая панель (сессии / SFTP-браузер)
app.rs         — главный цикл: incoming -> term_state -> render, ввод -> transport

Аутентификация и профили

Способ входа можно задать двумя способами:

  1. Прямо в сессии — поле auth в sessions.toml.
  2. Через именованный профиль — заведи один раз в credentials.toml (командой komiterm profile add), затем ссылайся на него по имени полем credential из любой сессии. Удобно, когда один логин/ключ используется для нескольких хостов.

Приоритет при разрешении (см. resolve.rs): explicit auth в сессии > credential-профиль > подсказка из ~/.ssh/config (IdentityFile) > запрос пароля как последний вариант.

komiterm profile add      # интерактивно: имя, логин, пароль/ключ/agent*
komiterm profile list
komiterm profile remove myprofile

* ssh-agent как способ входа временно не работает (см. TODO ниже) — профиль с ним создать можно, но подключение с таким профилем откажет.

Пароли и passphrase никогда не сохраняются на диск — только логин, путь к ключу и признак "спросить passphrase при подключении". Файл credentials.toml на Unix создаётся с правами 600.

known_hosts / защита от MITM

check_server_key в transport/ssh.rs больше не доверяет серверу безусловно — сверяется с ~/.ssh/known_hosts через russh::keys::check_known_hosts:

  • Ключ совпадает с записанным — подключаемся молча, как обычно.
  • Записи для хоста нет вообще (первое подключение) — печатается отпечаток ключа, и спрашивается подтверждение (Trust On First Use, как делает обычный ssh при "authenticity of host can't be established"). При согласии ключ дописывается в known_hosts — своей маленькой функцией (transport/ssh.rs::learn_known_hosts), а не russh::keys::learn_known_hosts: в используемой версии russh (0.62.2) это имя не реэкспортируется на верхнем уровне russh::keys (только check_known_hosts), так что строку формируем сами через PublicKey::to_openssh() — формат known_hosts простой и стабилен десятилетиями, велосипед тут минимальный.
  • Запись есть, но ключ другой — это подозрение на MITM или переустановленный сервер. Здесь нет интерактивного "продолжить всё равно": подключение отклоняется безусловно, с инструкцией вручную почистить строку в ~/.ssh/known_hosts, если пользователь уверен, что смена ключа легитимна. Слабое место так просто не обойти повторным нажатием Enter.

Из-за этого пришлось поднять версию russh с 0.44 до текущей (>=0.50, реально резолвится в 0.62.x) — модуль работы с ключами в старой версии жил отдельным крейтом russh-keys и не имел этих функций в удобном виде; в текущей версии russh::keys включает и агента, и известные хосты, и работу с ключами в одном месте. Заодно поменялся API аутентификации по ключу (authenticate_publickey теперь принимает PrivateKeyWithHashAlg вместо голого Arc<PrivateKey>) — учтено в текущем коде.

~/.ssh/config

Если у сессии use_ssh_config = true (по умолчанию), поле host сначала ищется как алиас в ~/.ssh/config — так же, как если бы ты написал ssh myalias. Оттуда подтягиваются HostName, User, Port, IdentityFile, если сессия их не переопределяет явно.

Путь один и тот же на всех трёх платформах (омашняя_директория>/.ssh/config), поэтому специального кода под Windows не потребовалось — OpenSSH для Windows использует ту же схему.

Поддержана директива Include (в т.ч. с *-wildcard в имени файла и путями относительно директории текущего конфига — Include config.d/* работает). Совпадения *-паттерна обходятся в отсортированном порядке, как это делает сам OpenSSH, иначе порядок Include стал бы недетерминированным. Есть защита от циклов (a.conf включает b.conf включает a.conf) через отслеживание уже разобранных файлов и предел глубины. Из не поддержанного: Match-блоки и полноценные glob-паттерны (?, [abc], отрицания через !) — см. TODO в конце sshconfig.rs.

Табы (мультиплексирование сессий)

komiterm myserver staging-box old-switch    # три таба сразу при запуске
komiterm                                     # один таб, сессия через менеджер

Хоткеи читаются из ~/.config/komiterm/hotkeys.toml (создаётся при первом запуске с показанными ниже значениями по умолчанию; Alt+1..Alt+9 в конфиг не вынесены — см. комментарий в hotkeys.rs, почему):

Комбинация Действие
Ctrl+PageUp/PageDown предыдущий / следующий таб
Alt+1..Alt+9 прыжок на таб N
F1 фокус на боковую панель / обратно на терминал
F2 новый таб (выбор сессии из списка)
Ctrl+F2 закрыть активный таб
F3 свернуть/развернуть боковую панель
F4 SFTP-браузер для активного таба (только SSH)
F10 выйти из KomiTerm целиком

Все табы держат живое соединение и вычитывают входящие данные каждый кадр (фон не "замирает", даже если ты сейчас смотришь в другой таб) — рисуется только активный. Открытие нового таба (F2) и начальный выбор сессии (если она не передана аргументом) показывают список сессий прямо поверх текущего экрана через ratatui::widgets::List — без выхода из raw-режима и без "грязного" экрана, как было в первой версии.

Если сохранённых сессий вообще нет (первый запуск), список не ошибка, а пустой экран с подсказкой — нажми n, чтобы создать сессию прямо тут же, не выходя из TUI и не редактируя sessions.toml руками. n работает и при непустом списке — добавить ещё одну сессию можно в любой момент, когда показан этот список (при F2 или при начальном выборе). Мастер — короткий пошаговый опрос (протокол → имя → поля по протоколу, включая способ входа для SSH), каждое поле — Enter продолжить, Esc отменяет всё создание сразу. Готовая сессия дописывается в sessions.toml и сразу открывается табом.

Как закрыть/выйти из одной сессии (не из всего komiterm): Ctrl+F2 закрывает активный таб явно. Выйти изнутри сессии обычным способом (exit или Ctrl+D в шелле) тоже работает — таб закрывается сам, как только замечает, что канал от удалённой стороны закрылся (проверяется каждый кадр). Если все табы закрылись — komiterm завершается сам, выходить из него отдельно уже не нужно.

Боковая панель сессий

Слева — как в MobaXterm — постоянная панель со списком всех сохранённых сессий (ui::render_sidebar), фиксированной ширины (SIDEBAR_WIDTH = 24 колонки). Открытые сейчас как табы сессии помечены точкой, активная — подсвечена инверсией. Сворачивается/разворачивается по F3, освобождая эти 24 колонки под содержимое терминала.

Важный нюанс: при сворачивании/разворачивании панели меняется реальная ширина видимой области терминала, поэтому F3 не просто перерисовывает UI — он ресайзит PTY на удалённой стороне (transport.resize) для всех табов, как при обычном ресайзе окна. Без этого full-screen приложения на той стороне (vim, htop, less) не узнали бы о появившемся/пропавшем месте.

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

  • / (или j/k) — выбор сессии в списке
  • Enter — переключиться на уже открытый таб этой сессии, либо открыть новый (без модального picker'а — сессия уже выбрана курсором)
  • Esc — вернуть фокус терминалу (панель остаётся видна)

F4 (SFTP) при открытии сразу переводит фокус на панель — незачем открывать браузер файлов и не иметь возможности сразу же по нему перемещаться. Esc внутри SFTP-режима возвращает и режим панели к списку сессий, и фокус терминалу — одним действием.

SFTP-браузер

F4 открывает в той же боковой панели файловый браузер для активного таба (переиспользует уже открытое SSH-соединение — отдельный channel с subsystem sftp, без нового пароля/ключа). Работает только для SSH, для Telnet-сессий покажет заглушку "SFTP недоступен".

Пока панель в режиме SFTP, стрелки/j/k/Enter/Esc уходят в неё, а не в шелл — так и должно быть, иначе непонятно, куда идёт навигация:

  • / (или j/k) — выбор записи в списке
  • Enter на папке — зайти внутрь; .. наверху списка — выйти на уровень выше
  • Enter на файле — скачать в ~/Downloads/komiterm/<имя_сессии>/
  • u — открыть локальный файловый браузер и закачать выбранный файл в текущую директорию (см. ниже)
  • Esc — вернуть панель к списку сессий (сама SFTP-сессия при этом не закрывается — переключение обратно на F4 покажет тот же листинг без повторного похода на сервер)
  • Повторный F4 — то же самое, что Esc

Закачка файлов (u)

u внутри SFTP-режима открывает localfs::LocalBrowser — тот же список файлов, но по локальному диску, начиная с домашней директории (обычный std::fs, никакого SSH). Навигация та же (//j/k, .. наверху), Enter на файле закачивает его в текущую директорию SFTP-браузера активного таба под тем же именем и возвращает в SFTP-режим с уже обновлённым листингом (закачанный файл сразу виден в списке). Esc — отмена, назад в SFTP-режим без закачки.

sftp.rs — тонкая обёртка над russh_sftp::client::SftpSession: листинг директории (папки впереди файлов, дальше по алфавиту), cd через canonicalize (чтобы .. и симлинки резолвил сам сервер, а не наша склейка строк), download/upload. Download/upload грузят файл целиком в память, для реально больших файлов нужно будет стримить чанками — см. TODO в конце sftp.rs.

API russh_sftp сверен с реальными примерами из репозитория (AspectUnk/russh-sftp/examples/client.rs, тот же паттерн — в russh/examples/sftp_client.rs), а не написан по памяти.

Строка состояния хоста

Внизу экрана (под содержимым терминала, отдельной строкой) — CPU/память/ load average активного таба, обновляется раз в STATS_INTERVAL (5 секунд по умолчанию, константа в app.rs).

Данные берутся не парсингом вывода интерактивного шелла (это было бы ненадёжно — зависит от промпта, алиасов, запущенной программы), а отдельным exec-каналом той же SSH-сессии — Transport::run_command(), новый метод трейта, третий по счёту наряду с open_sftp (тоже дефолт Ok(None) для Telnet). Команда гоняет cat /proc/loadavg, первые строки /proc/meminfo и первую строку /proc/stat, разделяя секции меткой @@@ для надёжного парсинга (hoststats::parse). Соответственно работает только на Linux-хостах и только по SSH — для Telnet и не-Linux строка состояния просто показывает имя сессии без метрик, без ошибок в интерфейсе.

CPU% — не то же самое, что load average: это доля "не-idle" времени между двумя последовательными снятиями /proc/stat (как считает top/htop), поэтому появляется только со второго опроса — на первом кадре после подключения будет прочерк, пока не накопится дельта.

Честный компромисс: опрос выполняется прямо в основном цикле и блокирует отрисовку/ввод на время round-trip команды (обычно доли секунды, но на дальних/медленных соединениях может быть заметно) — раз в 5 секунд это редкость, но не бесплатно. Опрашивается только активный таб — у фоновых вкладок статистика не обновляется, пока на них не переключишься.

Serial / COM-подключения

Для консольных портов сетевого/embedded-оборудования — как в MobaXterm, но без модема и прочего "телефонного" наследия PuTTY. Транспорт (serial.rs) использует tokio-serial — тот же паттерн read/write-задач, что и у telnet, просто без протокола поверх (сырые байты в обе стороны, никакого IAC).

[[session]]
name = "switch-console"
protocol = "serial"
port = "/dev/ttyUSB0"      # Linux/macOS; на Windows — "COM3", "COM15" и т.п.
baud_rate = 115200          # выбор скорости — обязательное поле, дефолта нет
# Остальное — опционально, дефолт 8N1 без flow control (подходит для
# подавляющего большинства консольных портов):
# data_bits = "eight"        # five | six | seven | eight
# parity = "none"            # none | odd | even
# stop_bits = "one"          # one | two
# flow_control = "none"      # none | software | hardware

При подключении к serial-сессии в терминал печатается небольшая справка (один раз, при открытии таба) — те самые нюансы, которые не видны нигде в интерфейсе:

--- Serial-подключение: /dev/ttyUSB0 @ 115200 бод ---
- Порт занят другим приложением? Один порт — одно соединение одновременно.
- Linux: Permission denied при открытии? Добавь себя в группу dialout
  (или uucp) и перелогинься: sudo usermod -aG dialout $USER
- data bits/parity/stop bits/flow control должны совпадать с устройством
  на другом конце — по умолчанию тут 8N1 без flow control.

Ограничения, которые стоит держать в голове:

  • Занятость порта. ОС не даёт двум процессам открыть один и тот же serial-порт одновременно — если он уже занят (в другом терминале, в MobaXterm, в screen/minicom), подключение вернёт ошибку открытия, а не зависнет.
  • Права доступа только на Linux/macOS. На Windows обычно ничего дополнительно настраивать не нужно; на Linux без группы dialout (uucp на некоторых дистрибутивах) — Permission denied.
  • Настройки порта должны совпадать с устройством. Неверный baud rate/parity — не ошибка подключения, а нечитаемая ерунда вместо текста ("mojibake"), потому что порт открывается успешно независимо от того, правильно ли задан протокол данных поверх него.
  • Нет ни SFTP, ни строки состояния хостаоба используют SSH-специфику (open_sftp/run_command), а serial-транспорт не переопределяет эти методы трейта, так что дефолтные Ok(None) отключают их автоматически, без специального кода в UI. Строка состояния просто покажет имя сессии, как для Telnet.
  • resize() — no-op. У физического провода нет понятия "размер терминала" — устройство на другом конце просто пишет вслепую в свой обычный vt100/ANSI, ресайз окна KomiTerm ни на что не влияет.

Рендер терминала

ui::render_terminal проходит по сетке alacritty_terminal напрямую через grid[Line(row)][Column(col)], а не через display_iter() — так проще гарантированно сохранить построчную раскладку. Поддержано: цвет текста/фона (Named/Indexed/Spec — включая true color), bold/dim/italic/underline/ strikeout/inverse, курсор (инверсией текущей ячейки). Соседние ячейки с одинаковым стилем схлопываются в один Span, чтобы не плодить по Span на символ на длинных однотонных строках.

Сборка

cargo build --release

Сессии редактируются в ~/.config/komiterm/sessions.toml, формат:

# Явный ключ прямо в сессии, host — реальный, ssh_config не трогаем
[[session]]
name = "myserver"
protocol = "ssh"
host = "example.com"
port = 22
user = "vladimir"
use_ssh_config = false
[session.auth]
method = "key"
path = "/home/vladimir/.ssh/id_ed25519"
passphrase = { source = "prompt" }

# host — алиас из ~/.ssh/config; HostName/User/IdentityFile подтянутся
# оттуда сами, если тут не переопределены
[[session]]
name = "prod-box"
protocol = "ssh"
host = "prod"                 # ищется в ~/.ssh/config как `Host prod`

# Логин/способ входа переиспользуются из именованного профиля
[[session]]
name = "staging-box"
protocol = "ssh"
host = "staging.internal"
credential = "work-key"       # см. `komiterm profile add` / credentials.toml

[[session]]
name = "old-switch"
protocol = "telnet"
host = "192.168.1.1"
port = 23

Профили входа лежат отдельно в ~/.config/komiterm/credentials.toml и управляются через komiterm profile add/list/remove (см. раздел выше) — руками их редактировать не обязательно.