У меня в проекте было четырнадцать копий одного IP‑адреса. По одной в каждом скрипте, который ходит на боевой сервер: деплой, перезапуск сервиса, правка DNS, диагностика почты. Классический копипаст, который живёт до первого переезда.

Переезд случился. Адрес поменялся. Четырнадцать скриптов стали указывать в никуда.

Дальше начинается то, ради чего я это пишу.

Скрипт не упал

Я ожидал четырнадцать таймаутов. Вместо этого скрипт подключился.

Прежний адрес провайдер уже отдал другому клиенту — так работают облачные и VPS‑провайдеры, освободившийся IP не лежит в резерве, он уходит в пул и выдаётся следующему. На том конце был живой SSH‑сервер. Мой скрипт установил соединение и отправил туда рутовый пароль из переменной окружения VPS_PASS.

Не человеку в чёрной шапке. Просто какому‑то клиенту того же провайдера, которому достался мой бывший адрес.

Виновата была одна строка:

ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())

Что именно делает эта строка

Когда SSH‑клиент подключается к серверу, тот представляется своим ключом хоста. Клиент сверяет ключ с тем, что записан в known_hosts. Совпал — продолжаем. Не совпал — останавливаемся и кричим.

Этот крик все видели:

WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
IT IS POSSIBLE THAT SOMEONE IS DOING SOMETHING NASTY!

Он выглядит как назойливость, потому что в 99% случаев ключ сменился по скучной причине: сервер переустановили. Но проверка существует ради оставшегося процента — она отвечает на вопрос «я разговариваю с той же машиной, что и в прошлый раз, или с другой, которая притворяется ею».

AutoAddPolicy отвечает на этот вопрос «неважно» и молча принимает любой ключ.

Обычно её объясняют защитой от MITM — и поэтому она кажется теорией: «кто будет перехватывать мой трафик до моего же сервера». Но перехват тут не нужен. Достаточно, чтобы адрес перестал принадлежать вам. Провайдер сделает это сам, бесплатно и без злого умысла.

Отдельно неприятно, что в логах это выглядит как успех. Соединение установлено, команда отправлена, код возврата нулевой. Ничего не сломалось — просто пароль теперь есть у постороннего.

Как чинил

Три правки, и все три обязательные — по отдельности каждая закрывает только часть.

Первое. Строгая политика и явная загрузка known_hosts:

ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.load_host_keys(str(Path.home() / ".ssh" / "known_hosts"))
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())

RejectPolicy не «строже». Она возвращает поведение по умолчанию: незнакомый или сменившийся ключ — исключение до того, как в сокет уйдёт хоть один байт учётных данных. Ключ нового сервера добавляется в known_hosts один раз, руками, когда вы точно знаете, что это ваша машина.

Второе. Адрес живёт в одном месте. У меня это модуль vps.py, из которого его импортируют все скрипты. Протухнуть в одном файле из четырнадцати он больше не может, а переезд правится одной строкой.

Третье. Ключ вместо пароля:

key_path = os.environ.get("VPS_KEY", str(Path.home() / ".ssh" / "id_ed25519"))
if Path(key_path).exists():
    ssh.connect(host, username=user, key_filename=key_path)
else:
    ssh.connect(host, username=user, password=os.environ["VPS_PASS"])

Приватный ключ не уходит на сервер — уходит подпись. Даже если вы всё‑таки подключились не туда, красть у вас нечего. Пароль оставлен запасным путём, но он именно запасной.

Тот же вопрос в другом месте

Через неделю после этой истории я делал на сайте инструмент: пользователь вводит адрес, сервер загружает страницу и показывает её вес, блокирующие скрипты и платформу.

Это ровно тот же класс проблемы, только с другой стороны. Там я не доверял чужому серверу, здесь сервер по моей команде идёт по чужому адресу. Оба раза вопрос один: «а точно ли на том конце то, что я думаю».

Что попробует ввести первый же любопытный:

http://127.0.0.1/            — то, что крутится на самом хосте
http://169.254.169.254/      — метаданные облака, в них бывают токены
file:///etc/passwd           — а вдруг клиент проглотит схему

Поэтому в чекере: только http и https, резолв имени до запроса и проверка полученного IP по приватным диапазонам, петле и link‑local, и повторная проверка после каждого редиректа — иначе внешний хост отдаст 302 на 127.0.0.1 и обойдёт весь входной контроль.

На это у меня стоят смоук‑тесты, которые гоняются против боевого сервера, а не против моков:

ok   blocked http://127.0.0.1/
ok   blocked http://169.254.169.254/
ok   blocked file:///etc/passwd

Вывод

Я не открыл ничего нового: не отключайте проверку ключа хоста — это написано в любом гайде. Мне было полезно понять, почему совет не работает.

Он звучит как защита от гипотетического злоумышленника, а выглядит как лишнее окно, которое мешает автоматизации. И его отключают не по глупости, а потому что риск кажется абстрактным.

Он не абстрактный. Достаточно, чтобы у вас поменялся сервер.

Проверьте свои деплой‑скрипты на AutoAddPolicy. Их обычно писали давно, второпях, и с тех пор никто в них не заглядывал.

Комментарии (1)


  1. Dzzzen
    29.08.2026 16:08

    Нейрослоп прямо в заголовке(( Может проще на хабре промпт публиковать, а кого заинтересует, сам его отправит ИИ?