nos_
ENRU

SSH-сервер в модуле ядра

У двух серверов nos нет другой консоли, кроме сети. Поэтому в ядре появился SSH-сервер: загружаемый модуль на Rust, проверенный клиентом OpenSSH.

9 мин чтения
СетьRustМодулиБезопасностьТестирование
Read in English

Нет консоли, кроме сети

Из трёх настоящих машин, на которых загружается nos, две — выделенные серверы Hetzner, EX44 и AX41. Ни последовательного порта, ни IPMI у них нет, так что общаться с ними можно только по сети (Реальное железо). Какое-то время мы набирали команды через UDP-шелл. Он выполняет любую команду из датаграммы, в которой есть его магическое число, и не знает ни шифрования, ни аутентификации. В приватной сети виртуальной машины это терпимо, а в интернете это открытая дверь.

12 сентября 2026 года в nos появился SSH-сервер (57544cd). Клиент OpenSSH входит по ключу Ed25519 и получает тот же шелл, что и на консоли. Соединение зашифровано, а пускают только с теми ключами, которые серверу разрешили. Пользовательского пространства в nos пока нет, поэтому сервер работает прямо в ядре, рядом с шеллом, который обслуживает. А раз машина может обойтись без него, он сделан загружаемым модулем на Rust, sshd.ko.

Что он поддерживает

Сервер знает по одному алгоритму каждого вида, и это те алгоритмы, которые любой OpenSSH последних десяти лет предлагает первыми или почти первыми. Весь список помещается в несколько констант в transport.rs:

/* What the server offers: one of each kind */
const KEX: &[&str] = &["curve25519-sha256", "curve25519-sha256@libssh.org"];
/* OpenSSH's strict key exchange, the answer to Terrapin: offered and asked
   for in the first KEXINIT only (OpenSSH's PROTOCOL, 1.9) */
const KEX_STRICT_SERVER: &str = "kex-strict-s-v00@openssh.com";
const KEX_STRICT_CLIENT: &str = "kex-strict-c-v00@openssh.com";
const HOST_KEYS: &[&str] = &[ED25519];
const CIPHERS: &[&str] = &["chacha20-poly1305@openssh.com"];
/* Named because a KEXINIT has to name a MAC. With an AEAD for the cipher
   nobody uses it, and OpenSSH does not even negotiate it. */
const MACS: &[&str] = &["hmac-sha2-256"];
const COMPRESSION: &[&str] = &["none"];

Для обмена ключами служит X25519 с SHA-256 (RFC 8731), для ключей хоста и пользователей — Ed25519 (RFC 8709). Шифр тоже один: конструкция OpenSSH на основе ChaCha20-Poly1305. Она прячет даже длину пакета, причём под отдельным ключом. Это аутентифицированное шифрование, поэтому отдельный MAC ему не нужен.

Строгий обмен ключами (strict key exchange) — ответ OpenSSH на Terrapin, атаку, которая вырезает сообщения из начала зашифрованного потока. Чтобы номера пакетов всё равно сошлись, атакующий подсовывает лишнее сообщение в рукопожатие, пока оно ещё не зашифровано. В строгом режиме во время первого обмена не может прийти ничего, кроме сообщений самого обмена, даже IGNORE. Кроме того, обе стороны обнуляют номера пакетов при каждом NEWKEYS, сообщении, которое включает новые ключи.

Повторный обмен ключами (rekeying) — это новый обмен посреди сеанса. Сервер сам его не начинает, но подхватывает обмен, начатый клиентом, в любой момент, даже посреди вывода команды. Управление потоком в SSH устроено через окно канала: сторона отправляет не больше байтов, чем другая согласилась принять, а получатель расширяет окно по мере чтения. Сервер даёт клиенту окно в 64 КиБ и сам ждёт, пока откроется окно клиента.

Всё остальное сервер отклоняет: пароли, ключи любых типов, кроме Ed25519, сжатие, sftp и вместе с ним современный scp, проброс портов, агента и X11, а также env. На соединение приходится один сеанс: либо шелл, либо одна команда (ssh host dmesg).

Протокол без ядра

Сервер состоит из двух крейтов. В src/rust/ssh живёт протокол: пакеты, обмен ключами, вход по открытому ключу, канал сеанса и редактор строки с историей на 32 строки. О ядре этот крейт ничего не знает. Всё, что ему нужно от машины, он получает через два трейта (lib.rs, документирующие комментарии опущены):

pub trait Link {
    fn recv(&mut self, buf: &mut [u8], timeout_ms: u64) -> Recv;
    fn send(&mut self, data: &[u8]) -> bool;
    fn now_ms(&self) -> u64;
    fn stopping(&self) -> bool;
    fn random(&mut self, buf: &mut [u8]) -> bool;
}

