From 72367682b56b27b2f272c0200c0d76dfa4760384 Mon Sep 17 00:00:00 2001 From: ayurishchev Date: Fri, 11 Sep 2026 13:09:17 +0300 Subject: [PATCH] Harden frr_anycast against unattended-upgrade-triggered route flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prod-инцидент: apt-daily-upgrade обновил libc6 -> postinst вызвал рестарт зависимых юнитов -> перезапустился systemd-networkd -> он вычистил из ядра BGP-маршруты zebra (KeepConfiguration не был задан, networkd по умолчанию удаляет "чужие" маршруты при старте). Zebra не знала о вычистке, трафик встал до ручного restart frr. Повторилось 10 сентября с обновлением systemd, но было замаскировано плейбуком. Роль frr_anycast теперь защищает от этого класса сбоя безусловно: - отключены apt-daily/apt-daily-upgrade (20auto-upgrades + маскирование таймеров и сервисов) - обновления пакетов только вручную; - systemd-networkd получает drop-in KeepConfiguration=yes - устраняет сам механизм вычистки маршрутов при любом будущем рестарте networkd, не только от конкретного пакета-триггера; - новый handler Restart systemd-networkd между Apply netplan и Restart frr (networkd должен переустановить владение интерфейсами до того, как zebra переустановит маршруты). Шаблоны FRR/nftables не затронуты: render-check.sh 6/6, render-check-cloud.sh 84/84, без изменений. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Bc3HkMiDwooSyLBLoMKNyF --- README.md | 9 +++++ ansible/roles/frr_anycast/handlers/main.yml | 10 ++++- ansible/roles/frr_anycast/tasks/main.yml | 43 +++++++++++++++++++++ docs/2026-09-11-system-hardening-plan.md | 25 ++++++++++++ docs/2026-09-11-system-hardening-summary.md | 36 +++++++++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 docs/2026-09-11-system-hardening-plan.md create mode 100644 docs/2026-09-11-system-hardening-summary.md diff --git a/README.md b/README.md index ca47a22..7bb02d6 100644 --- a/README.md +++ b/README.md @@ -165,3 +165,12 @@ ansible/tests/render-check-cloud.sh # 4 соседа, prepend только н `render-check-cloud.sh` проверяет: у каждого из 4 узлов ровно 4 `neighbor ... remote-as`, outbound `route-map` навешан на всех — `RM-OUT-PREPEND` (фильтр + `set as-path prepend 65200 65200 65200`) на 2×65103, `RM-OUT` (только фильтр) на 2×65104, `nftables.conf` содержит DNAT (`dnat to 95.163.53.117`) и `masquerade` на заданном внешнем интерфейсе, а также (если `nft` доступен на control node) валидирует синтаксис каждого `nftables.conf` через `nft -c -f`. Детали и разбор диаграммы: [docs/2026-09-09-ansible-site-cloud-plan.md](docs/2026-09-09-ansible-site-cloud-plan.md) / [docs/2026-09-09-ansible-site-cloud-summary.md](docs/2026-09-09-ansible-site-cloud-summary.md). + +## Защита от auto-update-триггеров и networkd route-flush + +Прод-инцидент: `apt-daily-upgrade` обновил `libc6` → postinst вызвал рестарт зависимых юнитов → перезапустился `systemd-networkd` → он вычистил из ядра BGP-маршруты zebra (не «свои» для него) → трафик встал до ручного `systemctl restart frr`. Роль `frr_anycast` теперь защищает от этого класса сбоя безусловно, на всех узлах обеих сред: + +- **Обновления только по требованию администратора**: `/etc/apt/apt.conf.d/20auto-upgrades` отключает периодические `apt update`/unattended-upgrade, дополнительно замаскированы `apt-daily.timer`/`apt-daily-upgrade.timer` и их `.service`. `apt upgrade` по-прежнему доступен вручную. +- **`systemd-networkd` не вычищает чужие маршруты**: drop-in `/etc/systemd/networkd.conf.d/99-keep-configuration.conf` (`KeepConfiguration=yes`) — защита не от конкретного пакета-триггера, а от самого механизма, на случай **любого** будущего рестарта networkd. + +Не влияет на рендер `frr.conf`/`daemons`/`nftables.conf` — `render-check.sh`/`render-check-cloud.sh` проходят без изменений. Детали: [docs/2026-09-11-system-hardening-plan.md](docs/2026-09-11-system-hardening-plan.md) / [docs/2026-09-11-system-hardening-summary.md](docs/2026-09-11-system-hardening-summary.md). diff --git a/ansible/roles/frr_anycast/handlers/main.yml b/ansible/roles/frr_anycast/handlers/main.yml index 6e802d5..3642f68 100644 --- a/ansible/roles/frr_anycast/handlers/main.yml +++ b/ansible/roles/frr_anycast/handlers/main.yml @@ -2,11 +2,19 @@ # Порядок важен: Ansible исполняет handlers в порядке ИХ ОБЪЯВЛЕНИЯ здесь, # а не в порядке notify. "Apply netplan" должен отработать раньше "Restart # frr", иначе dummy0 (и anycast-адрес на нём) ещё не будет существовать к -# моменту старта zebra/bgpd. +# моменту старта zebra/bgpd. "Restart systemd-networkd" — аналогично раньше +# "Restart frr": networkd должен перечитать KeepConfiguration=yes и (пере)стать +# владельцем интерфейсов ДО того, как zebra переустановит BGP-маршруты в ядро +# — иначе есть краткое окно, где свежий networkd ещё может вычистить их. - name: Apply netplan ansible.builtin.command: netplan apply changed_when: true +- name: Restart systemd-networkd + ansible.builtin.systemd: + name: systemd-networkd + state: restarted + - name: Restart frr ansible.builtin.systemd: name: frr diff --git a/ansible/roles/frr_anycast/tasks/main.yml b/ansible/roles/frr_anycast/tasks/main.yml index 9df8f65..a1881c2 100644 --- a/ansible/roles/frr_anycast/tasks/main.yml +++ b/ansible/roles/frr_anycast/tasks/main.yml @@ -34,6 +34,49 @@ success_msg: "DNAT на {{ dnat_target_ip }} будет применён (enable_snat: true)." when: dnat_target_ip | length > 0 +- name: Auto-update защита | отключить периодический apt (apt-daily/unattended-upgrades) + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + content: | + APT::Periodic::Update-Package-Lists "0"; + APT::Periodic::Unattended-Upgrade "0"; + owner: root + group: root + mode: "0644" + +- name: Auto-update защита | замаскировать apt-daily таймеры/сервисы + ansible.builtin.systemd: + name: "{{ item }}" + masked: true + state: stopped + enabled: false + loop: + - apt-daily.timer + - apt-daily-upgrade.timer + - apt-daily.service + - apt-daily-upgrade.service + +- name: Сеть | каталог для networkd drop-in существует + ansible.builtin.file: + path: /etc/systemd/networkd.conf.d + state: directory + owner: root + group: root + mode: "0755" + +- name: Сеть | systemd-networkd не должен вычищать маршруты, поставленные не им (zebra) + ansible.builtin.copy: + dest: /etc/systemd/networkd.conf.d/99-keep-configuration.conf + content: | + [Network] + KeepConfiguration=yes + owner: root + group: root + mode: "0644" + notify: + - Restart systemd-networkd + - Restart frr + - name: FRR APT-репозиторий | добавить GPG-ключ ansible.builtin.get_url: url: https://deb.frrouting.org/frr/keys.gpg diff --git a/docs/2026-09-11-system-hardening-plan.md b/docs/2026-09-11-system-hardening-plan.md new file mode 100644 index 0000000..dcde850 --- /dev/null +++ b/docs/2026-09-11-system-hardening-plan.md @@ -0,0 +1,25 @@ +# План внедрения: защита от auto-update-триггеров и networkd route-flush + +**Дата:** 2026-09-11 + +## Инцидент + +`apt-daily-upgrade.service` обновил `libc6` → postinst вызвал `daemon-reexec`/рестарт зависимых юнитов → перезапустился `systemd-networkd`. Networkd по умолчанию (`KeepConfiguration` не задан) при старте вычищает на управляемых интерфейсах маршруты, которые не совпадают с его конфигом — «чужие» для него BGP-маршруты zebra. Zebra не знала о вычистке (её RIB не менялся), кернел-FIB был пуст до ручного `systemctl restart frr`. Аналогичный триггер сработал повторно 10 сентября (обновление `systemd`), но был замаскирован плейбуком, перезапускавшим FRR следом. + +Вывод пользователя: триггером может стать обновление **любого** пакета, тянущего рестарт networkd — защита должна быть не от конкретного пакета, а структурная. + +## Решение (роль `frr_anycast`, применяется безусловно на всех узлах) + +1. **Отключить автоматические/unattended обновления**: + - `/etc/apt/apt.conf.d/20auto-upgrades` → `Update-Package-Lists "0"`, `Unattended-Upgrade "0"`. + - Замаскировать `apt-daily.timer`, `apt-daily-upgrade.timer` и их `.service` — двойная защита, даже если конфиг-файл будет вручную изменён. + - Обновления пакетов остаются возможны, но только явным `apt update && apt upgrade`, инициированным администратором. +2. **`systemd-networkd` не должен вычищать маршруты, поставленные не им** — drop-in `/etc/systemd/networkd.conf.d/99-keep-configuration.conf` с `KeepConfiguration=yes`. Защищает от той же категории сбоя при **любом** будущем рестарте networkd, независимо от причины. +3. Handler-цепочка при изменении networkd-конфига: `Restart systemd-networkd` → `Restart frr` (та же логика, что и ручной remediation 08:57 — зебра переустанавливает маршруты после того, как networkd перечитал конфиг). + +## Definition of done + +- Задачи добавлены в `roles/frr_anycast/tasks/main.yml`, handler — в `handlers/main.yml` (порядок: после `Apply netplan`, перед `Restart frr`). +- `frr.conf.j2`/`daemons.j2`/`nftables.conf.j2` не затронуты — `render-check.sh`/`render-check-cloud.sh` проходят без изменений (регресс-проверка). +- `--syntax-check` чист для обоих playbook'ов. +- README и summary обновлены. diff --git a/docs/2026-09-11-system-hardening-summary.md b/docs/2026-09-11-system-hardening-summary.md new file mode 100644 index 0000000..ddfcb6f --- /dev/null +++ b/docs/2026-09-11-system-hardening-summary.md @@ -0,0 +1,36 @@ +# Summary: защита от auto-update-триггеров и networkd route-flush + +**Дата:** 2026-09-11 +**План:** [2026-09-11-system-hardening-plan.md](2026-09-11-system-hardening-plan.md) + +## Разбор инцидента + +`apt-daily-upgrade.service` обновил `libc6` → postinst-скрипт вызвал `daemon-reexec`/рестарт зависимых юнитов → перезапустился `systemd-networkd`. Networkd по умолчанию (`KeepConfiguration` не задан) при старте вычищает на управляемых интерфейсах маршруты, которые не совпадают с его собственным конфигом — «чужие», включая BGP-маршруты, установленные zebra через netlink. Zebra о вычистке не узнавала (её внутренняя RIB не менялась) — маршруты в кернел-FIB отсутствовали до ручного `systemctl restart frr` в 08:57. Повторный триггер 10 сентября (обновление `systemd`) был замаскирован тем, что следом отработал плейбук и перезапустил FRR сам. Ключевой вывод (подтверждён): триггером может стать обновление любого пакета, тянущего рестарт networkd — защита нужна структурная, не «дождаться конкретного пакета». + +## Что сделано + +В `roles/frr_anycast/tasks/main.yml` добавлены безусловные задачи (применяются на всех узлах обеих сред): + +1. Отключение периодического apt (`/etc/apt/apt.conf.d/20auto-upgrades` → `Update-Package-Lists "0"`, `Unattended-Upgrade "0"`) + маскирование `apt-daily.timer`/`apt-daily-upgrade.timer`/их `.service` — двойная защита от повторного включения. +2. `systemd-networkd` drop-in `/etc/systemd/networkd.conf.d/99-keep-configuration.conf` с `KeepConfiguration=yes` — устраняет сам механизм вычистки маршрутов при рестарте networkd, независимо от причины рестарта. +3. Новый handler `Restart systemd-networkd` в `handlers/main.yml`, размещён между `Apply netplan` и `Restart frr` (порядок важен — networkd должен перечитать `KeepConfiguration` и переустановить владение интерфейсами до того, как zebra переустанавливает маршруты). + +Обновления пакетов остаются возможны, но только явным ручным `apt update && apt upgrade` — автоматический/unattended путь полностью исключён. + +## Результаты проверки + +- `--syntax-check` — OK для `site-a.yml` и `site-cloud.yml`. +- `render-check.sh` (площадка A) — 6/6, без изменений. +- `render-check-cloud.sh` (site-cloud) — 84/84, без изменений. + +Новые задачи не трогают шаблоны FRR/nftables, поэтому регресс-тесты рендера не затронуты — это дополнение к системной защите ОС, а не к BGP-конфигурации. + +## Известное ограничение + +Реальный apply на живых ВМ не выполнялся (ВМ не подняты, как и в предыдущих изменениях этой сессии) — эффект `KeepConfiguration=yes` и маскирования таймеров проверен только синтаксически/по документации модулей, не на реальном перезапуске networkd. + +## Изменённые файлы + +- `ansible/roles/frr_anycast/tasks/main.yml`, `ansible/roles/frr_anycast/handlers/main.yml`. +- `README.md` — новый раздел «Защита от auto-update-триггеров и networkd route-flush». +- `docs/2026-09-11-system-hardening-plan.md`, `docs/2026-09-11-system-hardening-summary.md`.