Рецидив вчерашнего ES-инцидента вскрыл истинную причину. Вчерашняя гипотеза «оператор в cutover попал на canonical» опровергнута. Root cause: ES (stack 33) публиковал 0.0.0.0:9200 мимо traefik. Free-ES 7.10 без auth → порт открыт всему интернету. Ransom-бот сносил индексы by-name (мимо Control #1 destructive_requires_name), оставлял read_me с BTC-выкупом. accessLog (Control #2) пуст — бот шёл прямо в порт, не через traefik. firewalld бесполезен (docker-publish обходит INPUT-зоны). Fix (Control #3): убрана публикация host-порта из stack 33 (Portainer PUT), дыра закрыта; re-restore epz/products/artmone из daily-2026-05-25. Отдельный баг: epz-поиск падал у ОБОИХ тенантов — getTenantIdSeller( config.get("tenant")) через node-config, а tenant не задан ни в default.json, ни в env-маппинге (TENANT env = мёртвый груз). Добавлен tenant в overlay default.json (slovo/bookva). products работал — отдельный код-путь. accessLog откатан (сторожил не ту дверь). - wiki concept: correction-блок + секция «Рецидив 2026-05-29» + exposure-audit - tasks: restore-es reopened+reclosed; new ⚪ harden-books-vds-exposed-ports - NEXT_SESSION handoff Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
ES destructive delete incident — canonical endpoint vs internal-only bookva-es
CORRECTION 2026-05-29 — диагноз сменился. Первичная гипотеза (оператор в cutover-prep ошибся endpoint'ом) опровергнута. Реальная причина — ransom-бот через публично открытый порт
0.0.0.0:9200в обход traefik+basicAuth. ES 7.10 free вообще без auth → голый ES на host-порту отвечал кому угодно без кредов. Доказано: рецидив в ночь 28→29.05,read_me= записка о выкупе (BTC), снос by-name (мимо Control #1), в traefik accessLog DELETE'ов нет (бот шёл прямо в:9200). Полный разбор — секция «Рецидив 2026-05-29 — true root cause» ниже. Секция «Hypothesis» сохранена как опровергнутая.
Summary
В период bookva-tenant-cutover-prep (2026-05-26) дважды были удалены все user-индексы (epz, products, artmone, плюс system .tasks и placeholder read_me) на canonical ES elasticsearch.kzntsv.site (books VDS stack 33). Удаление выполнено через ES REST API одной командой типа DELETE _all / DELETE * (5 индексов за <1 секунды). Контейнер не рестартовал, bind-volume не задет — данные стёрты на уровне cluster state.
Caller identity не восстановим — ES audit log = X-Pack платная фича (отсутствует на free 7.10), traefik accessLog был выключен, Portainer audit log = enterprise.
Hypothesis (timeline-based, не доказан) — ⛔ ОПРОВЕРГНУТА 2026-05-29
Эта гипотеза неверна. Рецидив 29.05 (см. секцию ниже) доказал автоматизированную атаку, не человеческую ошибку. Оставлено для истории рассуждений.
Оператор в момент cutover-prep хотел очистить новый bookva-es перед заполнением, но команда ушла на canonical elasticsearch.kzntsv.site вместо bookva-es:9200.
2026-05-25 09:51 UTC daily snapshot SUCCESS [read_me, products, epz, artmone, .tasks] ← данные ЕСТЬ
...
2026-05-26 03:02 UTC daily snapshot success [read_me] only ← Wipe #1 случился в окне
2026-05-26 05:49–06:44 UTC re-reindex artmone+epz+products восстанавливают данные
2026-05-26 09:10:18 UTC bookva-es container created (cutover-prep Step 2)
2026-05-26 10:21:43–44 UTC Wipe #2: 5 индексов удалены за 1 сек ← 1ч 11мин после bookva-es
bookva-es использует named volume bookva-es-data, БЕЗ traefik labels (internal-only через docker network proxy). Не задел canonical через volume mount или routing. DELETE с воркстейшна на bookva-es:9200 физически невозможен (порт не expose'нут наружу); на elasticsearch.kzntsv.site — возможен через traefik basicAuth.
Forensics — что есть и чего нет
| Источник | Доступность | Содержание |
|---|---|---|
ES container log docker logs elasticsearch |
✅ есть | o.e.c.m.MetadataDeleteIndexService пишет факт + index UUID + timestamp. Caller отсутствует |
| ES audit log | ❌ нет | X-Pack Security платная фича. Free 7.10 audit отключён |
| Traefik accessLog | ❌ был выключен | Файл traefik.yml не содержал секции accessLog:. Запросы (включая DELETE) не пишутся |
| Portainer audit | ❌ нет | Portainer CE без enterprise = audit log не включён |
Snapshot diff _snapshot/kreknin/_all |
✅ есть | Daily snapshots показывают какой день имеет какие индексы → окно wipe'а |
Shell history ~/.bash_history root |
⚠️ ограниченно | Только если delete был сделан с самого VDS. Если с воркстейшна / Kibana DevTools — пусто |
Recovery procedure
Если индексы исчезли — snapshot restore из kreknin repo. Daily snapshot pipeline (books-vds-backup-daily-kreknin, cron 06:00 MSK) автоматически снапшотит непустые индексы. До wipe'а snapshot контейнерит full data → restore = ~46 сек для ~800MB (I/O bound, файлы уже на диске).
ssh -i ~/.ssh/id_ed25519_books_ops root@89.253.255.133 'bash -s' <<'EOF'
PW=$(python -c "import json; print(json.load(open('/opt/books/api/config/default.json'))['elasticsearch']['auth']['password'])")
# 1. Найти последний snapshot С полными данными
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_snapshot/kreknin/_all' \
| python -c "import json,sys; [print(s['snapshot'], s['indices']) for s in json.load(sys.stdin)['snapshots']]"
# 2. Restore (synchronous, не overwrites open indices — current state должен быть пустой)
curl -sk -u "books:$PW" -H 'Content-Type: application/json' -X POST \
'https://elasticsearch.kzntsv.site/_snapshot/kreknin/daily-<YYYY-MM-DD>/_restore?wait_for_completion=true' \
-d '{"indices":"epz,products,artmone","include_global_state":false,"include_aliases":true,"index_settings":{"number_of_replicas":0}}'
# 3. Verify counts == source
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_cat/indices?v'
# 4. Restart consumers (reset connection pool)
docker restart books-api books-task-runner books-job-scheduler
EOF
Если все snapshot'ы посткоммитные (содержат только placeholder), fallback на reindex-from-remote из elasticold.kzntsv.site (см. ../../tasks/migrate-elasticsearch-to-books-vds § Playbook). Source ES не выключен per migration policy.
Рецидив 2026-05-29 — true root cause (ransom-бот через открытый :9200)
Через ~3 часа после вчерашнего restore индексы снова исчезли. Разбор дал настоящую причину, опровергнув гипотезу оператора.
Таймлайн (canonical ES container log)
2026-05-28T17:06:25Z epz shard started → GREEN ← restore прошлой сессии
2026-05-28T20:13:01Z [artmone] deleting index ← снос by-name
2026-05-28T20:13:02Z [epz] deleting index
2026-05-28T20:13:09Z [products] deleting index
2026-05-28T20:13:13Z [read_me] creating index (bulk api) ← записка о выкупе
2026-05-29T04:30:19Z [read_me] deleted + recreated ← повтор (автоматика)
Доказательства
read_me= ransom note. Содержимое: «Your database has been deleted... send 0.0041 BTC tobc1q38rjul6gdamfflf6p4ukz0ymtvfgfv2j9saf6r... emailwendy.etabw@gmx.comcode0SH7HH1Q72JL... 48 hours». Шаблон ES-ransom-бота.- Порт 9200 был открыт в интернет. Stack 33 compose содержал
ports: - 9200:9200→ docker публиковал0.0.0.0:9200на публичный IP.curl http://89.253.255.133:9200/без-u/кредов вернул cluster info. ES 7.10 free не имеет встроенной auth (X-Pack Security платный, выключен) → голый ES отвечал кому угодно. - Мимо traefik/basicAuth. basicAuth — это traefik-мидлвара на
elasticsearch.kzntsv.site:443, а НЕ в самом ES. Бот шёл прямо в:9200, минуя traefik → в accessLog (Control #2) DELETE'ов нет. Control #2 ретроспективно бесполезен против этого вектора. - Снос by-name (каждый индекс отдельной командой) → Control #1
(
destructive_requires_name=true) не ловит — он блокирует только_all/*. - firewalld активен, но бесполезен — docker вставляет свои iptables-правила (chain
DOCKER) в обход firewalld-зон.
docker-proxyслушал*:9200. Классический docker-publish-bypasses-firewalld. - bookva-es НЕ пострадал — его compose порт не публикует (
PortBindings: {}), доступ только по внутренней docker-сетиproxy. Данные целы (epz 820604 + products 105922). Подтверждает: вектор — именно публикация порта, не сам ES.
Реальный fix (applied 2026-05-29) — Control #3
Убрать публикацию host-порта из stack 33 (Portainer PUT /api/stacks/33?endpointId=1,
убран блок ports: - 9200:9200). После recreate: docker ps показывает 9200/tcp без
0.0.0.0: префикса, host-listener на 9200 исчез, внешний curl :9200 → refused. Traefik
route (elasticsearch.kzntsv.site, под basicAuth) и внутренний docker-доступ работают —
приложение ходит в ES только через traefik, host:9200 ему не нужен. Это закрывает вектор
(Control #1/#2 лечили симптом/forensics, не дыру).
Затем: DELETE /read_me, restore epz,products,artmone из daily-2026-05-25 (single good
snapshot — 26–29 содержат только read_me, бот успевал снести до 03:00 UTC backup'а),
restart books-api books-task-runner books-job-scheduler. Counts восстановлены (green).
Exposure audit (2026-05-29) — та же дыра на других сервисах
docker ps + ss -tlnp на books VDS: публично опубликованы host-портами также:
| Сервис | Порт наружу | Auth | Статус |
|---|---|---|---|
mongo |
0.0.0.0:27017 |
✅ enabled (Unauthorized без кредов) |
exposed, credentialed |
books-db (mariadb) |
0.0.0.0:3306 |
✅ root требует пароль | exposed, credentialed |
bookva-db (mariadb) |
0.0.0.0:33306 |
✅ | exposed, credentialed |
minio |
0.0.0.0:9000 |
✅ AccessDenied анонимно |
exposed, credentialed |
bookva-minio |
0.0.0.0:9001 |
✅ | exposed, credentialed |
| rsync | :873 |
? | exposed |
Все БД имеют auth (в отличие от ES) → не дыра-нараспашку, но публикация портов БД наружу — плохая практика (brute-force / leaked-creds). Не emergency, рискованнее закрывать (возможен внешний admin-доступ). Follow-up hardening task, не часть инцидент-фикса.
Главный learning: любой stateful-сервис с публикуемым host-портом на VDS = публичный вектор в обход traefik+firewalld. Auth должна быть на самом сервисе, не только в traefik-мидлваре. ES free auth не имеет → его порт публиковать нельзя НИКОГДА.
Preventive controls (applied 2026-05-28)
Control #1 — ES action.destructive_requires_name=true
Env var на stack 33 (Portainer-managed). После применения wildcard/_all delete возвращают 400 Bad Request:
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/_all'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/ep*'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/epz'
{"acknowledged":true} # legit by-name delete still works
Класс ошибки «DELETE wildcard на неправильный endpoint» теперь блокируется ES до того, как достанется до данных. Reindex по одному индексу остаётся легитимным (нужно для миграции). Полностью физически невозможно удалить >1 индекса одной командой.
Apply: добавить action.destructive_requires_name: "true" в environment: блок stack 33 compose, PUT через Portainer API endpoint /api/stacks/33?endpointId=1. ES container recreate ~10 сек, consumers переподключатся через retry.
Control #2 — Traefik accessLog (JSON)
Без этого никакая ретроспективная диагностика невозможна. Static config в /usr/docker/traefik/data/traefik.yml:
accessLog:
filePath: /letsencrypt/access.log
format: json
bufferingSize: 100
/letsencrypt/ уже bind-mounted (./letsencrypt:/letsencrypt в /usr/docker/traefik/docker-compose.yml), файл persistent. Restart container — изменения подхватились (accessLog = static config, не dynamic).
Sample entry:
{"ClientHost":"...", "ClientUsername":"books", "RequestMethod":"DELETE", "RequestHost":"elasticsearch.kzntsv.site",
"RequestPath":"/_all", "RouterName":"elasticsearch@docker", "DownstreamStatus":400, ...}
Будущие подозрительные запросы видны с IP, username basicAuth, methodом, путём, response status. Логирование = ~30–50 MB/day при типичной нагрузке (~1 req/sec).
Follow-ups
- Logrotate на
/usr/docker/traefik/letsencrypt/access.log— пока без него файл будет расти; не критично (disk free 63G, ressedimption ~1–2 года), но micro-task на logrotate cron имеет смысл. - bookva-es traefik route — если когда-нибудь bookva-es потребует внешнего доступа, обязательно отдельный hostname (
bookva-es.kzntsv.site) с отдельной basicAuth. Никогда не делитьсяelasticsearch.kzntsv.siteмежду slovo/bookva. - Snapshot retention review — сейчас 7 daily snapshots. Если бы инцидент проявился через 8 дней, daily-25.05 был бы вытеснен. Для критичных корпусов имеет смысл добавить weekly/monthly tier. Отдельная мини-задача.
- Логирование callers без accessLog ретроспективно невозможно — это main learning. Любой новый production endpoint должен иметь access log с момента создания.
Cross-refs
- ../entities/books-vds § Стек, ES — stack 33 inventory.
- ../sources/books-vds-backup-daily-kreknin-2026-05-25 — backup pipeline который сделал spasibo daily-25.05 snapshot.
- ../../tasks/restore-elasticsearch-indices-books-vds — incident response trace (этой сессии).
- ../../tasks/migrate-elasticsearch-to-books-vds — original migration playbook (применим для reindex-from-remote fallback).