Зоопарк версий под контролем: как я создал enodia — инвентаризацию сервисов с EOL и CVE
- суббота, 31 октября 2026 г. в 00:00:09
Сколько у вас в инфраструктуре версий PostgreSQL? А какие из них уже не поддерживаются? А сколько CVE висит на том nginx, который «работает — не трогай»?
Если вы ответили на все три вопроса без поиска по чатам и вики, дальше можно не читать. А я в какой‑то момент понял, что не могу, и написал для этого инструмент. Под катом история о том, как он появился, на какие грабли я наступил по дороге и почему MySQL однажды заблокировал собственный хост из‑за моего мониторинга.
У меня есть домашняя лаба pet‑проекта. Есть и работа: я ведущий DevOps‑инженер, и масштаб там, думаю, понятен. И там, и там всегда был и будет зоопарк версий. Где‑то стоит PostgreSQL 13, потому что «на нём так и запускали». Где‑то Jira, которую страшно обновлять. Где‑то Debian, поставленный три point‑релиза назад. Отдельно живут NAS, гипервизоры, BMC серверов и роутеры.
Пока сервисов десяток, их версии можно держать в голове. Когда их сотни, начинаются вопросы. Что именно у нас развёрнуто? Что из этого уже EOL или станет EOL через полгода? Где мы отстали на патч, а где на целый мажор? Где висят известные уязвимости?
Я взялся навести в этом порядок. Сразу было понятно, что вручную ничего не получится: обойти сотню сервисов и записать версии в табличку можно один раз, но через месяц табличка уже врёт.
Первое, что приходит в голову DevOps‑инженеру — Ansible. Так я и сделал: прошёлся плейбуком по хостам, собрал версии ОС (в основном Debian), выгрузил в CSV, а из CSV собрал гуглотаблицу для команды.
Получилось прикольно, но для одного раза. Дальше начались проблемы:
Отчёт нужно перегенерировать, а потом ещё и понять, что изменилось с прошлого раза. Поддерживать диффы руками больно.
Табличка знает версию, но не знает, хороша ли она. Сроки поддержки приходилось смотреть отдельно.
Ansible хорошо ходит по SSH. А Jira, PostgreSQL, Redis, NAS или BMC сервера — уже не хосты с SSH, а сервисы со своими протоколами.
Захотелось инструмента, который был бы одновременно масштабным, чтобы закрыть весь парк, и атомарным: одна команда, понятный результат, который можно положить в cron или CI.
Перед тем как писать своё, я честно поискал готовое. Нашлось много хорошего, но каждый инструмент закрывает только свой кусок:
endoflife.date и его CLI знают календари поддержки, но не знают, что развёрнуто у вас.
nvchecker знает свежие версии в апстриме, но тоже не знает, что развёрнуто.
Renovate и Dependabot работают с зависимостями в репозитории, а не с живыми серверами.
What's Up Docker следит за тегами контейнеров, а всё, что не в Docker, остаётся вне поля зрения.
Uptime Kuma и прочий мониторинг отвечают на вопрос «живо ли», а не «устарело ли».
Ни один из них не делает главного — не соединяет «что реально развёрнуто» x «календарь поддержки» x «известные уязвимости». Так появился enodia.
enodia — это CLI на Go. Распространяется одним бинарником под лицензией AGPL-3.0. Вы описываете парк в enodia.yaml, enodia опрашивает каждый сервис, узнаёт реальную версию и сверяет её со сроками поддержки и базами CVE.
Девиз проекта я сформулировал так: «Знайте, что у вас запущено, и сколько ему осталось» (в оригинале Know what you are running, and how long it has left). А вся механика укладывается во вторую фразу: «один раз описали сервисы — дальше enodia сама разберётся» (You describe your services once. Enodia handles the rest).
Пара слов о названии. Энодия («та, что у обочины») — эпитет Гекаты, факелоносицы и хранительницы перекрёстков. Мне показалось, что это подходящее имя для инструмента, который высвечивает увядающее и стоит ровно там, где вы выбираете, в какую сторону обновляться.
Конфиг выглядит так (это демо-стенд из статьи, о нём ниже):
schemaVersion: 1 targets: - id: billing-db product: postgresql address: 127.0.0.1:25432 credentials: pg - id: crm-mysql product: mysql address: 127.0.0.1:23306 - id: web-front product: nginx address: http://127.0.0.1:28080 - id: app01 product: debian address: 127.0.0.1:22022 credentials: app01-ssh credentials: pg: kind: password username: postgres password: "${DEMO_PASSWORD}"
Несколько решений, которые я принял в самом начале и о которых не жалею.
Проба знает свой продукт. Под каждый продукт написана своя проба на Go: для PostgreSQL она говорит на протоколе PostgreSQL, для Redis — на протоколе Redis, для Jira ходит в её REST API, для ОС подключается по SSH. Сейчас проб 123: Atlassian, GitLab, Jenkins, TeamCity, SonarQube, Keycloak, Vault, Grafana, базы данных и брокеры, ESXi и vCenter, Proxmox, NAS, BMC серверов, pfSense и RouterOS, а также 31 ОС по SSH. Для всего остального есть generic-проба: URL плюс regex или путь в JSON.
Продукт вы указываете в конфиге явно, а проба проверяет, что на том конце именно он. Если вы написали mysql, а там отвечает MariaDB, вы получите ошибку, а не тихо записанную неправильную версию. Почему это важно, станет видно ниже.
Сбор отдельно от оценки. Работа разбита на две фазы:
enodia collect -o inventory.jsonl # ходит по сервисам, пишет факты enodia check --from inventory.jsonl # сверяет факты с календарями и CVE
collect ничего не оценивает, он только собирает наблюдения. Недоступный сервис для него не ошибка, а факт «недоступен». check работает с готовым файлом. Это удобно в закрытом контуре: собрали инвентарь внутри, вынесли JSONL и оценили там, где есть интернет и базы. Можно и наоборот: базы занести внутрь контура, о них ниже.
Три оси вместо одного статуса. Сначала хотелось выдавать один вердикт: «хорошо» или «плохо». Но возьмём Confluence в LTS-ветке. Он обновлён до последнего патча своей ветки, ветка ещё поддерживается, и при этом уже вышел новый мажор. Это три независимых факта, и любой из них может быть важен. Поэтому в enodia три оси:
PATCH — отстаём ли мы внутри своей ветки: current, behind;
LIFECYCLE — поддерживается ли ветка: active, security (только исправления безопасности), eol;
BRANCH — есть ли ветка новее: latest, newer, newer_lts.
Из этих осей складывается итоговая серьёзность info / warn / fail, и от неё зависит код выхода: 0 — всё в порядке, 3 — есть предупреждения, 4 — есть провалы. Поэтому enodia check удобно ставить в CI. Если какая-то ось для вас критична, её можно ужесточить: например, --fail-on=patch:behind.
HTTPS по умолчанию и никаких паролей в конфиге. Если в адресе не указана схема, enodia идёт по HTTPS, чтобы учётные данные не уходили открытым текстом. Пароли подставляются из переменных окружения через ${VAR}. Их можно вынести в отдельный credentials-файл или получать через Vault Agent.
На этом этапе я думал, что самое сложное позади: спросил версию — записал версию. Как бы не так.
Сервер MySQL (и MariaDB) сразу после TCP-подключения сам присылает приветствие, и в нём есть версия. Логин для этого не нужен. Удобно: подключился, прочитал первый пакет, отключился. Так и работала проба до версии 2.2.1.
Подвох в том, что для сервера такое соединение выглядит как прерванное до аутентификации. Такие соединения с одного хоста сервер считает, и когда их набирается больше max_connect_errors, он блокирует хост целиком:
ERROR 1129 (HY000): Host '172.20.3.10' is blocked because of many connection errors; unblock with 'mariadb-admin flush-hosts'
Блок действует для всех клиентов с этого хоста. Если enodia запускается с того же сервера, где живёт приложение, то мой мониторинг версий однажды положил бы продакшен-приложение. Отличная иллюстрация к поговорке про лекарство и болезнь.
Воспроизвести это оказалось на удивление непросто. В официальных Docker-образах MySQL и MariaDB в конфиге стоят skip-name-resolve и skip-host-cache, и счётчик просто не ведётся. Для блока нужно, чтобы сервер разрешал имена клиентов и держал host cache. Именно так ведут себя «обычные» установки из пакетов. На демо-стенде я включил это руками и поставил max_connect_errors=3. На MariaDB 10.6 всё сработало как по нотам. После четырёх прогонов старой версии enodia:
$ mariadb -h db -e "SELECT 1" # обычный клиент с того же хоста ERROR 1129 (HY000): Host '172.20.3.10' is blocked because of many connection errors
А на пятом прогоне enodia заблокировала саму себя:
"error":"unreachable: server sent an ERR packet (code 1129) instead of a handshake"
В логе сервера это выглядит так:
[Warning] Aborted connection 24 to db: 'unconnected' user: 'unauthenticated' host: '172.20.3.10' (This connection closed normally without authentication)
Решение кажется контринтуитивным: надо попытаться залогиниться. Сервер считает в max_connect_errors только соединения, прерванные до аутентификации. Соединение, которое дошло до неё и получило отказ, считается установленным и в счётчик не попадает. Поэтому с 2.2.1 проба после чтения версии отвечает на приветствие пользователем enodia с пустым паролем, получает честный Access denied и вежливо прощается через COM_QUIT. Если сервер на MySQL 8 предлагает сменить плагин аутентификации на caching_sha2_password, проба отвечает пустым пакетом и дочитывает отказ.
Теперь в логе другая запись, и по ней сразу видно, кто стучался:
[Warning] Access denied for user 'enodia'@'app02.demo.lan' (using password: NO)
Сколько ни запускай новую версию, блока нет.
Помните про проверку, что на том конце именно тот продукт? Вот зачем она нужна. MariaDB 10.x представляется так:
5.5.5-10.11.19-MariaDB-ubu2204
Префикс 5.5.5- — маскировка под MySQL для старых клиентов, которые не знали схему версий MariaDB. Если читать версию в лоб, получится MySQL 5.5.5, давно мёртвый. А начиная с 11.0 MariaDB маскироваться перестала и пишет просто 11.4.13-MariaDB-ubu2404. Префикса нет, и единственный признак — подстрока -MariaDB. До версии 2.1.1 enodia записывала такие серверы как MySQL.
Ещё один класс проблем — версия формально есть, но она не та.
Debian. В /etc/os-release поле VERSION_ID никогда не содержит point-релиз. Полностью обновлённый Debian 13 и Debian 13, поставленный в день релиза, одинаково показывают 13. Настоящая версия, например 13.6, лежит в /etc/debian_version. Проба читает оба файла.
Ubuntu. Здесь наоборот: VERSION_ID замораживается на 22.04 на всю жизнь релиза, а point-релиз 22.04.5 есть только в поле VERSION.
VMware. endoflife.date пишет последнюю версию vCenter как 8.0 U3k, а ESXi — как 8.0 Update 3k. Наивный парсер отрезает всё после пробела и видит 8.0. В итоге любой хост на Update 3 выглядит новее календаря. Пришлось научить разбор версий понимать вендорское «Update N» и превращать его в 8.0.3.
Коротко, потому что это тянет на отдельную статью. Документации на протокол Perforce нет, поэтому я снял дамп обмена официального клиента p4 info и разобрал формат. Свой клиент воспроизводил рукопожатие байт в байт и работал против Perforce Proxy. А живые серверы p4d это же рукопожатие молча проглатывали: ни ответа, ни ошибки, ни RST. Обязательный TLS и rate limit я исключил, а официальный p4 с той же машины подключался без проблем. В итоге проба для Perforce стала первой и единственной, которая вызывает внешний CLI: путь к p4 задаётся в options.binary. Позже нашлась ещё одна мелочь: вызов p4 шёл без таймаута и на недоступном сервере мог висеть вечно. Исправлено в 1.2.1.
Допустим, версию мы узнали. Теперь нужно понять, хороша ли она. И тут выяснилось, что единой базы «продукт — ветки — даты окончания поддержки» не существует.
Ближе всего к ней endoflife.date. Это открытый проект, где сообщество поддерживает календари поддержки для сотен продуктов: ветки, даты EOL, последние версии. enodia берёт календари оттуда в первую очередь.
Но части нужного мне софта в endoflife.date не было, а у части была неполная история. Поэтому я туда законтрибьютил. На момент написания статьи приняты мои PR с двумя новыми продуктами, Jira Service Management и pgAdmin, с полной историей веток Confluence (1.0–7.12), Bitbucket (1.0–8.6) и Bamboo (0.9–7.2), а также с правками дат EOL у Jira Software и Confluence. На ревью висит ещё 21 продукт: TeamCity, TestRail, Crowd, FishEye и Crucible, Bitbucket Mesh, линейка Perforce Helix, pfSense CE и Plus, FreeRADIUS, Synology DSM и SRM, Jellyfin, Vaultwarden, Bitwarden Server, Owncast, openEuler, Kitsu. Если вам тоже чего-то не хватает в endoflife.date, присоединяйтесь: от этого выиграют все, а не только пользователи enodia.
Для софта, которого в endoflife.date нет, источником служат GitHub Releases и теги. Последнюю версию оттуда узнать можно, но с календарём поддержки сложнее. И здесь тоже есть грабли. Например, SonarQube разделился на две линейки: SonarQube Server с версиями вида 2025.x и Community Build с версиями вида 25.x. Это два разных календаря. enodia понимает, какой из них брать, по году в номере версии.
Следующий вопрос — уязвимости. Первым делом вспоминается NVD, американская национальная база уязвимостей. С неё я и начал.
А что, если вы работаете в России? Тогда есть БДУ ФСТЭК, Банк данных угроз безопасности информации. Для одних это обязательный источник по требованиям регулятора, для других просто ещё одна база. Я не стал выбирать и подключил обе. В отчёте у каждой находки видно, откуда она пришла. Для дистрибутивов, которые активно ставят в РФ (Astra Linux, РЕД ОС, ALT), есть своя поддержка: enodia определяет их по SSH, а для Astra и РЕД ОС ещё и сверяет установленные пакеты с OVAL-данными вендора.
На уровне пакетов ОС уязвимости ищутся по данным самих дистрибутивов. Всего поддерживается 10 дистрибутивов:
Debian — по Security Tracker;
Ubuntu, Linux Mint, RHEL, Rocky, Alma, Oracle Linux, Astra Linux, РЕД ОС — по OVAL;
Alpine — по secdb.
Базы скачивает команда enodia cve update, и это единственная команда, которая ходит в сеть за CVE. check, collect и serve работают с локальными файлами, поэтому базы можно занести в закрытый контур на флешке. Все базы на демо-стенде, NVD целиком с 2002 года, БДУ и данные вендоров, заняли около 350 МБ.
Первый же cve update споткнулся о TLS: bdu.fstec.ru не отдаёт промежуточный сертификат. Сервер подписан цепочкой «Russian Trusted», корня которой нет в системных хранилищах. Но даже с корнем проверка не проходит без промежуточного Sub CA. Решение: скачать корень с Госуслуг, промежуточный сертификат — по ссылке AIA из самого сертификата сервера, сложить их в один PEM и указать в конфиге:
cve: bdu: {path: ./cve/bdu.zip} update: ca_file: ./cve/russian-trusted.pem
Отключить проверку сертификата, конечно, было бы проще, но для инструмента, который занимается безопасностью, это был бы плохой пример.
Интереснее оказалось с самими данными. Диапазоны уязвимых версий в БДУ часто записаны как «до 18.5» без нижней границы и без учёта веток. Для продукта с параллельными ветками, например PostgreSQL, это значит, что последний патч каждой старой ветки формально «уязвим», даже если бага там никогда не было или его исправили в той же ветке.
Как это выглядит на практике. PostgreSQL 17.11 — последний патч своей ветки на момент проверки. Вот что получается по одним только БДУ и NVD:
ID PRODUCT PATCH LIFECYCLE BRANCH SEVERITY REASON CVES analytics-db postgresql current active newer info - 46
46 CVE на полностью обновлённом сервере. Выглядит страшно и совершенно неверно.
Решение — данные самого вендора. У PostgreSQL, MariaDB, nginx и Atlassian есть собственные страницы безопасности, где по каждой уязвимости перечислены затронутые и исправленные версии по веткам. Если вендор перечисляет ветку, его вердикт перебивает вердикт БДУ и NVD. С данными PostgreSQL у того же сервера 0 CVE, а у PostgreSQL 13.16 вместо 61 остаётся 48 настоящих.
Мораль: больше CVE не значит точнее. Если сканер показывает сотни уязвимостей на свежем сервере, на него перестают смотреть. Ложные срабатывания убивают доверие к инструменту быстрее, чем пропуски.
Проверим всё на живом стенде. Я поднял в Docker восемь сервисов разной степени свежести: два PostgreSQL, MySQL, две MariaDB, nginx, Redis и Debian с SSH. Сбор:
$ time enodia collect -o inventory.jsonl real 0m0.149s
Проверка:
$ enodia check --from inventory.jsonl ID PRODUCT PATCH LIFECYCLE BRANCH SEVERITY REASON CVES billing-db postgresql behind eol newer fail - 48 analytics-db postgresql current active newer info - - crm-mysql mysql behind eol newer_lts fail - 176 wiki-mariadb mariadb behind eol newer_lts fail - 37 shop-mariadb mariadb current active newer_lts warn - - web-front nginx current eol newer fail - 24 sessions-cache redis behind security newer warn - 20 app01 debian behind security newer warn - 76
Код выхода 4: есть провалы. Вместе с поиском по CVE проверка заняла 2,2 секунды. Обратите внимание на web-front: nginx 1.24.0 — последний патч своей ветки (current), но сама ветка уже EOL. Именно такие случаи теряются, если смотреть только на «обновлено ли до последнего».
Есть и другие виды. Например, --view lifecycle показывает, сколько дней осталось до EOL, или сколько дней назад он наступил:
ID PRODUCT LIFECYCLE EOL SUPPORT-ENDS DAYS-TO-EOL billing-db postgresql eol 2025-11-13 - -350 analytics-db postgresql active 2029-11-08 - 1105 crm-mysql mysql eol 2026-04-30 2025-04-30 -182 web-front nginx eol 2024-04-23 - -919 sessions-cache redis security 2027-04-01 2022-04-27 153 app01 debian security 2028-06-30 2026-07-11 609
А --view drift — насколько мы отстали внутри своей ветки:
ID PRODUCT CURRENT LATEST CYCLE PATCH CVES billing-db postgresql 13.16 13.23 13 behind 48 crm-mysql mysql 8.0.36 8.0.46 8.0 behind 176 wiki-mariadb mariadb 10.6.18 10.6.28 10.6 behind 37 app01 debian 12.5 12.15 12 behind 76
Для app01 CVE считаются по пакетам: enodia забрала по SSH список установленных пакетов и сверила его с Debian Security Tracker.
Для людей, которые не любят терминал, есть HTML-отчёт: enodia export --format html. Это один самодостаточный файл со всеми видами отчёта. JavaScript в нём нет: даже окно со списком CVE открывается средствами CSS. Такой файл не страшно отправить безопаснику.

