montana/Node/External-Audit/INCIDENT-HELSINKI-2026-05-19.md
2026-05-21 03:44:38 +03:00

8.9 KiB
Raw Blame History

Инцидент Helsinki — 2026-05-19. Пост-мортем + план предотвращения

Краткая хронология

Время (UTC) Событие
~19:00 xray на FI/FRA/US получил единый Reality-ключ через cargo deploy
~19:09 DNS-upstream в xray переключён на AdGuard DoH (https+local://94.140.14.14/dns-query) на всех 3 узлах
~19:50 xray под нагрузкой делает HTTPS-handshake к AdGuard на каждый DNS-запрос пользователя
~20:00 Автор сообщил «ВПН не грузит» на Helsinki
~20:00+ SSH к 91.132.142.42:22 принимает TCP, но banner exchange таймит (sshd завис)
~20:01 Reality :443 на FI ещё формально отвечает на TLS handshake (kernel-level), но xray внутри тоже залип
~20:01 DNS на FRA и US откачен на чистый 1.1.1.1 удалённо — оба работают
~20:02 FI остался недоступен, требуется reboot через хостинг-панель

Корневая причина

AdGuard DoH как DNS-upstream xray под нагрузкой. Каждый DNS-запрос пользователя превращался в полноценный HTTPS-handshake к 94.140.14.14:443. При активном использовании (десятки запросов в секунду на узел) ресурсы упёрлись:

  • file descriptors xray.service (~50 одновременных HTTPS-connections к AdGuard)
  • conntrack table (каждое TCP-соединение туда — отдельная запись)
  • CPU на ECDHE-handshake'ах

Когда xray упёрся в лимит, kernel начал отвергать новые TCP-fork'и. sshd не смог fork'ать child-процесс на новый коннект → banner timeout. Reality :443 продолжал «жить» на kernel-уровне для уже установленных коннектов.

Усугубляющие факторы

Фактор Цена
Раскатил AdGuard DoH сразу на все 3 узла без A/B Если бы только на FRA — заметил бы за 1 час, не повлияло бы на FI
Нет хостинг-API в Keychain для emergency reboot Не могу удалённо перезагрузить — нужен ручной шаг автора
Нет resource limits в xray.service (FD, memory) xray смог сожрать столько FDs что system отказал sshd
Нет sshd-watchdog (cron-based local restart) Узел остался в зомби-состоянии до ручного reboot
Нет мониторинга узла (load/FD/conntrack) Не было раннего alert «load > 5» или «FDs > 80%»
Нет per-node canary тестов после изменения Изменение применено вслепую, без baseline сравнения

План предотвращения — приоритизированный

P0 (сразу после возвращения FI)

P0-1. Resource limits в xray.service

В drop-in /etc/systemd/system/xray.service.d/40-resources.conf:

[Service]
LimitNOFILE=1048576
TasksMax=10000
MemoryMax=2G
MemoryHigh=1.5G
OOMScoreAdjust=500

Эффект: xray умрёт раньше чем заберёт ресурсы у sshd. systemd Restart=always поднимет.

P0-2. sshd защита от OOM + локальный watchdog

  • OOMScoreAdjust=-900 на ssh.service — kernel OOM-killer убивает xray, не sshd.
  • Cron */1 * * * * timeout 5 bash -c '</dev/tcp/127.0.0.1/22' || systemctl restart ssh — если sshd завис, локальный root-cron его перезапустит через минуту.

P0-3. DNS-upstream без DoH

  • Откатить на UDP DNS 1.1.1.1 + 8.8.8.8 (как FRA/US сейчас).
  • Adblock — позже через отдельный путь (см. P2-2).

P1 (в ближайшую неделю)

P1-1. Out-of-band доступ

  • Получить API tokens хостингов (THE.Hosting для FI/US, Timeweb для FRA/MSK), сохранить в Keychain:
    • the-hosting-api / token
    • timeweb-api / token
  • CLI montana-reboot <node> — делает reboot через API. Лежит в Montana/Node/External-Audit/scripts/.
  • Документация в OPERATIONS.md.

P1-2. Hosting-side watchdog

  • Если узел не отвечает на TCP :22 с двух разных монитор-узлов в течение 5 минут → orchestrator делает auto-reboot через хостинг API.
  • Реализация: расширение watchdog-thread в montana-orchestrator.

P1-3. Canary rollout

Скрипт Montana/Node/External-Audit/scripts/vpn-rollout.sh <change-script>:

  1. Применяет изменение только на FRA (canary).
  2. Ждёт 30 минут.
  3. Health-check FRA через /uptime + сравнение samples_24h с baseline.
  4. При зелёном — раскатывает на FI и US с интервалом 5 мин.
  5. При красном — rollback FRA и stop.

P2 (в течение месяца)

P2-1. Метрики node exporter + alerts

  • Node Exporter на каждом узле, scrape с Moscow.
  • Метрики: load1, node_filefd_allocated, node_nf_conntrack_entries, xray_open_connections.
  • Alert порог load1 > 4 ИЛИ FDs > 60% от LimitNOFILE → Telegram уведомление.

P2-2. AdBlock без overhead

Если в перспективе нужен adblock — не через DoH в xray, а через:

  • AdGuard Home (DNS-only daemon) локально на каждом узле на 127.0.0.1:53.
  • xray смотрит на 127.0.0.1:53 (UDP — fast).
  • AdGuard Home сам берёт upstream и применяет фильтры.
  • Никаких HTTPS-handshake'ов на DNS-resolve.

P2-3. State snapshot перед изменением

  • montana-config-snapshot — копирует /usr/local/etc/xray/config.json + /etc/systemd/system/xray.service.d/* в /var/lib/montana/snapshots/<timestamp>/.
  • Auto-snapshot перед каждым изменением через vpn-rollout.sh.
  • montana-config-restore <timestamp> — откат.

P3 (долгосрочно)

P3-1. Полная независимость management plane от Moscow

  • Перенос orchestrator + сайта montana.quest на отдельный мелкий VPS в EU (не VPN-узел).
  • Сейчас Moscow IP опубликован в публичных артефактах, что делает его уязвимым.

P3-2. Multi-region orchestrator

  • Активный orchestrator + standby на другом узле.
  • При падении основного — standby перехватывает через DNS-failover (cdn-orch.montana.quest multi-A с watchdog).

Что НЕ надо делать снова

  1. Не раскатывать изменения сразу на все узлы. Всегда canary FRA → 30мин → остальные.
  2. Не использовать DoH в xray внутри. Любые HTTPS-upstream создают O(n) connections на каждый запрос пользователя.
  3. Не оставлять xray без LimitNOFILE и MemoryMax. xray должен умирать первым, не утягивая sshd.
  4. Не делать массовых SSH-операций на узел из одного скрипта без backoff. Collector гнал ~60 SSH/мин — могло триггерить fail2ban либо просто load.
  5. Не считать что Reality :443 живой = узел рабочий. Reality может работать на kernel-уровне даже если xray внутри залип. Реальная проверка — end-to-end HTTP запрос через VPN.

Checklist при восстановлении FI

  1. Узел загрузился, sshd отвечает.
  2. systemctl is-active xray = active.
  3. ss -tlnp | grep :443 — слушает.
  4. reality probe от внешнего хоста → Verification: OK.
  5. Реальный end-to-end тест: curl --proxy socks5h://localhost:1090 https://google.com через локальный xray-клиент → 200 OK.
  6. Применить P0-1, P0-2, P0-3.
  7. Записать в OPEN-RISKS.md как закрытый риск R-old-N.

Тайминг

  • P0 — в течение 1 часа после возвращения FI.
  • P1 — до 2026-05-26.
  • P2 — до 2026-06-19.
  • P3 — в плане роадмапа.