RE:NODE

Конфиг SSH и ProxyJump: хосты, ключи и jump-серверы

Автор Команда RE:NODE
16 мин чтения


Клиентский конфиг SSH - это файл ~/.ssh/config (в Windows - C:\Users\<you>\.ssh\config). Каждый блок Host даёт подключению короткое имя и хранит всё, что иначе пришлось бы набирать вручную: HostName - настоящий адрес, User, Port, IdentityFile - ключ, ProxyJump - промежуточная машина. После этого ssh web заменяет ssh -i ~/.ssh/id_work -p 2222 deploy@203.0.113.10, и то же самое работает для scp, sftp, rsync, Git и любого редактора, который умеет SSH, потому что все они читают один и тот же файл.

Одно правило объясняет почти все неожиданности: для каждой настройки побеждает первое найденное значение. Конкретные блоки идут вверху, универсальный Host * - внизу, а параметр, указанный в командной строке, сильнее всего, что есть в файле. Запомните это, и остальная часть руководства станет справочником, из которого можно копировать.

Эта статья - про клиентскую сторону. Генерация ключей, агент и защита sshd_config на сервере описаны в материале SSH-ключи и защита sshd_config; если ключа у вас ещё нет, начните с него.

Где лежит конфиг#

Файлов два, и читаются они по порядку: сначала ваш, затем общесистемный.

СистемаФайл пользователяСистемный файл
Linux, BSD~/.ssh/config/etc/ssh/ssh_config
macOS~/.ssh/config/etc/ssh/ssh_config
Windows OpenSSH%USERPROFILE%\.ssh\config%ProgramData%\ssh\ssh_config

Пока вы его не создадите, файла нет. В Linux и macOS запись в него не должна быть разрешена никому, кроме вас, иначе ssh откажется его использовать и выдаст Bad owner or permissions on /home/you/.ssh/config:

bash
$ mkdir -p ~/.ssh && chmod 700 ~/.ssh$ touch ~/.ssh/config && chmod 600 ~/.ssh/config

Обратите внимание на название: ssh_config - это клиент, а sshd_config (с буквой d, от daemon) - сервер. Правка не того файла - удивительно частый способ потерять полдня, а серверный файл лежит в том же каталоге /etc/ssh.

Два флага делают файл прозрачным. ssh -G name выводит полностью разобранную конфигурацию для имени без подключения - каждый параметр после применения всех блоков и значений по умолчанию. ssh -F /dev/null name подключается в обход вашего конфига и одним шагом показывает, в нём ли проблема. Флаг -G появился в OpenSSH 6.8, так что он есть на всём, что вам вероятно встретится.

bash
$ ssh -G web | grep -E '^(hostname|user|port|identityfile|proxyjump) 'hostname 203.0.113.10user deployport 2222identityfile ~/.ssh/id_workproxyjump none

Блоки Host и как ssh выбирает значение#

Строка Host начинает блок, который продолжается до следующей строки Host или Match. Отступы нужны вам, а не ssh. Ключевые слова нечувствительны к регистру, значения в основном чувствительны, а писать можно как Keyword value, так и Keyword=value.

~/.ssh/config
Host web    HostName 203.0.113.10    User deploy    Port 2222    IdentityFile ~/.ssh/id_work    IdentitiesOnly yesHost *.lab    User admin    StrictHostKeyChecking accept-newHost *    ServerAliveInterval 30    AddKeysToAgent yes

Когда вы набираете ssh web, ssh проходит файл сверху вниз. Каждый блок, чей шаблон подходит, добавляет те настройки, которых ещё не было. Поэтому web получает порт из собственного блока, а ServerAliveInterval - из Host *. Если бы Host * стоял в начале и содержал User root, все более поздние строки User игнорировались бы - это классический вариант ситуации "мой конфиг не работает".

Исключение - небольшой набор параметров, которые накапливаются, а не берут первое значение: IdentityFile, CertificateFile, LocalForward, RemoteForward, DynamicForward и SendEnv. Несколько строк IdentityFile из нескольких подошедших блоков образуют список, который перебирается по порядку.

