# 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) > запрос пароля как последний вариант. ```bash 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`) — учтено в текущем коде. ## ~/.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`. ## Табы (мультиплексирование сессий) ```bash 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). ```toml [[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 на символ на длинных однотонных строках. ## Сборка ```bash cargo build --release ``` Сессии редактируются в `~/.config/komiterm/sessions.toml`, формат: ```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` (см. раздел выше) — руками их редактировать не обязательно.