133 lines
8.9 KiB
Markdown
133 lines
8.9 KiB
Markdown
|
|
# Инцидент 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 — **в плане роадмапа.**
|