Шаблоны сопоставляются с тем именем, которое вы набрали, а не с HostName. Они поддерживают * и ?, несколько шаблонов в одной строке и отрицание через !:

~/.ssh/config
Host web db cache    User deployHost 10.0.0.* !10.0.0.1    ProxyJump bastionHost * !github.com    ForwardAgent no

Отрицаемый шаблон сам по себе ничему не соответствует; он лишь исключает из других шаблонов в той же строке. Host !github.com в одиночку - это блок, который не применяется ни к чему.

Match - для условий, которые Host выразить не может

Блоки Match проверяют условия, а не имена. Полезные из них - host (имя после применения HostName), originalhost (то, что вы набрали), user, localuser, exec и all.

~/.ssh/config
# Use the office jump host only when not on the office networkMatch host 10.20.* !exec "ip route | grep -q 10.20.0.0/16"    ProxyJump gw.example.com

exec запускает команду оболочки и считается совпавшим, если она завершилась со статусом ноль. Она выполняется при каждом подключении, поэтому должна быть быстрой, и помните, что это команда оболочки на вашей машине - никогда не вставляйте такую, которую не понимаете.

Настройки, которые стоит знать#

Это параметры, которые заслуживают места в реальном конфиге. Значения по умолчанию - значения OpenSSH.

ПараметрПо умолчаниюЧто делает
HostNameнабранное имяНастоящий адрес или DNS-имя
Userваше локальное имяУдалённая учётная запись
Port22Удалённый порт SSH
IdentityFileстандартные файлы id_*Приватный ключ, который предлагается серверу
IdentitiesOnlynoПредлагать только указанные ключи
ProxyJumpнетСначала пройти через эти хосты
ServerAliveInterval0 (выкл.)Секунды между keepalive-запросами
ServerAliveCountMax3Сколько пропущенных keepalive до отключения
ConnectTimeoutсистемный таймаут TCPСколько секунд ждать подключения
StrictHostKeyCheckingaskКак относиться к неизвестным ключам хоста

HostName принимает токены, поэтому один блок может обслуживать целое семейство машин. %h - это имя, которое вы набрали:

~/.ssh/config
Host *.prod    HostName %h.example.com

Это работает не совсем так, как ожидают: ssh api.prod подключится к api.prod.example.com. Если в ваших реальных именах нет .prod, пишите по одному блоку на хост. Хитрые шаблоны экономят строки, но стоят ясности, а конфиг - это файл, который вы будете читать в два часа ночи.

User стоит задавать в каждом блоке, потому что по умолчанию берётся ваше локальное имя пользователя. На ноутбуке с Windows оно часто начинается с заглавной буквы или похоже на адрес почты и не существует ни на одном сервере Linux, а ошибка в таком случае - обычный Permission denied, и ничто не указывает на имя пользователя.

Port важен в основном для машин, где SSH перенесли с порта 22, чтобы в логах было тише, и для всего, что держит на другом порту собственный сервис, говорящий по SSH. ssh -p 2222 в командной строке сильнее файла, как и любой параметр командной строки.

IdentityFile и IdentitiesOnly

IdentityFile называет приватный ключ, и путь может начинаться с ~. Без него ssh пробует стандартные имена в ~/.ssh - id_rsa, id_ecdsa, id_ed25519 и их варианты для аппаратных ключей - плюс всё, что загружено в агент.

С одним ключом такое поведение нормально. С несколькими оно приводит к сбою, который рано или поздно встречает каждый, у кого есть рабочий и личный ключ: ssh предлагает все ключи из агента один за другим, каждое предложение считается попыткой аутентификации, и сервер с MaxAuthTries 3 разрывает соединение с сообщением Too many authentication failures, не дойдя до нужного ключа. IdentitiesOnly yes велит ssh предлагать только те ключи, что названы для этого хоста, даже если в агенте их больше. Поместите его в Host * ближе к концу файла и указывайте ключ в каждом блоке, которому он нужен.

IdentityFile принимает и файл .pub, если приватная половина хранится в другом месте, например в менеджере паролей, который работает как агент. ssh использует публичный ключ, чтобы выбрать, какой ключ агента предложить, - это самый аккуратный способ совместить IdentitiesOnly с агентом, в котором лежит дюжина ключей.

