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

133 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Инцидент 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 '</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](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 — **в плане роадмапа.**