komiterm/README.md
2026-07-17 09:30:05 +03:00

38 KiB
Raw 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 на реальной машине (не только в песочнице), а не только через сверку с документацией. Первый прогон нашёл 7 реальных ошибок компиляции, второй — ещё одну (сигнатура Channel::data). После этого cargo build --release прошёл полностью — остались только предупреждения о неиспользуемом коде (почищены). Живое подключение к серверу/устройству пока не проверялось.

Что было сломано и как исправлено (см. git-историю/этот README для контекста, если он у тебя есть):

  • async-trait не был объявлен в Cargo.toml, хотя использовался everywhere для Transport — добавлено.
  • russh::keys::learn_known_hosts не реэкспортируется на верхнем уровне в версии 0.62.2 (там есть только check_known_hosts) — записываем строку в ~/.ssh/known_hosts сами, через PublicKey::to_openssh() + дозапись в файл, без обращения к приватным/непереэкспортированным функциям russh.
  • authenticate_publickey/authenticate_password возвращают AuthResult (с методом .success() -> bool), а не голый bool, как предполагалось изначально — поправлено.
  • Handle::window_change в этой версии не существует — только Channel::window_change. Раз уж пришлось трогать эту часть, канал (shell/PTY) целиком переехал в отдельную задачу, которая мультиплексирует входящие сообщения от сервера и команды (write/resize/close) от Transport через tokio::select!, вместо того чтобы держать ChannelId и слать всё через Handle.
  • alacritty_terminal::event::WindowSize не реализует нужный трейт Dimensions для Term::new/Term::resize (это разные вещи: WindowSize — размер в пикселях для PTY, Dimensions — размер в колонках/строках символьной сетки) — завели свою маленькую структуру GridSize в term.rs.
  • Handle::data(...) возвращает Result<(), Bytes> (не пропущенные данные возвращаются как есть, а не завёрнуты в нормальный Error) — обёрнуто через map_err, чтобы ? не спотыкался о недостающий impl StdError.
  • (Второй раунд, после того как первый прогон дошёл до Channel::data) — Channel::data<R: AsyncRead + Unpin>(&self, data: R) принимает источник для чтения, а не готовый буфер/что-то конвертируемое через Into (в отличие от Handle::data, у которого сигнатура другая) — вместо data.into() теперь channel.data(&data[..]), &[u8] сам реализует AsyncRead.
  • (Третий раунд — уже не ошибка компиляции, а баг в поведении: экран при запуске не очищался.) enable_raw_mode() включает построчный ввод, но сам по себе не переключает терминал на отдельный "alternate screen buffer" — тот самый механизм, из-за которого vim/htop/less рисуют на чистом экране, а при выходе возвращают терминал ровно в то состояние, в котором он был (включая историю команд шелла). Без него komiterm рисовал свой UI поверх текущего скроллбэка — добавлены enter_tui()/leave_tui() в app.rs, вызывающие EnterAlternateScreen/LeaveAlternateScreen из crossterm парно с enable_raw_mode()/disable_raw_mode() на каждом пути выхода из run() (их несколько — отмена выбора сессии, ошибка подключения, обычное завершение).
  • (Четвёртый раунд — первое подключение к реальному SSH-серверу прошло успешно, но нашлось два визуальных бага.) Промпт passphrase от rpassword (и TOFU-промпт known_hosts) пишет прямо в tty в обход ratatuiTerminal про эту запись не знает и её не перерисовывает, поэтому огрызок текста промпта оставался на экране поверх строки состояния. Добавлен terminal.clear()? сразу после каждого transport::connect(), случающегося уже внутри TUI (стартовый picker, F2, Enter по сессии в фокусированной панели) — форсирует полную перерисовку экрана с нуля вместо диффинга, который не видел "чужой" записи.
  • (Пятый раунд, тот же скриншот.) Название сессии в боковой панели не помещалось / было не видно целиком. Вероятная причина — "тяжёлые" юникод-символы (● ➤ 📁 и т.п.), чья реальная ширина в конкретном шрифте терминала может не совпадать с тем, что предполагает расчёт колонок в ratatui (типичная проблема для символов вне базовой латиницы/кириллицы — не все шрифты меряют их как честную одну ячейку). Заменил все декоративные символы на простой ASCII (*, >, [D], _, -) и увеличил SIDEBAR_WIDTH с 24 до 30 колонок с запасом. Если после этого текст всё ещё обрезается — это, вероятно, уже про шрифт терминала (кириллица не всегда идеально моноширинная в некоторых шрифтах), а не про код.
  • (Шестой раунд — не баг, а конфликт хоткея, плюс найденный заодно баг.) Дефолт quit сменён с ctrl+alt+c на f10 (конфликтовал с DE/WM у пользователя; существующие hotkeys.toml это не трогает — старые конфиги правятся вручную). Заодно нашлась дыра: если сессия завершалась сама (exit/Ctrl+D в шелле), таб не закрывался, а следующая же клавиша в нём валила всё приложениеtab.transport.write(...).await? пробрасывал ошибку записи в мёртвый канал через весь main_loop. Теперь канал каждого таба каждый кадр проверяется на TryRecvError::Disconnected (не просто "пусто сейчас", а "закрыт навсегда") — таб с завершившейся сессией закрывается автоматически, без валящего приложение падения.