По клику на число CVE открывается список с CVSS, ссылками на CVE.org и БДУ и источником каждой находки. Для записей из БДУ показывается описание на русском:

Для мониторинга есть экспорт в Prometheus (--format prometheus): по каждой оси отдаётся отдельная метрика, и алерты в Grafana на них пишутся тривиально.
enodia_lifecycle_status{id="billing-db",status="eol"} 1 enodia_lifecycle_status{id="analytics-db",status="active"} 1
Есть и режим enodia serve --interval: он собирает данные по таймеру и отдаёт HTML, JSON и /metrics. Важная деталь: запрос к странице не запускает новый сбор. Кнопка «обновить», которая при каждом клике опрашивает весь парк, — это DoS, который вы устраиваете себе сами. Встроенной авторизации нет, сервис нужно ставить за reverse proxy.
enodia — статический бинарник без зависимостей, так что платформ много:
ОС | Архитектуры | Минимальная версия |
|---|---|---|
Linux | amd64, arm64 | ядро 3.2+ (Debian 8+, Ubuntu 14.04+, RHEL/CentOS 7+) |
Windows | amd64, arm64, 386 | Windows 10 / Server 2016 |
macOS | amd64 (Intel), arm64 (Apple Silicon) | macOS 12 Monterey |
Android (Termux) | arm64 | Android 7 |
Проще всего поставить скриптом:
# Linux, macOS, Termux curl -fsSL https://get.enodia.sh/unix | sh
# Windows (PowerShell) irm https://get.enodia.sh/windows | iex
На Windows можно и через пакетные менеджеры, тогда обновления будет отслеживать он:
winget install EpicMorg.Enodia choco install enodia
Ещё есть пакеты deb, rpm, apk и pacman на странице релизов, архивы под все платформы из таблицы и образ epicmorg/enodia на Docker Hub, quay.io и ghcr.io. Про Termux отдельно: для Android пришлось собирать отдельный бинарник, потому что статический linux/arm64 Android запускать отказывается.
Документация на восьми языках, включая русский, лежит на docs.enodia.sh. Там же описан конфиг для каждой пробы.
Зоопарк версий никуда не денется. Его можно только видеть. Если у вас есть ответ на вопрос «что у нас устарело и где дыры», полученный одной командой и одинаковый сегодня и через месяц, дальше остаётся планировать обновления, а не искать, что обновлять.
Чему меня научила эта история:
Спросить версию — уже нетривиально. Протоколы, маскировки, point-релизы в разных местах, вендорские «Update 3k».
Мониторинг может навредить. Безобидное «подключился и отключился» в MySQL оборачивается блокировкой хоста.
Данные важнее кода. Половина работы — не пробы, а источники: календари, базы CVE, их несовершенство и способы его компенсировать.
Open source работает в обе стороны. Я беру календари из endoflife.date и возвращаю туда то, чего в нём не хватало.
Исходники лежат на GitHub. Буду рад комментариям, отзывам, звёздам, issue и PR с новыми пробами. А если вашего софта нет ни в enodia, ни в endoflife.date, напишите в комментариях, какого именно.