pub trait Shell {
    fn authorized(&mut self, user: &str, key: &PublicKey) -> bool;
    fn logged_in(&mut self, _user: &str, _key: &PublicKey) {}
    fn run(&mut self, line: &str, io: &mut dyn Io);
}

Реализует их src/rust/modules/sshd: в модуле есть задача-слушатель, по задаче на каждое соединение, команда sshd и два файла, которые ведёт сервер. В крейте протокола нет ни одного unsafe. В модуле таких блоков четыре, и каждый превращает указатель на контекст задачи обратно в её Box.

Криптографию дают крейты RustCrypto и dalek, которые уже лежали в репозитории ради TLS-клиента, так что новых зависимостей сервер не принёс. Одну деталь сборки пришлось учесть отдельно. Модуль собирается собственным запуском cargo, и features, включённые для ядра, до него не доходят, поэтому крейт ssh сам просит у sha2 программную реализацию. Иначе sha2 во время работы выберет SHA-инструкции процессора, а SIMD ядро держит выключенным. Раз ядра в крейте нет, он запускается и на хосте: там его гоняет сетевой фаззер с полноценным SSH-клиентом (Фаззинг ядра на хосте).

Ключи в форматах OpenSSH

Ключ хоста лежит в /etc/ssh/ssh_host_ed25519_key, в собственном формате закрытых ключей OpenSSH. Туда подходит ключ, созданный командой ssh-keygen -t ed25519 -N '', так что сервер может использовать тот же ключ хоста, что и другая ОС, которую он загружает. Модуль создаёт ключ при первом запуске и только там, где файла нет. Если файл есть, но прочитать его не вышло, sshd start завершается ошибкой: с новым ключом каждый клиент, помнящий старый, перестал бы соединяться.

/etc/ssh/authorized_keys принимает строки ssh-ed25519 в том виде, в каком их пишет OpenSSH. Строку с опциями вроде from= или command= сервер пропускает с предупреждением, потому что соблюдать их не умеет. Команды sshd allow и sshd deny правят файл прямо из шелла.

Ревью первой версии нашло три способа опустошить этот файл: переполненный диск, две правки одновременно и deny, который наткнулся на байт, недопустимый в UTF-8. Когда SSH остаётся единственным входом на машину, пустой файл означает, что войти больше нельзя. Поэтому теперь файлы никогда не перезаписываются на месте. Новое содержимое пишется в <path>.new, сбрасывается на диск и только после этого занимает место старого файла, а всю правку, от чтения до записи, держит одна блокировка.

Запуск при загрузке: /etc/rc

Сам собой insmod при загрузке не запускается, поэтому тот же коммит добавил /etc/rc. В нём лежат команды шелла, которые задача шелла выполняет один раз, когда DHCP уже поднял сеть.

$ rc add insmod /sshd.ko
$ rc add sshd start
$ rc
1  insmod /sshd.ko
2  sshd start

Вывод этих команд сразу идёт на экран, а в журнал ядра попадает, когда команда завершится. Если бы он писался в журнал по мере печати, dmesg в таком скрипте читал бы собственный вывод. Если какая-то строка мешает машине подняться, параметр rc=off в командной строке ядра велит пропустить файл (sshd).

В октябре так стало по умолчанию. В системный образ для серверов входят sshd.ko и /etc/rc, который загружает и запускает модуль, nos-install --ssh-key добавляет ключ, а UDP-шелл больше не включает ни одна поставляемая конфигурация (2777692). Как войти на машину, рассказано в руководстве «Подключение к работающему nos».

Первый пользователь пассивной стороны TCP

До sshd в nos никто не принимал TCP-соединения. Пассивная сторона стека менялась прямо по ходу работы над сервером:

  • Accept принимает таймаут и возвращается, как только слушатель закрыт. Задача-слушатель ждёт соединений порциями по 250 мс и в промежутках проверяет, не останавливают ли сервер.
  • Ещё он отдаёт соединение, которое другая сторона успела закрыть. Раньше такое соединение висело в CLOSE-WAIT: закрыть его было некому, и слот оставался занят навсегда.
  • Слушатель держит не больше 16 соединений, которые никто не принял, а при закрытии сбрасывает их.
  • Abort завершает соединение пакетом RST. Сторона, которая закрывает соединение первой, минуту держит слот в TIME-WAIT, а слотов у ядра всего 64.

Последнее серверу нужно всерьёз: в порт 22 в интернете стучатся круглые сутки. Сервер обслуживает 8 соединений сразу, из них не больше 4 на стадии входа, а остальные сбрасывает (lib.rs):

    if total >= MAX_SESSIONS || logging_in >= MAX_LOGGING_IN {
        shared.stats.refused.fetch_add(1, Ordering::Relaxed);
        kcore::trace!(1, "sshd: {} refused: {} sessions, {} of them logging in", peer, total, logging_in);
        /* Reset, not closed: closing first on everyone turned away would
           keep a TCP slot a minute each, and run the kernel out of them */
        stream.abort();
        return;
    }

