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

359 lines
26 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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