Проверка ключа хоста без привычки жать yes-yes-yes

StrictHostKeyChecking accept-new (OpenSSH 7.6 и новее) без вопросов добавляет ключи хостов, которых вы ещё не видели, и по-прежнему громко отказывает, если ключ известного хоста изменился. Это разумная настройка для лаборатории из одноразовых машин и плохая - для боевого сервера, к которому вы подключаетесь впервые: там запрос - единственный момент, когда можно сверить отпечаток. Ограничивайте её шаблоном, а не помещайте в Host *. UserKnownHostsFile может направить группу хостов в отдельный файл, что удобно для тестовой сети, которую пересоздают каждую неделю:

~/.ssh/config
Host *.lab    StrictHostKeyChecking accept-new    UserKnownHostsFile ~/.ssh/known_hosts_lab

ProxyJump и jump-серверы#

Jump-сервер (его же называют bastion) - это одна машина, до которой можно добраться из интернета и которая стоит перед другими, недоступными напрямую: база данных в частной сети, игровой сервер за файрволом, пропускающим SSH только с bastion, или все внутренние машины небольшой компании. ProxyJump делает этот переход невидимым.

~/.ssh/config
Host bastion    HostName 203.0.113.10    User jump    IdentityFile ~/.ssh/id_ed25519Host db    HostName 10.0.0.20    User deploy    IdentityFile ~/.ssh/id_ed25519    ProxyJump bastion

Теперь ssh db подключается к bastion, просит его открыть TCP-соединение с 10.0.0.20:22 и запускает второй, полноценный сеанс SSH через этот туннель. Важно, где происходит аутентификация: на вашем ноутбуке, от начала до конца, для обоих переходов. Bastion пересылает зашифрованные байты и никогда не видит ваш приватный ключ, ваш агент и ваш сеанс. Поэтому ProxyJump и заменил пробрасывание агента как правильный способ добраться до второй машины.

сеанс SSH 1только проброс TCPсеанс SSH 2, внутри 1Ваша машинахранит ключиbastionпубличный, порт 22db10.0.0.20, частный
ProxyJump оставляет оба входа у вас

Что стоит знать о нём:

  • Он появился в OpenSSH 7.3. В более старых версиях эквивалент - ProxyCommand ssh -W %h:%p bastion, который делает ту же работу и встречается в большинстве руководств до 2017 года. Не задавайте оба параметра для одного хоста: побеждает тот, что ssh прочитает первым.
  • Каждый переход ищется в конфиге. ProxyJump bastion использует адрес, пользователя, порт и ключ из блока bastion. Переход можно также записать прямо в строке как user@host:port.
  • Цепочки задаются через запятую. ProxyJump bastion,inner проходит через оба хоста по порядку, и каждый переход снова разбирается по конфигу.
  • В командной строке это `-J`. ssh -J jump@203.0.113.10 deploy@10.0.0.20 - разовая форма, и scp с sftp тоже принимают -J.
  • `ProxyJump none` в более конкретном блоке отключает переход, унаследованный от широкого шаблона ниже по файлу.

Целевая машина видит подключение как приходящее с частного адреса bastion, поэтому правила файрвола на внутренней машине должны разрешать SSH с этого адреса и ни с какого другого. На самом bastion sshd должен разрешать TCP-проброс: AllowTcpForwarding no или DisableForwarding yes в его sshd_config ломает любой переход сообщением channel 0: open failed: administratively prohibited.

Учётная запись на bastion, которая умеет только перебрасывать

Bastion полезнее, когда учётная запись на нём не может ничего, кроме проброса. В authorized_keys пользователя jump на bastion параметры ключа из руководства по защите делают именно это:

/home/jump/.ssh/authorized_keys (on the bastion)
restrict,port-forwarding,permitopen="10.0.0.20:22",permitopen="10.0.0.21:22" ssh-ed25519 AAAAC3Nza... laptop

