Files
admin/.wiki/concepts/es-destructive-delete-incident-2026-05-26.md
vitya 063910e265 incident(books-vds-es): true RCA — ransom-бот через открытый :9200, не оператор
Рецидив вчерашнего 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>
2026-05-29 09:31:25 +03:00

16 KiB
Raw Blame History

title: ES destructive delete incident — canonical endpoint vs internal-only bookva-es type: concept tags: [elasticsearch, incident, books-vds, ransom-bot, exposed-port, docker-firewalld-bypass] related: ../entities/books-vds, ../sources/books-vds-backup-daily-kreknin-2026-05-25 updated: 2026-05-29

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:4906: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:4344 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        ← повтор (автоматика)

Доказательства

  1. read_me = ransom note. Содержимое: «Your database has been deleted... send 0.0041 BTC to bc1q38rjul6gdamfflf6p4ukz0ymtvfgfv2j9saf6r... email wendy.etabw@gmx.com code 0SH7HH1Q72JL... 48 hours». Шаблон ES-ransom-бота.
  2. Порт 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 отвечал кому угодно.
  3. Мимо traefik/basicAuth. basicAuth — это traefik-мидлвара на elasticsearch.kzntsv.site:443, а НЕ в самом ES. Бот шёл прямо в :9200, минуя traefik → в accessLog (Control #2) DELETE'ов нет. Control #2 ретроспективно бесполезен против этого вектора.
  4. Снос by-name (каждый индекс отдельной командой) → Control #1 (destructive_requires_name=true) не ловит — он блокирует только _all/*.
  5. firewalld активен, но бесполезен — docker вставляет свои iptables-правила (chain DOCKER) в обход firewalld-зон. docker-proxy слушал *:9200. Классический docker-publish-bypasses-firewalld.
  6. 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 — 2629 содержат только 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. Логирование = ~3050 MB/day при типичной нагрузке (~1 req/sec).

Follow-ups

  • Logrotate на /usr/docker/traefik/letsencrypt/access.log — пока без него файл будет расти; не критично (disk free 63G, ressedimption ~12 года), но 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