golang

Зоопарк версий под контролем: как я создал enodia — инвентаризацию сервисов с EOL и CVE

  • суббота, 31 октября 2026 г. в 00:00:09
https://habr.com/ru/articles/1101784/

Сколько у вас в инфраструктуре версий PostgreSQL? А какие из них уже не поддерживаются? А сколько CVE висит на том nginx, который «работает — не трогай»?

Если вы ответили на все три вопроса без поиска по чатам и вики, дальше можно не читать. А я в какой‑то момент понял, что не могу, и написал для этого инструмент. Под катом история о том, как он появился, на какие грабли я наступил по дороге и почему MySQL однажды заблокировал собственный хост из‑за моего мониторинга.

Зоопарк

У меня есть домашняя лаба pet‑проекта. Есть и работа: я ведущий DevOps‑инженер, и масштаб там, думаю, понятен. И там, и там всегда был и будет зоопарк версий. Где‑то стоит PostgreSQL 13, потому что «на нём так и запускали». Где‑то Jira, которую страшно обновлять. Где‑то Debian, поставленный три point‑релиза назад. Отдельно живут NAS, гипервизоры, BMC серверов и роутеры.

Пока сервисов десяток, их версии можно держать в голове. Когда их сотни, начинаются вопросы. Что именно у нас развёрнуто? Что из этого уже EOL или станет EOL через полгода? Где мы отстали на патч, а где на целый мажор? Где висят известные уязвимости?

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

Первый заход: Ansible и гуглотаблица

Первое, что приходит в голову 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

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, который банил сам себя

Сервер 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 в костюме MySQL

Помните про проверку, что на том конце именно тот продукт? Вот зачем она нужна. 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: протокол есть, ответа нет

Коротко, потому что это тянет на отдельную статью. Документации на протокол 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 понимает, какой из них брать, по году в номере версии.

CVE: NVD, БДУ и данные вендоров

Следующий вопрос — уязвимости. Первым делом вспоминается 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. Такой файл не страшно отправить безопаснику.

HTML-отчёт enodia
HTML-отчёт enodia

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

Список CVE в HTML-отчёте
Список CVE в HTML-отчёте

Для мониторинга есть экспорт в 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, напишите в комментариях, какого именно.