restrict отключает всё необязательное, port-forwarding снова включает проброс, а permitopen ограничивает его перечисленными адресами назначения. С таким ключом одиночный ssh bastion не даёт оболочки, зато ssh db работает. Украденный ключ открывает два порта SSH на двух частных машинах, которым всё равно нужны собственные ключи.

Keepalive и зависающие соединения#

Сеанс, который через несколько минут простоя не закрывается, а зависает, почти всегда - результат того, что запись в таблице NAT домашнего роутера, мобильной сети или корпоративного файрвола тихо удалили. Исправляется это на стороне клиента:

~/.ssh/config
Host *    ServerAliveInterval 30    ServerAliveCountMax 3

Каждые 30 секунд тишины ssh отправляет запрос внутри зашифрованного канала и ждёт ответа. Этот трафик не даёт промежуточным устройствам забыть соединение, а если подряд пропадают три ответа, ssh закрывает сеанс примерно через 90 секунд, а не оставляет замёрзший терминал. Не путайте это с TCPKeepAlive, который включён по умолчанию и работает на уровне TCP; его проще подделать, а тайминг определяется операционной системой и часто составляет два часа, что совершенно бесполезно против роутера, который забывает соединение через пять минут.

ConnectTimeout 10 - парная настройка для скриптов. Без неё подключение к выключенной машине ждёт системного таймаута TCP, который может быть заметно больше минуты, и скрипт резервного копирования, обходящий хосты в цикле, застревает на первом же недоступном.

Переиспользование соединения с ControlMaster#

Мультиплексирование позволяет последующим сеансам к тому же хосту ехать по TCP-соединению первого. Второй ssh web, каждый scp и каждый шаг запуска Ansible или git pull стартуют мгновенно и не проходят аутентификацию заново. На медленном канале или через jump-сервер разница - секунды на каждую команду.

~/.ssh/config
Host *    ControlMaster auto    ControlPath ~/.ssh/cm-%C    ControlPersist 10m

ControlMaster auto делает первое соединение главным и позволяет последующим им пользоваться. ControlPath - локальный файл сокета; %C - хеш параметров соединения (локальный хост, удалённый хост, порт, пользователь), поэтому он уникален для каждого назначения и всегда короткий. Это важно: у сокетов Unix есть ограничение на длину пути порядка сотни символов, а старый стиль %r@%h:%p ломается на длинных именах хостов. ControlPersist 10m держит главное соединение открытым в фоне десять минут после закрытия последнего сеанса.

Главным соединением управляют через -O:

bash
$ ssh -O check web     # is a master running for this host?Master running (pid=48213)$ ssh -O exit web      # close it and every session on it

Ограничения реальны, и именно из-за них некоторые оставляют это выключенным:

  • Всё делит одно соединение. Если сеть пропадёт, все сеансы на нём умрут разом.
  • Настройки определяет главное соединение. Новый сеанс с другими параметрами, например с пробросом порта, присоединяется к существующему соединению, а параметры уровня соединения, включая пользователя и путь через jump-серверы, принадлежат главному. Если изменение конфига, похоже, ни на что не влияет, сначала выполните ssh -O exit.
  • Сокет - это учётные данные. Любой, кто может открыть этот файл, способен воспользоваться вашим уже аутентифицированным соединением, поэтому он должен лежать в каталоге, который читаете только вы.
  • Windows OpenSSH это не поддерживает. Во встроенном клиенте нет мультиплексирования через сокеты Unix, и строка ControlMaster приводит к ошибкам, а не к скорости. Используйте защиту через Match exec, держите её в отдельном файле для машин с Linux и macOS или работайте через WSL.

Windows OpenSSH: пути и подводные камни#

Windows 10 (1809 и новее) и Windows 11 поставляются с клиентом OpenSSH как C:\Windows\System32\OpenSSH\ssh.exe, и он читает конфиг в том же формате. Файл пользователя - C:\Users\<you>\.ssh\config, а ~ в IdentityFile работает. Пути с пробелами нужно брать в двойные кавычки; прямые слэши вовсе снимают вопросы об экранировании:

C:\Users\you\.ssh\config
Host web    HostName 203.0.113.10    User deploy    IdentityFile ~/.ssh/id_ed25519    IdentitiesOnly yesHost db    HostName 10.0.0.20    User deploy    IdentityFile "C:/Users/you/Key Store/id_db"    ProxyJump web

