Files
cloud-ip-validator/docs/API.md
T

578 lines
32 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.
# API Control API
Control API — единственная точка входа в систему для `validator-agent`,
`prober` и оператора (администратора). Все данные передаются в формате
JSON, базовый префикс прикладных методов — `/api/v1`.
> **Важно.** На данный момент API не защищён аутентификацией/авторизацией
> — эндпоинты доступны любому, кто может достучаться до порта control-api
> по сети. Это касается и методов из раздела
> [«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией)
> ниже — они меняют, что и как проверяется, без подтверждения личности
> вызывающего. Для эксплуатации за пределами доверенного сегмента сети
> обязательно ограничьте доступ на уровне сети/файрвола (см.
> [SETUP.md](SETUP.md#сетевые-доступы)). Добавление bearer-токена — известное
> направление доработки, в текущей версии не реализовано.
Базовый URL в примерах — `http://control-api.internal:8080`, замените на
адрес вашего стенда (см. `server.listen_addr` в конфиге control-api).
> Для работы из браузера вместо `curl` есть `admin-dashboard` — веб-панель,
> дающая графический доступ ко всему административному API ниже, см.
> [DASHBOARD.md](DASHBOARD.md).
## Содержание
- [Общие соглашения](#общие-соглашения)
- [Методы для validator-agent](#методы-для-validator-agent)
- [Методы для prober](#методы-для-prober)
- [Служебные и административные методы](#служебные-и-административные-методы)
- [Управление очередью и конфигурацией](#управление-очередью-и-конфигурацией)
- [Модель состояний и связь методов с ней](#модель-состояний-и-связь-методов-с-ней)
- [Сквозной пример работы (curl)](#сквозной-пример-работы-curl)
## Общие соглашения
- Тело запроса и ответа — JSON (`Content-Type: application/json`).
- Успешные ответы возвращают `200 OK`, либо `204 No Content` (когда
данных нет — например, у валидатора сейчас нет назначения).
- Ошибки возвращают `4xx`/`5xx` и тело вида:
```json
{"error": "текст ошибки"}
```
- Временные метки (`checked_at` в запросах) передаются в формате
RFC3339/RFC3339Nano, например `2026-08-21T09:15:00.123456789Z`. Если поле
не удалось распарсить, сервер молча подставит текущее время сервера — не
полагайтесь на это в продакшене, всегда передавайте валидную метку.
- `validator_id` и `site_id` в пути запроса должны совпадать со
значениями, известными control-api — заданными в `control-api.yaml`
при первом запуске (пустая база) либо созданными позже через
`/api/v1/admin/config/*` (см.
[«Управление очередью и конфигурацией»](#управление-очередью-и-конфигурацией))
— иначе методы, требующие существующую сущность, вернут `404`.
## Методы для validator-agent
Эти методы вызывает бинарник `validator-agent`, работающий на ВМ-валидаторе.
Оператору вручную дёргать их обычно не требуется — они приведены для
понимания протокола и для отладки через curl.
### `POST /api/v1/agents/register`
Регистрация/переактивация валидатора. Вызывается один раз при старте
агента (и безопасно при каждом рестарте — идемпотентна).
Запрос:
```json
{
"validator_id": "validator_01",
"hostname": "vm-validator-01",
"agent_version": "1.0.0"
}
```
Ответ:
```json
{"ok": true, "poll_interval_seconds": 5}
```
`poll_interval_seconds` — рекомендованный интервал опроса, значение берётся
из `orchestrator.poll_interval_seconds` конфига control-api.
> `validator_id` должен быть заранее описан в конфиге control-api
> (`validators[].validator_id`) вместе с `os_port_id` — сам агент порт ID
> не передаёт и не может его сменить через API.
### `POST /api/v1/agents/{id}/heartbeat`
"Я жив". Обновляет `last_heartbeat_at` валидатора. Если валидатор не
присылает heartbeat дольше `orchestrator.heartbeat_timeout_seconds`, он
помечается `unreachable`.
Запрос (тело необязательно, поля информационные):
```json
{"local_state": "idle"}
```
Ответ: `{"ok": true}`. `404`, если `validator_id` не зарегистрирован.
### `GET /api/v1/agents/{id}/assignment`
Есть ли у валидатора сейчас работа. Опрашивается в каждом цикле.
- `204 No Content` — заданий нет.
- `200 OK` с телом:
```json
{
"ip_id": 42,
"ip_address": "203.0.113.10",
"phase": "awaiting_self_check",
"check_config": [
{"type": "https", "targets": ["https://hub.docker.com", "https://github.com", "https://packages.ubuntu.com"]},
{"type": "icmp", "targets": ["https://hub.docker.com", "https://github.com", "https://packages.ubuntu.com"]}
]
}
```
`phase` — `awaiting_self_check` (нужно выполнить self-check) либо
`checking` (self-check уже пройден, можно/нужно выполнять проверки).
`check_config` — уже развёрнутая конфигурация проверок (тип + список
целей), агенту не нужно самому сопоставлять группы целей.
### `POST /api/v1/agents/{id}/self-check`
Отчёт о результате self-check — подтверждение, что исходящий трафик
валидатора действительно идёт через только что назначенный FIP. Агент
определяет это **сам**, обращаясь к внешнему (снаружи облака) IP-echo
сервису (`self_check.ip_echo_urls` в `validator-agent.yaml`, например
`api.ipify.org`) и сравнивая ответ с `ip_address` из задания — control-api
в этом определении не участвует. Важно, что ресурс должен быть именно
внешним: OpenStack применяет SNAT через Floating IP только к трафику,
уходящему через внешнюю сеть, поэтому обращение к чему-либо внутри
проекта (в том числе к самому control-api, если он в той же внутренней
сети) покажет приватный адрес валидатора независимо от того, правильно
ли привязан FIP.
Запрос:
```json
{
"ip_id": 42,
"detected_egress_ip": "203.0.113.10",
"success": true,
"detail": "matched"
}
```
Ответ: `{"ok": true}`. При `success: false` control-api сам решает —
повторить попытку назначения FIP или пометить IP как `failed` (после
исчерпания `orchestrator.max_self_check_retries`).
### `POST /api/v1/agents/{id}/events`
Произвольная запись в журнал аудита, привязанная (опционально) к IP.
Используется агентом для событий `config_received`, `self_check_result`
и т.п.
Запрос:
```json
{
"event_type": "config_received",
"ip_id": 42,
"payload": "{\"checks\":2}"
}
```
Ответ: `{"ok": true}`.
### `POST /api/v1/agents/{id}/results`
Отчёт о результатах исходящих (egress) проверок. Можно отправлять по
одной проверке сразу после выполнения (рекомендуется — так прогресс не
теряется при падении агента) либо пачкой.
Запрос:
```json
{
"results": [
{
"ip_id": 42,
"check_type": "https",
"target": "https://github.com",
"success": true,
"latency_ms": 87,
"detail": "ok",
"checked_at": "2026-08-21T09:15:00.123Z"
}
]
}
```
Ответ: `{"ok": true}`. Повторная отправка того же `(ip_id, check_type,
target)` в рамках текущей попытки — безопасна и просто перезапишет
результат (upsert по уникальному ключу).
### `POST /api/v1/agents/{id}/complete`
Сигнал "все исходящие проверки для этого IP выполнены".
Запрос:
```json
{"ip_id": 42}
```
Ответ: `{"ok": true}`.
## Методы для prober
Эти методы вызывает бинарник `prober`, работающий на внешней площадке.
### `POST /api/v1/probers/register`
Регистрация пробера. `site_id` должен присутствовать в конфиге control-api
(`sites[].site_id`), иначе — `400`.
Запрос:
```json
{"site_id": "site-1", "hostname": "probe-host-1"}
```
Ответ: `{"ok": true, "poll_interval_seconds": 5}`.
### `GET /api/v1/probers/{site_id}/assignments`
Список всех IP, которые сейчас находятся в состоянии `checking` — то есть
всё, что нужно прозондировать на этом цикле опроса (валидаторов может
работать несколько параллельно, поэтому список, а не один IP).
Ответ:
```json
[
{"ip_id": 42, "ip_address": "203.0.113.10", "ports": [22, 80, 443, 8080], "icmp": true}
]
```
Пустой список `[]`, если сейчас нечего проверять.
### `POST /api/v1/probers/{site_id}/results`
Отчёт о результатах входящих (inbound) проверок с данной площадки.
Запрос:
```json
{
"results": [
{"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-22", "success": true, "latency_ms": 12, "checked_at": "2026-08-21T09:15:01Z", "complete": false},
{"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-80", "success": true, "latency_ms": 9, "checked_at": "2026-08-21T09:15:01Z", "complete": false},
{"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-443", "success": true, "latency_ms": 10, "checked_at": "2026-08-21T09:15:01Z", "complete": false},
{"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "tcp-8080","success": false,"latency_ms": 0, "checked_at": "2026-08-21T09:15:01Z", "complete": false},
{"ip_id": 42, "ip_address": "203.0.113.10", "check_type": "icmp", "success": true, "latency_ms": 5, "checked_at": "2026-08-21T09:15:01Z", "complete": true}
]
}
```
`complete: true` нужно проставить ровно на одном (обычно последнем)
результате в пачке — это сигнал "площадка N закончила зондирование этого
IP на данном проходе". До этого момента control-api не будет считать
данные с этой площадки завершёнными.
Ответ: `{"ok": true}`.
## Служебные и административные методы
### `GET /healthz`
Проверка живости процесса. Ответ: `{"ok": true}`. Используется в systemd/
внешних системах мониторинга.
### `GET /api/v1/admin/status`
Сводка по очереди — сколько IP в каком состоянии.
```json
{
"total_ips": 25,
"ips_by_state": {"queued": 10, "checking": 3, "done": 11, "failed": 1},
"total_validators": 4
}
```
### `GET /api/v1/admin/ips`
Полный список всех IP из очереди со всеми полями (см.
[USAGE.md](USAGE.md#значения-полей-ip) — расшифровка полей и статусов).
### `GET /api/v1/admin/ips/{ip}`
Детали по одному адресу: сам объект IP, все проверки текущей попытки и
вся история событий по нему.
```json
{
"ip": { "ID": 42, "IPAddress": "203.0.113.10", "State": "done", "OverallResult": "pass", "...": "..." },
"checks": [ {"Source": "egress", "CheckType": "https", "Target": "https://github.com", "Success": true, "...": "..."} ],
"events": [ {"EventType": "fip_associated", "OccurredAt": "...", "...": "..."} ]
}
```
> Обратите внимание: вложенные объекты `ip`/`checks`/`events` сериализуются
> без переопределения имён полей (используются имена Go-структур, например
> `IPAddress`, `State`, `Success`) — в отличие от методов для
> agent/prober, где поля в `snake_case`. Это осознанная асимметрия:
> административные методы — для человека/дашборда, а не для машинного
> протокола.
### `GET /api/v1/admin/validators`
Список всех валидаторов с их текущим состоянием (`unregistered`, `idle`,
`assigned`, `checking`, `unreachable`) и `CurrentIPID`, если валидатор
сейчас занят.
## Управление очередью и конфигурацией
Методы этого раздела — единственный способ менять состав очереди
(`ip_addresses`), список валидаторов, площадок (`sites`) и целей
проверки (`targets`/`check_types`) **без остановки процесса**: изменения
применяются немедленно и переживают последующий рестарт control-api. Все
тела запросов/ответов — `snake_case` (в отличие от `GET
/admin/status|ips|validators` выше, которые отдают сырые поля Go-структур
в PascalCase — эти два стиля сосуществуют осознанно, см. примечание к
`GET /api/v1/admin/ips/{ip}`).
**Источник истины.** `control-api.yaml` используется только как
одноразовый bootstrap для пустой базы данных: секции `validators`,
`sites`, `targets`, `check_types` читаются из YAML один раз, при самом
первом старте на пустых таблицах. Как только в соответствующей таблице
появилась хотя бы одна строка (через bootstrap либо через методы ниже) —
YAML для этой секции больше не перечитывается ни при одном последующем
рестарте; правки нужно вносить через API. Список IP-адресов
(`ip_addresses` в YAML) — исключение, он остаётся отдельным, всегда
аддитивным путём постановки в очередь при каждом старте (см.
[SETUP.md](SETUP.md#развёртывание-control-api)); он не конфликтует с
`POST /api/v1/admin/ips` ниже.
### `POST /api/v1/admin/ips`
Единая точка для двух задач: добавить новые адреса в очередь **и**
принудительно перепроверить уже завершённые — один и тот же вызов, разница
только в текущем состоянии каждого конкретного адреса. Список
обрабатывается в one transaction, в порядке следования адресов:
- адрес неизвестен control-api → добавляется в очередь как новый
(`queued`);
- адрес сейчас `done`/`failed` → принудительно перезапускается: сбрасывается
результат, `attempt_number` увеличивается, `retry_count` обнуляется,
адрес снова становится `queued`;
- адрес сейчас `queued` (ещё не взят в работу) → только переупорядочивается
под порядок текущего списка, повторно не добавляется;
- адрес сейчас активно проверяется (`assigning_fip` / `awaiting_self_check`
/ `checking` / `aggregating`) → не трогается вообще — нельзя запустить
вторую параллельную проверку одного и того же адреса.
Порядок обработки внутри одного вызова соответствует порядку адресов в
списке; повторная отправка того же списка позже даёт тот же относительный
порядок прогона.
Запрос:
```json
{"addresses": ["203.0.113.10", "203.0.113.11"]}
```
Ответ (`200`):
```json
{
"added": ["203.0.113.11"],
"requeued": ["203.0.113.10"],
"reordered": [],
"skipped_in_progress": []
}
```
`400`, если `addresses` пуст.
### `POST /api/v1/admin/ips/{ip}/cancel`
Принудительно останавливает проверку конкретного адреса, не дожидаясь
`checking_window_seconds` — работает из любого нетерминального состояния,
включая `queued` (в этом случае это просто удаление ещё не начатой
проверки из очереди). Если Floating IP уже привязан — отвязывается
(best-effort, как и при обычном завершении проверки); владеющий валидатор
освобождается. Итог записывается как `overall_result: "cancelled"`
(состояние `failed`).
Ответ: `{"ok": true}`. `404`, если адрес неизвестен. `409`, если адрес уже
в терминальном состоянии (`done`/`failed`/уже отменён) — отменять нечего.
### `DELETE /api/v1/admin/ips/{ip}`, `POST /api/v1/admin/ips/delete`, `POST /api/v1/admin/ips/clear`
**Безвозвратное удаление**, в отличие от `cancel` выше: строка `ip_queue`
и вся её история (`checks`, `events`) стираются физически, без возможности
восстановления. Работает из любого состояния, включая активно
проверяемое — если Floating IP привязан, он отвязывается тем же
best-effort способом, что и при `cancel`/обычном завершении, владеющий
валидатор освобождается.
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| DELETE | `/api/v1/admin/ips/{ip}` | — | `200 {"ok":true}` | `404` неизвестный адрес |
| POST | `/api/v1/admin/ips/delete` | `{"addresses":[...]}` | `200 {"deleted":[...],"not_found":[...]}` | `400` пустой список |
| POST | `/api/v1/admin/ips/clear` | — | `200 {"deleted":[...]}` | — |
`POST .../delete` удаляет ровно перечисленный список (неизвестные адреса
идут в `not_found`, не ошибка — тот же терпимый стиль, что у `POST
/api/v1/admin/ips`). `POST .../clear` удаляет **вообще всё**, что сейчас в
очереди, включая адреса в процессе проверки — самая опасная операция
этого API, используйте с осторожностью.
```bash
curl -s -X DELETE "$BASE/api/v1/admin/ips/203.0.113.10"
curl -s -X POST "$BASE/api/v1/admin/ips/delete" -d '{"addresses":["203.0.113.10","203.0.113.11"]}'
curl -s -X POST "$BASE/api/v1/admin/ips/clear"
```
### Валидаторы: `/api/v1/admin/config/validators`
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| GET | `/api/v1/admin/config/validators` | — | `[{"validator_id","os_port_id","state"}]` | |
| POST | `/api/v1/admin/config/validators` | `{"validator_id","os_port_id"}` | `201` | `409`, если `validator_id` уже существует |
| PUT | `/api/v1/admin/config/validators/{id}` | `{"os_port_id"}` | `200` | `404` |
| DELETE | `/api/v1/admin/config/validators/{id}` | — | `200` | `404`; `409`, если валидатор сейчас владеет IP |
### Площадки: `/api/v1/admin/config/sites`
Слотов ровно три (`index` ∈ {1, 2, 3}) — это ограничение схемы БД
(`ip_queue.site{1,2,3}_complete`), а не искусственное. Пустой список слотов
— штатный сценарий, отключающий inbound-проверки целиком (см.
[USAGE.md](USAGE.md#управление-площадками-проберами)).
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| GET | `/api/v1/admin/config/sites` | — | `[{"index","site_id"}]` (до 3 строк) | |
| PUT | `/api/v1/admin/config/sites/{index}` | `{"site_id"}` | `200` | `400`, если `index` не 1..3; `409`, если `site_id` уже занят другим слотом |
| DELETE | `/api/v1/admin/config/sites/{index}` | — | `200` | `404` |
### Группы целей: `/api/v1/admin/config/targets`
Группа целей — именованный список URL/адресов (например `stub-targets: [
"https://hub.docker.com", ...]`), на который затем ссылаются типы
проверок.
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| GET | `/api/v1/admin/config/targets` | — | `[{"name","targets"}]` | |
| PUT | `/api/v1/admin/config/targets/{group}` | `{"targets":[...]}` | `200` | `400`, если список пуст |
| DELETE | `/api/v1/admin/config/targets/{group}` | — | `200` | `404`; `409`, если группа используется каким-то `check_type` |
### Типы проверок: `/api/v1/admin/config/check-types`
Тип проверки (`https`, `icmp`, `ssh`, ...) ссылается на одну или несколько
групп целей по имени; именно развёрнутый список отсюда validator-agent
получает в `check_config` при `GET /api/v1/agents/{id}/assignment`.
| Метод | Путь | Тело | Успех | Ошибки |
|---|---|---|---|---|
| GET | `/api/v1/admin/config/check-types` | — | `[{"name","enabled","targets"}]` (`targets` — имена групп) | |
| PUT | `/api/v1/admin/config/check-types/{name}` | `{"enabled","targets":["group",...]}` | `200` | `400`, если названа несуществующая группа |
| DELETE | `/api/v1/admin/config/check-types/{name}` | — | `200` | `404` |
### Пример: конфигурация целиком через API, без единой строки в YAML
```bash
BASE=http://127.0.0.1:8080
curl -s -X POST "$BASE/api/v1/admin/config/validators" \
-d '{"validator_id":"validator_01","os_port_id":"port-abc123"}'
curl -s -X PUT "$BASE/api/v1/admin/config/targets/web" \
-d '{"targets":["https://hub.docker.com","https://github.com"]}'
curl -s -X PUT "$BASE/api/v1/admin/config/check-types/https" \
-d '{"enabled":true,"targets":["web"]}'
curl -s -X PUT "$BASE/api/v1/admin/config/sites/1" -d '{"site_id":"site-1"}'
# Поставить адрес в очередь и, отдельным вызовом позже, принудительно
# перепроверить его ещё раз — тот же метод, разница только в состоянии:
curl -s -X POST "$BASE/api/v1/admin/ips" -d '{"addresses":["203.0.113.10"]}'
curl -s -X POST "$BASE/api/v1/admin/ips" -d '{"addresses":["203.0.113.10"]}' # forced recheck
# Остановить проверку, не дожидаясь checking_window_seconds:
curl -s -X POST "$BASE/api/v1/admin/ips/203.0.113.10/cancel"
```
## Модель состояний и связь методов с ней
```
queued ──(control-api сам, без вызова API)──▶ assigning_fip
│
OpenStack FIP associate успешен
▼
awaiting_self_check
│
POST .../self-check {success:true}
▼
checking
│ POST .../results (agent) │ POST .../results (prober, по числу настроенных площадок)
▼ ▼
egress_complete=true siteN_complete=true (только для N, перечисленных в sites конфига)
│
egress + все НАСТРОЕННЫЕ площадки complete=true ИЛИ истекло checking_window_seconds
▼
aggregating
│
done (pass/partial/fail)
или failed
```
Переходы `queued → assigning_fip → awaiting_self_check` и финальная
агрегация выполняются control-api самостоятельно по таймеру (см.
`orchestrator.poll_interval_seconds`), явного HTTP-метода для их запуска
нет — это фоновый цикл (`Tick`), а не запрос/ответ.
Площадки (`siteN_complete`) — опциональны: сколько их учитывается,
целиком определяется текущим списком `sites` (0–3 записи, управляется
через `/api/v1/admin/config/sites` — см.
[выше](#управление-очередью-и-конфигурацией)). Пустой список — агрегация
ждёт только `egress_complete`, ни одна площадка не требуется. Подробнее —
[USAGE.md](USAGE.md#управление-площадками-проберами).
Три дополнительных перехода, все инициируются оператором через
`/api/v1/admin/ips*`, а не самим оркестратором:
- **любое нетерминальное состояние → `failed` (`overall_result:
"cancelled"`)** — `POST /api/v1/admin/ips/{ip}/cancel`;
- **`done`/`failed` → `queued` (новая попытка)** — `POST
/api/v1/admin/ips` с уже завершённым адресом в списке;
- **любое состояние → адрес физически исчезает из очереди**, вместе со
всей историей — `DELETE /api/v1/admin/ips/{ip}`, `POST
/api/v1/admin/ips/delete`, `POST /api/v1/admin/ips/clear` (см.
[выше](#delete-apiv1adminipsip-post-apiv1adminipsdelete-post-apiv1adminipsclear)).
Не путать с cancel — cancel сохраняет запись как историю (`failed`/
`cancelled`), delete стирает её целиком без возможности восстановления.
## Сквозной пример работы (curl)
Ниже — минимальный ручной прогон одного IP через API, как если бы вы
писали собственного клиента вместо `validator-agent`/`prober`. Полезно
для отладки и для понимания протокола.
```bash
BASE=http://127.0.0.1:8080
# 1. Регистрация валидатора (validator_01 уже должен быть в конфиге control-api)
curl -s -X POST "$BASE/api/v1/agents/register" \
-d '{"validator_id":"validator_01","hostname":"debug-host","agent_version":"manual"}'
# 2. Подождать, пока control-api (фоновым тиком) назначит IP и привяжет FIP —
# проверяем через admin/status или admin/ips, либо просто опрашиваем assignment
curl -s "$BASE/api/v1/agents/validator_01/assignment"
# => {"ip_id":1,"ip_address":"203.0.113.10","phase":"awaiting_self_check","check_config":[...]}
# 3. Self-check: спросить у ВНЕШНЕГО (вне облака) IP-echo сервиса, каким
# адресом мы наружу выглядим — это делает сам агент, control-api тут
# ни при чём (см. self_check.ip_echo_urls в validator-agent.yaml)
curl -s "https://api.ipify.org"
# => 203.0.113.10 (в реальном стенде это и есть проверка через FIP)
curl -s -X POST "$BASE/api/v1/agents/validator_01/self-check" \
-d '{"ip_id":1,"detected_egress_ip":"203.0.113.10","success":true,"detail":"matched"}'
# 4. Отправить результаты egress-проверок (по одному check_config пункту)
curl -s -X POST "$BASE/api/v1/agents/validator_01/results" \
-d '{"results":[{"ip_id":1,"check_type":"https","target":"https://github.com","success":true,"latency_ms":80,"checked_at":"2026-08-21T09:00:00Z"}]}'
# 5. Сообщить, что все egress-проверки выполнены
curl -s -X POST "$BASE/api/v1/agents/validator_01/complete" -d '{"ip_id":1}'
# 6. Со стороны пробера: узнать, что сейчас проверяется, и отправить результат
curl -s -X POST "$BASE/api/v1/probers/register" -d '{"site_id":"site-1"}'
curl -s "$BASE/api/v1/probers/site-1/assignments"
curl -s -X POST "$BASE/api/v1/probers/site-1/results" \
-d '{"results":[{"ip_id":1,"ip_address":"203.0.113.10","check_type":"icmp","success":true,"latency_ms":5,"checked_at":"2026-08-21T09:00:01Z","complete":true}]}'
# 7. Проверить итоговый результат (после того как control-api агрегирует)
curl -s "$BASE/api/v1/admin/ips/203.0.113.10" | python3 -m json.tool
```
Для полностью автоматизированного локального прогона (без ручных curl)
см. `scripts/run-local-e2e.sh` и [docs/LOCAL_E2E.md](LOCAL_E2E.md).