Что осталось (честные пробелы):

  1. ssh.rs: AuthMethod::Agent временно отключён — метод, которым это было изначально реализовано (authenticate_future на Handle, взят из старого примера в репозитории russh), в используемой версии (0.62.2) не существует. Актуальную замену не удалось подтвердить без живой компиляции — вместо третьей попытки угадать API вслепую, честно возвращаю ошибку с указанием использовать Key или Password. Если agent важен — дай знать, разберёмся предметно по фактической ошибке компилятора, как в этот раз.
  2. ui.rs: мастер создания сессии (n в списке сессий) — текстовые поля только добавляют/стирают символы с конца строки, без перемещения курсора внутри неё и без валидации на лету (например, нечисловой порт просто тихо упадёт на дефолт при парсинге). Для тех же целей komiterm profile add (создание credential-профиля) по-прежнему только через komiterm profile add в терминале, не из этого мастера — они пока не связаны. Из боковой панели в режиме списка сессий (F1, не F2) n пока не работает — только из самого пикера при F2/начальном запуске.
  3. sftp.rs: закачка привязана к UI, но download/upload по-прежнему грузят файл целиком в память, нет rename/mkdir/delete в браузере (у SftpSession методы есть) — см. TODO в конце sftp.rs.
  4. hoststats.rs: только Linux (/proc) и только SSH; опрос блокирует главный цикл на время round-trip. Не проверено на реальном хосте.
  5. serial.rs: создать serial-сессию можно и через мастер (n), и руками в sessions.toml — но нет SerialPort::available_ports() ни там, ни там (списка реально подключённых портов, а не текстового поля вслепую). Не проверено на реальном устройстве.
  6. telnet.rs: отвечаем на DO/WILL только для ECHO и SUPPRESS-GO-AHEAD; NAWS (передача размера окна) не реализован.
  7. sshconfig.rs: Match-блоки и полноценные glob-паттерны не поддержаны.
  8. profiles.rs: добавление профиля — простые вопросы в stdin, не форма в ratatui (осознанно: это CLI-подкоманда вне raw-режима основного TUI).
  9. Версии крейтов в Cargo.toml по-прежнему не проверялись сборкой в песочнице (тот же древний rustc 1.75, упирается в edition2024 на первом же транзитивном пакете) — но теперь у нас есть подтверждение с реальной машины на актуальном тулчейне, и именно оттуда взяты все исправления выше.

Дальше по roadmap (после MVP)

  • SSH-туннели (local/remote/dynamic port forwarding) — russh это умеет
  • Логирование сессии в файл
  • NAWS для telnet (передача размера окна серверу)
  • rename/mkdir/delete в SFTP-браузере (методы у SftpSession уже есть)

Сборка

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 (см. раздел выше) — руками их редактировать не обязательно.