Все подводные камни лежат вне синтаксиса.

Кодировка. В Windows PowerShell 5.1 команда echo "Host web" > config пишет UTF-16, и ssh отвергает файл с невнятным Bad configuration option на первой строке. Создайте файл командой New-Item -ItemType File $HOME\.ssh\config и правьте его в редакторе, который сохраняет обычный UTF-8. PowerShell 7 по умолчанию пишет UTF-8 и этой проблемы не имеет.

Права на ключ. Windows ssh проверяет ACL приватных ключей и отказывается от ключа, который могут читать другие учётные записи, с сообщением UNPROTECTED PRIVATE KEY FILE. Обычно это случается с ключом, скопированным откуда-то ещё. Уберите унаследованные права и дайте доступ только себе:

code
icacls "$HOME\.ssh\id_ed25519" /inheritance:ricacls "$HOME\.ssh\id_ed25519" /grant:r "$($env:USERNAME):(R)"

Агент - это служба. AddKeysToAgent yes ничего не делает, пока не запущен OpenSSH Authentication Agent. Из PowerShell с правами администратора:

code
Get-Service ssh-agent | Set-Service -StartupType AutomaticStart-Service ssh-agent

Две программы ssh. Git for Windows поставляет собственный ssh в C:\Program Files\Git\usr\bin. Он читает тот же ~/.ssh/config, но использует свой агент, поэтому ключи, добавленные Windows-версией ssh-add, для Git невидимы. Исправить это можно, указав Git системный клиент: git config --global core.sshCommand "C:/Windows/System32/OpenSSH/ssh.exe". Расширение Remote-SSH для VS Code читает тот же файл конфига, так что каждое имя Host, которое вы задали, появляется в его списке.

Рецепты, которые нужны снова и снова#

Два аккаунта GitHub на одной машине

GitHub определяет вас по ключу, а каждый аккаунт подключается как git@github.com, поэтому второму аккаунту нужны второй ключ и псевдоним:

~/.ssh/config
Host github-work    HostName github.com    User git    IdentityFile ~/.ssh/id_work    IdentitiesOnly yes

Клонируйте, подставив псевдоним вместо имени хоста, - git clone git@github-work:company/app.git - и проверяйте командой ssh -T github-work. Без IdentitiesOnly агент может предложить личный ключ первым, GitHub его примет, и вы отправите изменения не от того человека.

Доступ к базе данных через SSH

База данных, которая на сервере слушает только 127.0.0.1, доступна с вашего ноутбука через проброс порта, который можно хранить в конфиге:

~/.ssh/config
Host web-pg    HostName 203.0.113.10    User deploy    LocalForward 15432 127.0.0.1:5432    SessionType none

ssh web-pg затем держит туннель открытым без оболочки (SessionType none требует OpenSSH 8.7; на более старых клиентах используйте ssh -N web-pg), а клиент на вашей машине подключается к localhost:15432. Это часто лучший ответ, чем открывать порт базы данных в интернет; подробно об этом говорится в статье удалённые подключения к PostgreSQL.

Разделение файла через Include

Include (OpenSSH 7.3 и новее) читает другие файлы, в том числе по маске, а относительные пути отсчитываются от ~/.ssh. Ставьте его в самое начало, вне любого блока, иначе он будет действовать только внутри блока, в котором стоит:

~/.ssh/config
Include config.d/*Host *    IdentitiesOnly yes    ServerAliveInterval 30

Один файл на клиента или проект в ~/.ssh/config.d/ оставляет длинный конфиг читаемым и позволяет удалить все хосты проекта за один шаг.

Управляемые серверы по SFTP

У игрового, прикладного или веб-сервера на хостинг-панели обычно нет оболочки, но блок пригодится и для его доступа по SFTP. На RE:NODE SFTP доступен на каждом сервере, а учётные данные выдаются на сервер, а не на аккаунт, поэтому блок на каждый сервер с хостом, портом и именем пользователя из панели позволяет заменить длинную строку командой sftp mc-world, а редакторы, читающие конфиг, покажут его по имени. Пароль всё равно запрашивается каждый раз; не пытайтесь навязать ключ через IdentitiesOnly в таком блоке. О клиентах и о стороне панели рассказано в статье SFTP и файловый менеджер.

Полный конфиг для старта#

Собранный вместе, конфиг для одного VDS, закрытой машины за ним и GitHub выглядит так:

~/.ssh/config
Include config.d/*Host vds    HostName 203.0.113.10    User deploy    IdentityFile ~/.ssh/id_ed25519Host db    HostName 10.0.0.20    User deploy    IdentityFile ~/.ssh/id_ed25519    ProxyJump vdsHost github.com    User git    IdentityFile ~/.ssh/id_ed25519Host *    IdentitiesOnly yes    AddKeysToAgent yes    ServerAliveInterval 30    ServerAliveCountMax 3    ConnectTimeout 10    ControlMaster auto    ControlPath ~/.ssh/cm-%C    ControlPersist 10m

В Windows удалите три строки Control. Если сервер свежий, учётную запись и файрвол на другой стороне разбирает материал первый час на новом VDS, а руководство по ufw показывает, как разрешить SSH только с bastion, когда jump-сервер уже настроен.

Диагностика конфига#

СимптомОбычная причина
Настройки словно игнорируютсяИх раньше задал предыдущий блок
Too many authentication failuresАгент предлагает все ключи; добавьте IdentitiesOnly yes
Bad owner or permissionsКонфиг или ключ доступен на запись другим
Bad configuration option на строке 1Файл в UTF-16 из PowerShell 5.1
administratively prohibited на переходеНа bastion отключён проброс
Изменение конфига не действуетСоединение ControlMaster ещё открыто
Could not resolve hostnameДля имени нет блока Host или в нём опечатка

Для всего остального метод один и тот же - три шага. ssh -G name показывает, какими ssh считает настройки. ssh -v name показывает, какие строки конфига применены, какие ключи предложены и где остановилось подключение; при ProxyJump вывод охватывает оба перехода, так что смотрите, на каком из них сбой. ssh -F /dev/null user@address полностью включает файл в круг подозреваемых или исключает его.

FAQ#

Используют ли scp, rsync и Git мой конфиг SSH?

Да. Все они запускают системный клиент ssh, который читает ~/.ssh/config, поэтому псевдоним Host работает везде, где вы написали бы имя хоста: scp file web:/tmp/, rsync -a dir/ web:/srv/, git@github-work:org/repo.git. Программы со своей реализацией SSH, например некоторые графические клиенты SFTP, могут игнорировать файл или читать только его часть.

В чём разница между ProxyJump и пробросом агента?

При ProxyJump оба входа происходят на вашей машине, а промежуточный хост лишь передаёт байты. При пробросе агента (ssh -A) вы входите на промежуточный хост и затем пользуетесь своим агентом оттуда, так что любой с root на той машине может позаимствовать ваши ключи, пока вы подключены. Используйте ProxyJump всякий раз, когда цель - добраться до другой машины.

Можно ли одновременно использовать другой порт и jump-сервер?

Да. Port в блоке относится к хосту этого блока, а каждый переход разбирается отдельно, так что для bastion на порту 2222 и цели на 22 нужны только их собственные строки Port. В строке то же самое делает ProxyJump jump@203.0.113.10:2222.

Безопасно ли включать ControlMaster для всех хостов сразу?

В Linux и macOS для интерактивной работы это безопасно, если ControlPath указывает внутрь вашего собственного каталога ~/.ssh. Некоторые исключают хосты, которые используются для долгих передач файлов, поскольку один застрявший главный сеанс останавливает все сеансы на нём. В Windows не включайте его вовсе.

Почему ssh всё равно спрашивает про ключ или берёт не тот?

Обычно в агенте лежит несколько ключей, а IdentitiesOnly не задан, либо в пути IdentityFile опечатка, и ssh молча откатывается к ключам по умолчанию. ssh -v перечисляет по порядку все предложенные ключи, а ssh -G показывает, какие строки identityfile он действительно разобрал.