Рецидив вчерашнего 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>
207 lines
16 KiB
Markdown
207 lines
16 KiB
Markdown
---
|
||
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: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, файлы уже на диске).
|
||
|
||
```bash
|
||
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 — 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`:
|
||
|
||
```yaml
|
||
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:
|
||
```json
|
||
{"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).
|