Автоматизация сбора данных с хостов Python: скрипт на 40 машин вместо ручного SSH

Каждый квартал я собирал версии OpenSSH с 40 серверов для аудита безопасности. Терминал, ssh user@host, команда, записать результат в таблицу, следующий хост. На 40 машинах — 2.5–3 часа, и это без учёта зависших подключений и опечаток при копировании вывода. Первый Python-скрипт с paramiko сократил всё до 4 минут. Ниже — пошаговая сборка такого скрипта: от единичного подключения до параллельного опроса десятков хостов с выгрузкой в CSV.
Почему bash-цикл — тупик для массового обхода хостов
Стандартный рефлекс — bash-цикл: for host in $(cat hosts.txt); do ssh user@$host "uname -a"; done. На бумаге выглядит рабочим. На практике плохо масштабируется, и вот почему.
Последовательное выполнение. Каждое SSH-подключение — 3–5 секунд: TCP-хендшейк, аутентификация, выполнение команды, закрытие. 40 хостов — минимум 2–3 минуты чистого ожидания. А если один хост не отвечает и SSH висит до дефолтного таймаута (30–60 секунд) — этот мёртвый сервер тормозит весь обход. В обзоре инструментов parallel SSH на linux.com проблема описана прямо: «if a task needs an hour to complete and you need to run it on 20 hosts, parallel execution beats serial by a mile».
Слепота к ошибкам. Bash-цикл не останавливается при ошибке подключения — идёт дальше. Ошибка мелькнула в терминале и уехала вверх. Какие хосты пропущены — непонятно, пока не сверишь вывод с исходным списком вручную. На 40 хостах это раздражает. На 200 — прямая потеря данных.
Credentials в открытом виде. Без ключевой аутентификации bash-цикл требует пароль для каждого хоста. Альтернатива — sshpass с паролем в командной строке, который виден в ps aux и остаётся в ~/.bash_history. SSH brute-force (категория #22 по классификации AbuseIPDB — сервис отчётности о вредоносных IP; категория включает brute-force и credential stuffing на порт 22) — одна из самых частых категорий атак по объёму репортов. Хардкод пароля в скрипте — подарок атакующему при компрометации рабочей станции.
Python решает все три проблемы: параллельные подключения через пул потоков, обработка исключений для каждого хоста отдельно и нормальная работа с SSH-ключами или хранилищами секретов.
Paramiko Python SSH: подключение и выполнение команд на хосте
Paramiko — Python-библиотека для работы с протоколом SSHv2. Подключение, аутентификация по паролю или ключу, выполнение команд, передача файлов через SFTP — всё на чистом Python, без зависимости от системного ssh-клиента. Paramiko работает под капотом Ansible и Fabric (Python-библиотека для удалённого выполнения команд на группе хостов). По данным сравнительного обзора пяти Python SSH-библиотек (elegantnetwork.github.io) — зрелое решение с широкой поддержкой методов аутентификации: пароль, ключевой файл, passphrase, SSH-агент.
Предпосылки. Python 3.6+ (проверить: python3 --version), установленный paramiko (pip install paramiko>=3.4.1). Более ранние версии содержат серьёзные уязвимости: GHSA-f2j6-wrhh-v25m (обход аутентификации, затронуты <2.11.1 и >=3.0,<3.4.1), GHSA-45×7-px36-x8w8 (Terrapin — prefix truncation attack, затронуты <3.4.0), GHSA-232r-66cg-79px (недостаточная проверка аутентификации, затронуты <3.4.0; исправлено в 3.4.0). Также нужен доступ к целевому хосту по SSH — ключ добавлен в ~/.ssh/authorized_keys или известен пароль.
Минимальный скрипт — подключение к одному хосту и сбор данных:
import paramiko
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect('192.168.1.10', username='audit',
key_filename='/home/user/.ssh/id_rsa', timeout=10)
_, stdout, _ = client.exec_command('cat /etc/os-release | head -2')
print(stdout.read().decode().strip())
client.close()
В терминале появится строка вида NAME="Ubuntu" и VERSION_ID="22.04". Если вместо этого Authentication failed — проверьте, что публичный ключ добавлен на хосте (cat ~/.ssh/authorized_keys). Если Connection timed out — хост недоступен или SSH-сервер не слушает порт 22.
Про безопасность. AutoAddPolicy() принимает любой ключ хоста без проверки — допустимо в лабораторной среде. В продакшене замените на RejectPolicy() с предварительно наполненным файлом known_hosts, иначе скрипт уязвим для man-in-the-middle. И убедитесь, что paramiko ≥3.4.1: уязвимости аутентификации в ранних версиях усиливают риск при некорректной проверке identity хоста.
В терминах MITRE ATT&CK (открытая база тактик и техник атак — каждая техника имеет идентификатор вроде T1046) такой скрипт реализует Network Service Discovery (T1046, тактика Discovery): обнаружение версий ПО на хостах. Пентестеры используют эту технику для поиска уязвимых сервисов, защитники — для инвентаризации. Сам Python как инструмент выполнения — техника T1059.006 (Command and Scripting Interpreter: Python, тактика Execution). Если SOC (Security Operations Center — команда мониторинга ИБ) увидит массовые SSH-подключения от одного IP, Sigma-правило (например, net_firewall_susp_network_scan_by_ip.yml из репозитория SigmaHQ) может поднять алерт. Учитывайте при запуске из корпоративной сети — предупредите SOC заранее, чтобы вас не заблокировали за «сканирование».
Многопоточность Python скрипты: параллельный опрос через concurrent.futures
Одно подключение работает — пора масштабировать. Модуль concurrent.futures (стандартная библиотека Python 3.2+, ничего ставить не нужно) создаёт пул потоков и распределяет задачи. Класс ThreadPoolExecutor управляет пулом, где потоки (threads) — легковесные единицы параллельного выполнения внутри одного процесса.
Почему потоки, а не процессы. SSH-подключение — I/O-bound операция: основное время уходит на ожидание ответа от удалённого хоста. Пока один поток ждёт ответа от хоста A, другой уже подключается к B. ThreadPoolExecutor для таких задач быстрее и экономнее ProcessPoolExecutor, который поднимает полноценные процессы ОС и нужен для CPU-bound нагрузки — шифрование, парсинг больших файлов.
Функция collect_data(host) — обёртка над paramiko-подключением из предыдущего раздела. Принимает IP хоста, выполняет команду, возвращает кортеж (host, результат_или_ошибку). Обработка исключений внутри функции гарантирует: если хост недоступен, скрипт не упадёт — вернётся строка ERROR: Connection timed out.
Запуск параллельного обхода:
from concurrent.futures import ThreadPoolExecutor, as_completed
hosts = open('hosts.txt').read().splitlines()
with ThreadPoolExecutor(max_workers=10) as pool:
futures = {pool.submit(collect_data, h): h for h in hosts}
for f in as_completed(futures):
host, result = f.result()
print(f'{host}: {result}')
Скрипт одновременно подключится к 10 хостам (параметр max_workers). as_completed отдаёт результаты по мере завершения — вывод появляется не в порядке списка, а по скорости ответа. 40 хостов при max_workers=10 — четыре волны по 10 подключений. Общее время — порядка десятков секунд вместо минут последовательного обхода, если все хосты доступны. Но если среди них есть мёртвый — время волны с этим хостом растягивается до таймаута (10–60 секунд в зависимости от timeout), и итоговое время определяется самым медленным хостом в каждой волне.
Как подобрать max_workers. Для SSH-задач 10–20 работает хорошо. Больше 50 — риск отказа на стороне хостов: превышение лимита одновременных SSH-сессий или срабатывание fail2ban. Я ставлю 15 для сетей до 100 хостов и 25 для сетей до 300.
Разница с threading и multiprocessing. concurrent.futures — высокоуровневая обёртка над обоими. Для сетевых задач ThreadPoolExecutor достаточен. Если после сбора данных нужна CPU-тяжёлая обработка (хеширование файлов, парсинг дампов) — переключение на ProcessPoolExecutor занимает замену одного слова в коде. Для продвинутых сценариев есть asyncio с библиотекой asyncssh — полноценная асинхронная реализация SSH, которая по бенчмаркам (elegantnetwork.github.io) даёт преимущество на масштабах 100+ хостов.
Безопасное хранение credentials для автоматизации SSH подключений
Хардкод пароля в скрипте — классический анти-паттерн. Пароль попадает в git log, в бэкап ноутбука, в Slack-тред «помоги дебажнуть». Три варианта — от простого к надёжному.
SSH-ключи (рекомендуемый минимум). Генерация: ssh-keygen -t ed25519 -C "audit-script". Публичный ключ раскладывается на хосты через ssh-copy-id или через Ansible. Paramiko принимает путь к ключу через key_filename. Если ключ защищён passphrase — paramiko запросит его интерактивно или примет через SSH-агент (ssh-agent).
Переменные окружения. Когда ключевая аутентификация невозможна (legacy-оборудование, сетевые устройства): пароль в переменной export SSH_AUDIT_PASS='...', в скрипте — os.environ['SSH_AUDIT_PASS']. Файл .env добавляется в .gitignore. Библиотека python-dotenv загружает переменные из файла автоматически.
Ansible Vault / HashiCorp Vault. Для продакшен-автоматизации на десятках скриптов: ansible-vault encrypt_string шифрует секреты AES-256, расшифровка — при запуске по мастер-паролю. HashiCorp Vault — отдельный сервер секретов с API, ротацией и журналированием доступа. Если масштаб вырос до сотен хостов и нескольких команд — Vault окупает себя за первую же утечку, которой не произошло.
Сбор данных с множества хостов: от терминала к CSV-отчёту
Вывод в терминал полезен для отладки, но результат аудита — таблица: хост, версия ПО, дата, статус. Модуль csv (стандартная библиотека Python) записывает результаты в файл:
import csv
with open('audit_report.csv', 'w', newline='') as f:
writer = csv.writer(f)
writer.writerow(['host', 'ssh_version', 'status'])
for host, result in results:
status = 'OK' if 'ERROR' not in result else 'FAIL'
writer.writerow([host, result, status])
На выходе — файл audit_report.csv с тремя колонками. Хосты со статусом FAIL — не ответившие. Файл открывается в Excel или Google Sheets, либо обрабатывается через pandas (pd.read_csv('audit_report.csv')) для фильтрации и визуализации.
На практике я собираю не только версию SSH, но и uname -r (ядро), df -h / (свободное место), last -1 (последний логин). Каждая команда — дополнительный вызов exec_command() внутри одного SSH-подключения. Переподключаться для каждой команды не нужно — paramiko держит сессию открытой.
Когда скрипт, а когда Ansible
Python-скрипт с paramiko идеален для задач «выполнить команду, собрать вывод, записать результат». Ansible — для задач «привести хост в нужное состояние»: установить пакет, изменить конфигурацию, перезапустить сервис. Если задача — чтение, а не изменение, скрипт проще и не требует inventory-файла, playbook и ролей. Fabric — промежуточный вариант: Python API для выполнения команд на группе хостов с поддержкой sudo и параллельного запуска, но с меньшим порогом входа, чем Ansible.
Автоматизация рутинных задач Python — не замена Ansible. Это способ превратить трёхчасовой ручной обход в скрипт на 30 строк, который запускается по cron и кладёт отчёт в нужную папку.
Третий год наблюдаю одну и ту же историю: команда разворачивает Ansible ради задачи «раз в месяц собрать версии ПО с 30 хостов». Пишут inventory, playbook, роль, дебажат Jinja-шаблоны неделю — и получают результат, который paramiko-скрипт выдал бы за вечер. Ansible великолепен для configuration management. Но для чистого сбора данных — это экскаватор для лунки под саженец.
Скрипт на paramiko + concurrent.futures + csv — понятный, контролируемый, расширяемый без поиска ответов на Stack Overflow на каждом шаге. Проблема не в инструментах. Проблема в том, что инженеры тянутся к фреймворкам там, где хватает библиотеки. Навык «написать скрипт под конкретную задачу» при переходе в ИБ стоит дороже, чем знание любого отдельного фреймворка — и автоматизация сбора данных с хостов python учится быстрее, чем кажется. Если переходишь из смежной сферы и хочешь пройти эту базу системно, а не ковыряться в разрозненных туториалах — на IB Basics эту цепочку закрывают за пару месяцев без воды.
Эту тему и смежные навыки разбирают на практике в курсе «Python для пентестера» Codeby Academy.