26 KiB
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
Аутентификация и профили
Способ входа можно задать двумя способами:
- Прямо в сессии — поле
authвsessions.toml. - Через именованный профиль — заведи один раз в
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 (см. раздел выше) —
руками их редактировать не обязательно.