# Инцидент 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`: ```ini [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 '` — делает reboot через API. Лежит в `Montana/Node/External-Audit/scripts/`. - Документация в [OPERATIONS.md](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 `: 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//`. - Auto-snapshot перед каждым изменением через `vpn-rollout.sh`. - `montana-config-restore ` — откат. ### 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 — **в плане роадмапа.**