У этого ограничения есть и протокольная сторона. От соединения до входа у клиента 30 секунд, и в первой версии повторный обмен ключами это время растягивал. Четыре клиента, которые без конца меняли ключи, могли занять все слоты для входа. Теперь на вход отведён один срок, и его проверяет каждое чтение. В октябре ревью гипервизора обнаружило, что до этих слотов могут дотянуться и гостевые машины, поэтому теперь слушатель принадлежит одному устройству (e9e78d3).

Ещё одна ошибка TCP била по медленным читателям. Send проталкивал байт данных в закрытое окно, чтобы получатель заново сообщил размер окна. Дублирующиеся ACK в ответ этот байт так и не подтверждали, и лимит повторных передач объявлял получателя мёртвым. Приостановленный клиент ssh, который просто медленно читает, обрывался меньше чем за минуту. Теперь закрытое окно прощупывает только persist-таймер.

Гейт

scripts/sshd-test.py проверяет сервер целиком, настоящим клиентом OpenSSH (sshd). На arm64 он загружает nos в QEMU, подгружает модуль через UDP-шелл и гоняет его: команды, шелл с терминалом и без, 3 МиБ вывода через окно клиента в 2 МиБ, ключ, который сервер должен отвергнуть, четыре сеанса сразу, sshd stop при открытом сеансе и rmmod sshd изнутри сеанса. Потом тест прописывает модуль в /etc/rc, перезагружает машину и проверяет, что сервер поднялся сам и с тем же ключом хоста. На x86-64 тест загружает корневой образ, который уже запускает сервер, и просто входит.

Одна проверка снова прогоняет те же 3 МиБ, но с RekeyLimit=16K. Сервер, занятый потоковым выводом, замечает запрос на новый обмен ключами, только когда снова читает соединение, а здесь это происходит, когда кончается окно. Поэтому тест ждёт смены ключей посреди вывода, а не каждые 16 КиБ. Другая проверка открывает 70 соединений и сразу их закрывает, а потом ещё шесть, которые просто висят. Четыре из шести должны получить сеанс, два должны быть сброшены, и ни один TCP-слот не должен застрять в закрывающемся состоянии.

Вторая проверка однажды падала не по той причине. Она считала всплеск отработанным, как только никто не входил в систему, но так бывает и до прихода первой пробы. Отставшие соединения попадали в подсчёт, и проверка видела «refused 12, logging in 0». Теперь она ещё и ждёт, чтобы число соединений три секунды не менялось (44579ed).

Гейт окупился меньше чем за неделю. Когда TCP переезжал на Rust (380308a; переезду посвящён отдельный пост), гейт поймал единственную регрессию, которую принёс перенос. Выделение соединения брало блокировки всех слотов, включая слот слушателя, а один путь выделял соединение, уже держа блокировку слушателя. Процессор крутился на блокировке, которую сам же и держал. Сейчас проходит 41 проверка, а ещё одна падает по причинам, к TCP не относящимся.

Команда рядом с сеансом

Сюрприз от OpenSSH пришёл через его keepalive. До 24 сентября команда выполнялась в задаче самого сеанса, и пока она не завершалась, соединение никто не читал. На AX41 30-секундный hv wait через ssh с ServerAliveInterval=5 обрывался на 14-й секунде с сообщением «Timeout, server not responding».

Теперь команда выполняется в собственной задаче, а между ней и сеансом лежат буферы: 16 КиБ для вывода и 4 КиБ для ввода (50746e4). Вывод будит сеанс сразу. После каждых 10 мс тишины сеанс на миллисекунду берётся за соединение: отвечает на keepalive, принимает расширение окна и откладывает набранный текст на потом. Первая версия вместо этого ждала на сокете, и 3 МиБ cat передавались 1,6 с вместо 0,7 с. Когда сеанс стал ждать саму команду, время вернулось к 0,7 с.

Для этого в тесте появилась отдельная проверка: top 5000, то есть пять секунд тишины, при ServerAliveInterval=1 и ServerAliveCountMax=2. Команда может и читать то, что клиент набирает, пока она работает. Так hv attach показывает консоль гостя вживую через ssh -t (3aab12c).

Что он защищает

Сервер работает в ядре без разделения привилегий, поэтому ошибка в нём становится ошибкой ядра, а тот, кто получил его шелл, получил и ядро. Отсюда и осторожность: парсер написан на безопасном Rust и проверяет границы при каждом чтении, подписи проверяются строго, а теги пакетов сравниваются за постоянное время (sshd).